diff options
Diffstat (limited to 'public')
| -rw-r--r-- | public/bluebey-studio/index.html | 14 | ||||
| -rw-r--r-- | public/bluebey-studio/src/main.js | 63 | ||||
| -rw-r--r-- | public/bluebey-studio/src/style.css | 27 |
3 files changed, 95 insertions, 9 deletions
diff --git a/public/bluebey-studio/index.html b/public/bluebey-studio/index.html index db44f3c..7111b9b 100644 --- a/public/bluebey-studio/index.html +++ b/public/bluebey-studio/index.html @@ -107,7 +107,8 @@ if (window.matchMedia('(max-width: 768px), (max-height: 500px) and (pointer: coa ドラッグ:回転 / ホイール:ズーム / 右ドラッグ:移動 / ぶるべーをドラッグ:移動 </p> <p id="viewport-hint-touch" class="viewport-hint"> - 1本指でドラッグ:回転 / 2本指:ズーム・移動 / ぶるべーをドラッグ:移動 + 2本指でドラッグ:カメラ移動(ぶるべーは動きません)/ 2本指をつまむ:ズーム<br> + ぶるべーの上で1本指:ぶるべーを移動(上下で高さも)/ 何もない所で1本指:回転 </p> <div id="toast" role="status" aria-live="polite"></div> @@ -141,11 +142,20 @@ if (window.matchMedia('(max-width: 768px), (max-height: 500px) and (pointer: coa <li>気に入ったポーズは「ポーズを保存」でファイルに残せます。</li> </ul> + <h3>スマホでの操作</h3> + <ul> + <li><b>2本指でぐりぐり</b>:カメラ(見ている基準点)を動かします。ぶるべーは基準点から動きません。</li> + <li><b>2本指をつまむ(ピンチ)</b>:ズーム(寄る/引く)。</li> + <li><b>ぶるべーの上で1本指でぐりぐり</b>:ぶるべーを基準点から動かします。上下に動かすと高さも変わります。</li> + <li><b>何もない所で1本指でぐりぐり</b>:カメラが回ります。</li> + <li>パネル(下のシート)は、上の横線(ハンドル)をドラッグすると上下に動かせます。</li> + </ul> + <h3>表情</h3> <ul> <li>目:開き具合(まばたき・ウインク)、目線(上下左右)、瞳の大きさ、ハイライト、閉じ方の種類。</li> <li>口:笑いの深さ、口の開き、舌、口角の点、太さ、色。</li> - <li>「目線パッド」をドラッグすると両目が一緒に動きます。</li> + <li>「目線パッド」をドラッグすると、選んだ側の目線が動きます(「左右を連動させる」中は両目)。</li> <li>プリセット(にっこり・ウインク・びっくり…)から始めると早いです。</li> <li>「手描きパーツを使う」を選ぶと、元のGIMP製パーツに切り替わります。</li> </ul> diff --git a/public/bluebey-studio/src/main.js b/public/bluebey-studio/src/main.js index 49494a4..0008d48 100644 --- a/public/bluebey-studio/src/main.js +++ b/public/bluebey-studio/src/main.js @@ -1783,6 +1783,28 @@ async function init() { } /** + * Freeze the current camera frame into a canvas. + * + * The AR preview is a live video, so by the time the PNG finishes encoding the + * feed has moved on and the saved picture no longer matches what was on screen + * when the shutter was pressed. Snapping the frame first keeps the two in step. + */ + function snapshotBackdrop() { + if (state.view.background !== 'camera') return undefined; + const video = backdrop.el?.querySelector?.('video'); + if (!video || video.readyState < 2 || !video.videoWidth || !video.videoHeight) return undefined; + const frame = document.createElement('canvas'); + frame.width = video.videoWidth; + frame.height = video.videoHeight; + try { + frame.getContext('2d').drawImage(video, 0, 0); + } catch { + return undefined; + } + return frame; + } + + /** * Paint the backdrop *behind* the rendered model. * * WHY the scratch canvas and `destination-over`: `ctx` already holds the @@ -1792,13 +1814,13 @@ async function init() { * a scratch canvas first also lets the dark veil sit on top of the photo, which * it could not do once the photo had been slipped underneath. */ - function drawBackdropInto(ctx, width, height, scale) { - const source = backdropSource(); + function drawBackdropInto(ctx, width, height, scale, sourceOverride) { + const source = sourceOverride ?? backdropSource(); if (!source) return false; const style = backdropStyle(state.view); if (!style.visible) return false; - const iw = source.naturalWidth || source.videoWidth || 0; - const ih = source.naturalHeight || source.videoHeight || 0; + const iw = source.naturalWidth || source.videoWidth || source.width || 0; + const ih = source.naturalHeight || source.videoHeight || source.height || 0; if (!iw || !ih) return false; const back = document.createElement('canvas'); @@ -1807,13 +1829,18 @@ async function init() { const paint = back.getContext('2d'); // The same CSS the live layer uses: blur (bleeding past the edges), the fit, - // the mirror and the scale/offset transform. + // the mirror and the scale/offset transform. The offset is in the element's + // own (pre-scale) space, so it is multiplied by `scale` here, exactly as + // `transform: scale(s) translate(...)` does in CSS. const bleed = style.blur > 0 ? style.blur * 2 : 0; const w = width + bleed * 2; const h = height + bleed * 2; paint.save(); if (style.blur > 0) paint.filter = `blur(${style.blur}px)`; - paint.translate(width / 2 + style.offset.x * width, height / 2 + style.offset.y * height); + paint.translate( + width / 2 + style.scale * style.offset.x * width, + height / 2 + style.scale * style.offset.y * height, + ); paint.scale(style.scale * (style.mirror ? -1 : 1), style.scale); paint.translate(-width / 2, -height / 2); @@ -1878,6 +1905,11 @@ async function init() { function applyBackdrop() { const viewState = state.view; backdrop.apply(viewState); + // The AR shutter rides the viewport, not the panel, so it shows/hides here. + if (app.arShutter) app.arShutter.hidden = viewState.background !== 'camera'; + // The touch hint would sit under the shutter, so it steps aside in AR mode. + const touchHint = document.getElementById('viewport-hint-touch'); + if (touchHint) touchHint.style.visibility = viewState.background === 'camera' ? 'hidden' : ''; const url = viewState.background === 'preset' ? assetUrl(viewState.backgroundPreset, 'background') : viewState.background === 'image' @@ -1902,6 +1934,9 @@ async function init() { } async function captureImage({ scale = 2, transparent = null, lines = null } = {}) { + // Freeze the live camera frame up front, so the saved picture holds the frame + // that was on screen when the button was pressed (see `snapshotBackdrop`). + const frozenBackdrop = snapshotBackdrop(); return withHiddenHelpers(async () => { // The face texture is redrawn on a throttle (every 26 ms) while the render // loop runs, so a capture taken straight after a change could still hold the @@ -1954,7 +1989,7 @@ async function init() { const base = canvas.clientWidth || 1280; const outputScale = result.width / Math.max(1, base); let changed = false; - if (!wantsTransparent && drawBackdropInto(ctx, result.width, result.height, outputScale)) changed = true; + if (!wantsTransparent && drawBackdropInto(ctx, result.width, result.height, outputScale, frozenBackdrop)) changed = true; const gionItems = state.gion?.items ?? []; if (gionItems.length) { for (const item of gionItems) await ensureGionSheet(item.sheet); @@ -2434,6 +2469,20 @@ async function init() { // headings, and you open the one you want instead of scrolling past twelve. resizeViewport(); + // AR (カメラ=AR) get a camera-app style shutter over the viewport, so a photo + // is one tap away. It is a DOM layer over the canvas, so it never lands in the + // export; its visibility follows the background mode (see `applyBackdrop`). + const shutter = document.createElement('button'); + shutter.type = 'button'; + shutter.id = 'ar-shutter'; + shutter.className = 'ar-shutter'; + shutter.title = '写真を撮る'; + shutter.setAttribute('aria-label', '写真を撮る'); + shutter.hidden = true; + shutter.addEventListener('click', () => { savePNG(); }); + stage.append(shutter); + app.arShutter = shutter; + document.getElementById('btn-shot')?.addEventListener('click', () => app.actions.savePNG()); document.getElementById('btn-copy')?.addEventListener('click', () => app.actions.copyPNG()); document.getElementById('btn-help')?.addEventListener('click', toggleHelp); diff --git a/public/bluebey-studio/src/style.css b/public/bluebey-studio/src/style.css index f4c9270..61c53db 100644 --- a/public/bluebey-studio/src/style.css +++ b/public/bluebey-studio/src/style.css @@ -111,6 +111,33 @@ body:not(.panel-hidden) #stage { small-screen media query at the end of the viewport/panel section below. */ #viewport-hint-touch { display: none; } +/* AR (camera) mode's shutter: a camera-app button over the viewport. It is a DOM + layer, so it never appears in the exported PNG. */ +.ar-shutter { + position: absolute; + left: 50%; + bottom: calc(22px + env(safe-area-inset-bottom)); + transform: translateX(-50%); + z-index: 4; + width: 64px; + height: 64px; + padding: 0; + border-radius: 50%; + border: 4px solid #ffffff; + background: rgba(255, 255, 255, 0.35); + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35); + cursor: pointer; +} +.ar-shutter::after { + content: ""; + position: absolute; + inset: 6px; + border-radius: 50%; + background: #ffffff; +} +.ar-shutter:active::after { background: #d9d2e8; } +.ar-shutter[hidden] { display: none; } + /* -------------------------------------------------------------------- panel */ #panel { |
