diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-05 13:17:29 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-05 13:17:29 +0900 |
| commit | eecb25b1ac52ec87e382ff8249fa3dfb9e295c13 (patch) | |
| tree | 2c22e605d883f128366f4e43acbe81eea38d09ab /public/bluebey-studio/src/main.js | |
| parent | e22218a01f418fffc89d0c16d88ada84a764b448 (diff) | |
bluebey-studio: ARカメラにシャッターを追加し、スマホ操作の説明を追記
Diffstat (limited to 'public/bluebey-studio/src/main.js')
| -rw-r--r-- | public/bluebey-studio/src/main.js | 63 |
1 files changed, 56 insertions, 7 deletions
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); |
