aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src/main.js
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 /public/bluebey-studio/src/main.js
parent6258fcb99337aa596f576a9275a867e0b9b2841f (diff)
bluebey-studio: 背景写真に「隠すマスク」を追加
Diffstat (limited to 'public/bluebey-studio/src/main.js')
-rw-r--r--public/bluebey-studio/src/main.js121
1 files changed, 121 insertions, 0 deletions
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) {