aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-05 13:17:29 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-05 13:17:29 +0900
commiteecb25b1ac52ec87e382ff8249fa3dfb9e295c13 (patch)
tree2c22e605d883f128366f4e43acbe81eea38d09ab
parente22218a01f418fffc89d0c16d88ada84a764b448 (diff)
bluebey-studio: ARカメラにシャッターを追加し、スマホ操作の説明を追記
-rw-r--r--public/bluebey-studio/index.html14
-rw-r--r--public/bluebey-studio/src/main.js63
-rw-r--r--public/bluebey-studio/src/style.css27
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 {