From 2ced83398189ac47588ddde87fdbf5cae106aa4d Mon Sep 17 00:00:00 2001 From: Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:26:02 +0900 Subject: bluebey-studio: 背景写真に「隠すマスク」を追加 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/bluebey-studio/src/main.js | 121 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) (limited to 'public/bluebey-studio/src/main.js') 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 , 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) { -- cgit v1.3.1