aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-22 23:26:02 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-22 23:26:02 +0900
commit2ced83398189ac47588ddde87fdbf5cae106aa4d (patch)
treef8e22708d5f3261e0a890632d0e8800bd132c07f
parent6258fcb99337aa596f576a9275a867e0b9b2841f (diff)
bluebey-studio: 背景写真に「隠すマスク」を追加
-rw-r--r--public/bluebey-studio/src/floodfill.js80
-rw-r--r--public/bluebey-studio/src/main.js121
-rw-r--r--public/bluebey-studio/src/occlusion.js184
-rw-r--r--public/bluebey-studio/src/panel.js20
-rw-r--r--public/bluebey-studio/src/presets.js5
-rw-r--r--public/bluebey-studio/src/urlState.js12
6 files changed, 419 insertions, 3 deletions
diff --git a/public/bluebey-studio/src/floodfill.js b/public/bluebey-studio/src/floodfill.js
new file mode 100644
index 0000000..ef50d1d
--- /dev/null
+++ b/public/bluebey-studio/src/floodfill.js
@@ -0,0 +1,80 @@
+// src/floodfill.js
+//
+// The magic-wand fill behind the "hide ぶるべー" mask.
+//
+// Kept free of the DOM so it can be tested with a plain Uint8ClampedArray:
+// `pixels` is RGBA row-major, `mask` is the same layout, and the fill writes
+// opaque white into `mask` for every 4-connected pixel whose colour is close
+// enough to the seed's.
+
+/**
+ * How tolerant a slider value (0..100) is, in RGB distance.
+ *
+ * The scale runs to 1.5x the diagonal of the RGB cube so that the top of the
+ * slider still merges neighbouring tones in a photo instead of stopping at
+ * "almost white".
+ */
+export function toleranceDistance(tolerance) {
+ const n = Number(tolerance);
+ const value = Number.isFinite(n) ? Math.min(100, Math.max(0, n)) : 0;
+ return (value / 100) * 255 * 1.5;
+}
+
+/**
+ * Fill from `(x, y)`.
+ *
+ * @param {Uint8ClampedArray|Uint8Array} pixels RGBA source
+ * @param {Uint8ClampedArray|Uint8Array} mask RGBA destination (white = hide)
+ * @param {number} width
+ * @param {number} height
+ * @param {number} x seed column
+ * @param {number} y seed row
+ * @param {number} tolerance 0..100
+ * @returns {number} how many pixels were painted
+ */
+export function floodFill({ pixels, mask, width, height, x, y, tolerance = 32 }) {
+ if (!pixels || !mask || !(width > 0) || !(height > 0)) return 0;
+ const sx = Math.max(0, Math.min(width - 1, Math.round(Number(x) || 0)));
+ const sy = Math.max(0, Math.min(height - 1, Math.round(Number(y) || 0)));
+ const count = width * height;
+ const start = sy * width + sx;
+ const base = start * 4;
+ const sr = pixels[base];
+ const sg = pixels[base + 1];
+ const sb = pixels[base + 2];
+ const limit = toleranceDistance(tolerance) ** 2 * 3;
+
+ // 0 = untouched, 1 = queued, 2 = done. Marking on push keeps the stack to
+ // one entry per pixel, so a preallocated buffer can never overflow.
+ const seen = new Uint8Array(count);
+ const stack = new Int32Array(count);
+ let top = 0;
+ let painted = 0;
+ stack[top++] = start;
+ seen[start] = 1;
+
+ while (top > 0) {
+ const p = stack[--top];
+ seen[p] = 2;
+ const i = p * 4;
+ const dr = pixels[i] - sr;
+ const dg = pixels[i + 1] - sg;
+ const db = pixels[i + 2] - sb;
+ if (dr * dr + dg * dg + db * db > limit) continue;
+
+ mask[i] = 255;
+ mask[i + 1] = 255;
+ mask[i + 2] = 255;
+ mask[i + 3] = 255;
+ painted++;
+
+ const px = p % width;
+ const py = (p - px) / width;
+ if (px > 0 && !seen[p - 1]) { seen[p - 1] = 1; stack[top++] = p - 1; }
+ if (px < width - 1 && !seen[p + 1]) { seen[p + 1] = 1; stack[top++] = p + 1; }
+ if (py > 0 && !seen[p - width]) { seen[p - width] = 1; stack[top++] = p - width; }
+ if (py < height - 1 && !seen[p + width]) { seen[p + width] = 1; stack[top++] = p + width; }
+ }
+
+ return painted;
+}
diff --git a/public/bluebey-studio/src/main.js b/public/bluebey-studio/src/main.js
index f3db446..7847355 100644
--- a/public/bluebey-studio/src/main.js
+++ b/public/bluebey-studio/src/main.js
@@ -19,6 +19,7 @@ import { captionFontStack } from './textOutlines.js';
import { buildProp, PROP_DEFAULTS, disposeProp, applyPropText } from './props.js';
import { rollAll, decodeSeed } from './gacha.js';
import { encodeState, decodeState } from './urlState.js';
+import { openOcclusionEditor } from './occlusion.js';
import { createScreenOutline, BEHIND_LABEL, SOLID_LABEL, LEAF_LABEL, NOSE_LABEL } from './outline.js';
import { gionSheetUrl, stampRect, imageAspect, imageReady, drawGion, DEFAULT_STAMP_WIDTH } from './gion.js';
@@ -1307,6 +1308,8 @@ async function init() {
refresh,
loadHatModel,
recenterCamera,
+ editOcclusionMask,
+ clearOcclusionMask,
patch: (scope, value) => applyState({ [scope]: value }, { scope }),
syncPanel: () => app.panel?.sync(),
imageCanvas: (options) => captureImage(options),
@@ -1822,6 +1825,7 @@ async function init() {
function applyBackdrop() {
const viewState = state.view;
backdrop.apply(viewState);
+ applyOcclusionMask();
// 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.
@@ -1846,6 +1850,109 @@ async function init() {
image.src = url;
}
+ /** A Promise for an <img>, for the data URLs the state holds. */
+ function loadImageUrl(url) {
+ return new Promise((resolve, reject) => {
+ const image = new Image();
+ image.onload = () => resolve(image);
+ image.onerror = () => reject(new Error('画像を読み込めませんでした'));
+ image.src = url;
+ });
+ }
+
+ /** True when a mask should hide part of the character right now. */
+ function occlusionActive() {
+ return state.view.background === 'image'
+ && typeof state.view.backgroundImage === 'string'
+ && state.view.occlusionEnabled !== false
+ && typeof state.view.occlusionMask === 'string'
+ && state.view.occlusionMask.startsWith('data:');
+ }
+
+ /**
+ * Hide the character where the photo's object is, so the object reads as
+ * being in front. The mask is screen-space, which is exactly what the fixed
+ * backdrop wants; `occlusionInverse` is the same PNG inverted (opaque where
+ * the character may show), because CSS masks keep what is opaque.
+ */
+ function applyOcclusionMask() {
+ const inverse = occlusionActive() && typeof state.view.occlusionInverse === 'string'
+ ? state.view.occlusionInverse
+ : null;
+ const value = inverse ? `url("${inverse}")` : 'none';
+ canvas.style.maskImage = value;
+ canvas.style.webkitMaskImage = value;
+ canvas.style.maskSize = '100% 100%';
+ canvas.style.webkitMaskSize = '100% 100%';
+ canvas.style.maskRepeat = 'no-repeat';
+ canvas.style.webkitMaskRepeat = 'no-repeat';
+ canvas.style.maskPosition = '0 0';
+ canvas.style.webkitMaskPosition = '0 0';
+ app.needsRender = true;
+ }
+
+ /** Paint the current backdrop at `width`x`height` CSS pixels, on its own. */
+ function renderBackdropSurface(width, height, source) {
+ const surface = document.createElement('canvas');
+ surface.width = width;
+ surface.height = height;
+ drawBackdropInto(surface.getContext('2d'), width, height, 1, source);
+ return surface;
+ }
+
+ /**
+ * Open the magic-wand editor on the current photo. The mask is kept in the
+ * canvas' CSS pixel space, so it only lines up while the backdrop's fit /
+ * size / offset stay as they were when it was made.
+ */
+ async function editOcclusionMask() {
+ if (state.view.background !== 'image' || !state.view.backgroundImage) {
+ toast('「自分の画像」を背景にしたときだけ、隠すマスクを作れます');
+ return;
+ }
+ const rect = canvas.getBoundingClientRect();
+ if (rect.width < 20 || rect.height < 20) {
+ toast('画面が小さすぎて、マスクを作れません');
+ return;
+ }
+ let source;
+ try {
+ source = await loadImageUrl(state.view.backgroundImage);
+ } catch {
+ toast('背景の画像を読み込めませんでした');
+ return;
+ }
+ // A faint cut-out of the character, so the user can aim at what hides it.
+ let ghost = null;
+ try {
+ ghost = (await captureImage({ scale: 1, transparent: true })).canvas;
+ } catch (error) {
+ console.warn('下絵を作れませんでした', error);
+ }
+ const surface = renderBackdropSurface(
+ Math.max(1, Math.round(rect.width)),
+ Math.max(1, Math.round(rect.height)),
+ source,
+ );
+ const result = await openOcclusionEditor({ backdrop: surface, character: ghost, rect });
+ if (!result) return;
+ state.view.occlusionMask = result.mask;
+ state.view.occlusionInverse = result.inverse;
+ state.view.occlusionEnabled = true;
+ applyBackdrop();
+ app.panel?.sync();
+ toast('隠すマスクを設定しました。プレビューで確認してください');
+ }
+
+ function clearOcclusionMask() {
+ state.view.occlusionMask = null;
+ state.view.occlusionInverse = null;
+ state.view.occlusionEnabled = false;
+ applyBackdrop();
+ app.panel?.sync();
+ toast('隠すマスクを消しました');
+ }
+
function applyBackground() {
exporter.applyBackground(renderer, backgroundOf(state.view));
}
@@ -1906,6 +2013,20 @@ async function init() {
const base = canvas.clientWidth || 1280;
const outputScale = result.width / Math.max(1, base);
let changed = false;
+ // The mask punches the character out where the photo's object sits, so the
+ // backdrop drawn below shows through and reads as being in front.
+ if (!wantsTransparent && occlusionActive()) {
+ try {
+ const maskImage = await loadImageUrl(state.view.occlusionMask);
+ ctx.save();
+ ctx.globalCompositeOperation = 'destination-out';
+ ctx.drawImage(maskImage, 0, 0, result.width, result.height);
+ ctx.restore();
+ changed = true;
+ } catch (error) {
+ console.warn('隠すマスクを適用できませんでした', error);
+ }
+ }
if (!wantsTransparent && drawBackdropInto(ctx, result.width, result.height, outputScale, frozenBackdrop)) changed = true;
const gionItems = state.gion?.items ?? [];
if (gionItems.length) {
diff --git a/public/bluebey-studio/src/occlusion.js b/public/bluebey-studio/src/occlusion.js
new file mode 100644
index 0000000..3016dd7
--- /dev/null
+++ b/public/bluebey-studio/src/occlusion.js
@@ -0,0 +1,184 @@
+// src/occlusion.js
+//
+// A tiny "hide ぶるべー behind this" mask editor.
+//
+// WHY: the background is a fixed image, so hiding the character behind a tree
+// is a 2D masking job, not a depth-estimation one. The user taps the object;
+// a magic wand (see floodfill.js) marks it. The editor hands back two PNGs:
+//
+// mask - opaque where the object is, used by the export to punch the
+// character out there (`destination-out`)
+// inverse - transparent where the object is, used as a CSS `mask-image` on
+// the WebGL canvas so the live preview hides it too
+//
+// Both are in the canvas' CSS pixel space, so the editor is handed the
+// backdrop *as already painted* (fit, scale, offset, blur, darken applied by
+// main.js) rather than the raw photo. That keeps the mask aligned with what
+// the export will draw.
+
+import { floodFill } from './floodfill.js';
+
+const DEFAULT_TOLERANCE = 32;
+
+/**
+ * @param {HTMLCanvasElement} backdrop the backdrop painted at the canvas' CSS
+ * size (the same pixels the export will composite)
+ * @param {HTMLCanvasElement|null} character a transparent-background render of
+ * the character, drawn faintly so the user can aim
+ * @param {{ left:number, top:number, width:number, height:number }} rect the
+ * canvas' on-screen rectangle
+ * @returns {Promise<{mask:string, inverse:string}|null>} null when cancelled
+ */
+export function openOcclusionEditor({ backdrop, character = null, rect }) {
+ return new Promise((resolve) => {
+ const width = Math.max(1, Math.round(rect.width));
+ const height = Math.max(1, Math.round(rect.height));
+
+ const root = document.createElement('div');
+ root.className = 'occlusion-editor';
+ root.style.cssText = [
+ 'position:fixed',
+ `left:${rect.left}px`,
+ `top:${rect.top}px`,
+ `width:${width}px`,
+ `height:${height}px`,
+ 'z-index:100',
+ 'background:#111',
+ 'overflow:hidden',
+ 'touch-action:none',
+ 'user-select:none',
+ 'font:13px system-ui, "Hiragino Sans", "Yu Gothic UI", sans-serif',
+ ].join(';');
+
+ // The backdrop, exactly as the studio paints it.
+ const photo = document.createElement('canvas');
+ photo.width = width;
+ photo.height = height;
+ photo.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;cursor:crosshair;';
+ const pctx = photo.getContext('2d', { willReadFrequently: true });
+ pctx.drawImage(backdrop, 0, 0, width, height);
+ const pixels = pctx.getImageData(0, 0, width, height).data;
+
+ // A faint copy of ぶるべー, so the user can see what is being covered.
+ const ghost = document.createElement('canvas');
+ ghost.width = width;
+ ghost.height = height;
+ ghost.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;opacity:0.55;pointer-events:none;';
+ if (character) ghost.getContext('2d').drawImage(character, 0, 0, width, height);
+
+ // The mask: opaque white where the object is.
+ const mask = document.createElement('canvas');
+ mask.width = width;
+ mask.height = height;
+ mask.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;opacity:0.55;pointer-events:none;';
+ const mctx = mask.getContext('2d');
+ const maskImage = mctx.createImageData(width, height);
+
+ const bar = document.createElement('div');
+ bar.style.cssText = [
+ 'position:absolute',
+ 'left:0', 'right:0', 'top:0',
+ 'display:flex', 'flex-wrap:wrap', 'gap:8px', 'align-items:center',
+ 'padding:8px 10px',
+ 'background:rgba(20,16,28,0.86)', 'color:#fff',
+ 'box-sizing:border-box',
+ ].join(';');
+
+ const title = document.createElement('strong');
+ title.textContent = '隠すマスク';
+
+ const hintText = document.createElement('span');
+ hintText.textContent = '隠したい物(木・建物など)をタップ。何回でも追加できます';
+ hintText.style.cssText = 'opacity:0.8;flex:1 1 220px;';
+
+ const toleranceWrap = document.createElement('label');
+ toleranceWrap.style.cssText = 'display:flex;gap:6px;align-items:center;white-space:nowrap;';
+ toleranceWrap.append(document.createTextNode('にじみ'));
+ const tolerance = document.createElement('input');
+ tolerance.type = 'range';
+ tolerance.min = '0';
+ tolerance.max = '100';
+ tolerance.step = '1';
+ tolerance.value = String(DEFAULT_TOLERANCE);
+ tolerance.style.width = '110px';
+ toleranceWrap.append(tolerance);
+
+ const button = (label, onClick) => {
+ const el = document.createElement('button');
+ el.type = 'button';
+ el.textContent = label;
+ el.style.cssText = 'padding:4px 10px;border:1px solid rgba(255,255,255,0.35);border-radius:6px;background:rgba(255,255,255,0.1);color:#fff;cursor:pointer;';
+ el.addEventListener('click', onClick);
+ return el;
+ };
+
+ const undoStack = [];
+
+ bar.append(
+ title,
+ hintText,
+ toleranceWrap,
+ button('クリア', () => {
+ undoStack.length = 0;
+ maskImage.data.fill(0);
+ mctx.putImageData(maskImage, 0, 0);
+ }),
+ button('キャンセル', () => { cleanup(); resolve(null); }),
+ button('決定', () => {
+ cleanup();
+ resolve({ mask: mask.toDataURL('image/png'), inverse: invertedDataUrl(mask, width, height) });
+ }),
+ );
+
+ root.append(photo, ghost, mask, bar);
+ document.body.append(root);
+
+ function cleanup() {
+ root.remove();
+ window.removeEventListener('keydown', onKeydown);
+ }
+
+ /** @param {string} dataUrl */
+ function invertedDataUrl(source, w, h) {
+ const out = document.createElement('canvas');
+ out.width = w;
+ out.height = h;
+ const ctx = out.getContext('2d');
+ ctx.fillStyle = '#fff';
+ ctx.fillRect(0, 0, w, h);
+ ctx.globalCompositeOperation = 'destination-out';
+ ctx.drawImage(source, 0, 0);
+ return out.toDataURL('image/png');
+ }
+
+ photo.addEventListener('pointerdown', (event) => {
+ if (event.button !== 0) return;
+ const r = photo.getBoundingClientRect();
+ const before = new Uint8ClampedArray(maskImage.data);
+ const count = floodFill({
+ pixels,
+ mask: maskImage.data,
+ width,
+ height,
+ x: event.clientX - r.left,
+ y: event.clientY - r.top,
+ tolerance: Number(tolerance.value),
+ });
+ if (!count) return;
+ undoStack.push(before);
+ if (undoStack.length > 5) undoStack.shift();
+ mctx.putImageData(maskImage, 0, 0);
+ });
+
+ // Ctrl/⌘+Z takes back the last tap, which is the mistake people make most.
+ function onKeydown(event) {
+ if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 'z') return;
+ const before = undoStack.pop();
+ if (!before) return;
+ maskImage.data.set(before);
+ mctx.putImageData(maskImage, 0, 0);
+ event.preventDefault();
+ }
+ window.addEventListener('keydown', onKeydown);
+ });
+}
diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js
index a0b2611..69c8454 100644
--- a/public/bluebey-studio/src/panel.js
+++ b/public/bluebey-studio/src/panel.js
@@ -1443,6 +1443,10 @@ export function buildPanel(app, root) {
const dataUrl = await app.backdrop.loadImageFile(file);
state.view.backgroundImage = dataUrl;
state.view.background = 'image';
+ // A mask describes the old photo; keeping it would hide the wrong thing.
+ state.view.occlusionMask = null;
+ state.view.occlusionInverse = null;
+ state.view.occlusionEnabled = false;
app.actions.refresh('view');
sync();
toast(`背景の画像「${file.name}」を読み込みました`);
@@ -1456,6 +1460,19 @@ export function buildPanel(app, root) {
items: [{ id: 'bgimg', label: '画像を読み込む', onClick: () => { if (confirmImageLicense()) backgroundImagePicker.click(); } }],
}));
+ // "Hide ぶるべー behind this": only meaningful for a user photo, and the
+ // clear button only when a mask exists. Both live in one wrapper so the
+ // syncer can show/hide them together.
+ const occlusionEdit = buttons({
+ items: [{ id: 'mask-edit', label: '隠すマスクを作る', onClick: () => { app.actions.editOcclusionMask(); } }],
+ });
+ const occlusionClear = buttons({
+ items: [{ id: 'mask-clear', label: 'マスクを消す', onClick: () => { app.actions.clearOcclusionMask(); } }],
+ });
+ const occlusionGroup = h('div', { style: { display: 'none' } }, occlusionEdit.el, occlusionClear.el,
+ hint('写真の中の物(木・建物など)をタップすると、その形にぶるべーが隠れます。背景の「合わせ方」や「大きさ」を変えると、マスクの位置がずれます。'));
+ backdropSection.add(occlusionGroup);
+
const backgroundFitSegment = segmented({
label: '合わせ方',
options: [
@@ -2413,6 +2430,9 @@ export function buildPanel(app, root) {
backgroundBlurSlider.set(state.view?.backgroundBlur ?? 0);
backgroundDarkenSlider.set(state.view?.backgroundDarken ?? 0);
backgroundScaleSlider.set(state.view?.backgroundScale ?? 1);
+ const hasOcclusionMask = typeof state.view?.occlusionMask === 'string' && state.view.occlusionMask !== '';
+ occlusionGroup.style.display = background === 'image' ? '' : 'none';
+ occlusionClear.el.style.display = hasOcclusionMask ? '' : 'none';
cameraFacingSegment.set(state.view?.cameraFacing ?? 'environment');
cameraMirrorToggle.set(state.view?.cameraMirror === true);
gyroToggle.set(state.view?.gyro === true);
diff --git a/public/bluebey-studio/src/presets.js b/public/bluebey-studio/src/presets.js
index a31c952..75a8373 100644
--- a/public/bluebey-studio/src/presets.js
+++ b/public/bluebey-studio/src/presets.js
@@ -63,6 +63,11 @@ export function defaultState() {
backgroundDarken: 0, // 0..1 dark veil, helps the character read
backgroundScale: 1,
backgroundOffset: { x: 0, y: 0 },
+ // "Hide ぶるべー behind a tree" mask, made by tapping the photo. `mask` is
+ // opaque where the object is; the export punches the character out there.
+ occlusionMask: null,
+ occlusionInverse: null,
+ occlusionEnabled: false,
// AR: which camera, and whether to mirror (front camera feels mirrored)
cameraFacing: 'environment', // environment | user
cameraMirror: false,
diff --git a/public/bluebey-studio/src/urlState.js b/public/bluebey-studio/src/urlState.js
index d6fb6bd..e1e5e3e 100644
--- a/public/bluebey-studio/src/urlState.js
+++ b/public/bluebey-studio/src/urlState.js
@@ -44,9 +44,10 @@ const TAG_PLAIN = '0';
const BASE64URL = /^[A-Za-z0-9_-]+$/;
/**
- * A copy of `state` that is safe to put in a URL. Only the inline background
- * image is dropped (replaced with `null`), and only when it is a `data:` URL
- * long enough to blow up the link.
+ * A copy of `state` that is safe to put in a URL. The inline background image
+ * and the occlusion mask are both multi-megabyte data URLs, so they are
+ * dropped (a link cannot carry them) and the mask is marked off; everything
+ * else is kept.
*/
export function stripForUrl(state) {
// A JSON round-trip gives the copy for free and drops anything that could not
@@ -58,6 +59,11 @@ export function stripForUrl(state) {
if (typeof image === 'string' && image.startsWith('data:') && image.length > MAX_INLINE_IMAGE) {
copy.view.backgroundImage = null;
}
+ if (copy.view && (copy.view.occlusionMask || copy.view.occlusionInverse)) {
+ copy.view.occlusionMask = null;
+ copy.view.occlusionInverse = null;
+ copy.view.occlusionEnabled = false;
+ }
return copy;
}