diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-22 23:26:02 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-22 23:26:02 +0900 |
| commit | 2ced83398189ac47588ddde87fdbf5cae106aa4d (patch) | |
| tree | f8e22708d5f3261e0a890632d0e8800bd132c07f /public | |
| parent | 6258fcb99337aa596f576a9275a867e0b9b2841f (diff) | |
bluebey-studio: 背景写真に「隠すマスク」を追加
Diffstat (limited to 'public')
| -rw-r--r-- | public/bluebey-studio/src/floodfill.js | 80 | ||||
| -rw-r--r-- | public/bluebey-studio/src/main.js | 121 | ||||
| -rw-r--r-- | public/bluebey-studio/src/occlusion.js | 184 | ||||
| -rw-r--r-- | public/bluebey-studio/src/panel.js | 20 | ||||
| -rw-r--r-- | public/bluebey-studio/src/presets.js | 5 | ||||
| -rw-r--r-- | public/bluebey-studio/src/urlState.js | 12 |
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; } |
