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/bluebey-studio/src/occlusion.js | |
| parent | 6258fcb99337aa596f576a9275a867e0b9b2841f (diff) | |
bluebey-studio: 背景写真に「隠すマスク」を追加
Diffstat (limited to 'public/bluebey-studio/src/occlusion.js')
| -rw-r--r-- | public/bluebey-studio/src/occlusion.js | 184 |
1 files changed, 184 insertions, 0 deletions
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); + }); +} |
