From eecb25b1ac52ec87e382ff8249fa3dfb9e295c13 Mon Sep 17 00:00:00 2001 From: Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:17:29 +0900 Subject: bluebey-studio: ARカメラにシャッターを追加し、スマホ操作の説明を追記 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/bluebey-studio/index.html | 14 +++++++-- public/bluebey-studio/src/main.js | 63 ++++++++++++++++++++++++++++++++----- public/bluebey-studio/src/style.css | 27 ++++++++++++++++ 3 files changed, 95 insertions(+), 9 deletions(-) (limited to 'public') 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 ドラッグ:回転 / ホイール:ズーム / 右ドラッグ:移動 / ぶるべーをドラッグ:移動

- 1本指でドラッグ:回転 / 2本指:ズーム・移動 / ぶるべーをドラッグ:移動 + 2本指でドラッグ:カメラ移動(ぶるべーは動きません)/ 2本指をつまむ:ズーム
+ ぶるべーの上で1本指:ぶるべーを移動(上下で高さも)/ 何もない所で1本指:回転

@@ -141,11 +142,20 @@ if (window.matchMedia('(max-width: 768px), (max-height: 500px) and (pointer: coa
  • 気に入ったポーズは「ポーズを保存」でファイルに残せます。
  • +

    スマホでの操作

    + +

    表情

    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 @@ -1782,6 +1782,28 @@ async function init() { return backdropImage; } + /** + * 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. * @@ -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 { -- cgit v1.3.1