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(-)
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
気に入ったポーズは「ポーズを保存」でファイルに残せます。
+ スマホでの操作
+
+ - 2本指でぐりぐり:カメラ(見ている基準点)を動かします。ぶるべーは基準点から動きません。
+ - 2本指をつまむ(ピンチ):ズーム(寄る/引く)。
+ - ぶるべーの上で1本指でぐりぐり:ぶるべーを基準点から動かします。上下に動かすと高さも変わります。
+ - 何もない所で1本指でぐりぐり:カメラが回ります。
+ - パネル(下のシート)は、上の横線(ハンドル)をドラッグすると上下に動かせます。
+
+
表情
- 目:開き具合(まばたき・ウインク)、目線(上下左右)、瞳の大きさ、ハイライト、閉じ方の種類。
- 口:笑いの深さ、口の開き、舌、口角の点、太さ、色。
- - 「目線パッド」をドラッグすると両目が一緒に動きます。
+ - 「目線パッド」をドラッグすると、選んだ側の目線が動きます(「左右を連動させる」中は両目)。
- プリセット(にっこり・ウインク・びっくり…)から始めると早いです。
- 「手描きパーツを使う」を選ぶと、元のGIMP製パーツに切り替わります。
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