diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-09 08:33:42 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-09 08:33:42 +0900 |
| commit | 9deb6762ef5794b340943e2d2845da79c437dce5 (patch) | |
| tree | 817a24ae56d7b06f5964f68682432ccb9febff02 /bluebey-studio/src/main.js | |
| parent | 1b883a70b9d3427e7cb65eaf99d2aa7ca5a1cb1c (diff) | |
bluebey-studio: 準拠版(フキダシ・眉毛・髪・ひげ・ほっぺ・体の色を削除)
ぶるべーはしゃべらない・姿を変えない方針に合わせた準拠版。セリフ/ナレーション/吹き出し・口パク・眉毛(ゴルゴ等)・髪(頭の模様)・ひげ・ほっぺ・体の色/テーマを削除。目・口・眼鏡・帽子・鼻ちょうちん・ポーズ・背景・小物・光・書き出しは維持。caption.js / mouthFlap.js は削除。
Diffstat (limited to 'bluebey-studio/src/main.js')
| -rw-r--r-- | bluebey-studio/src/main.js | 340 |
1 files changed, 41 insertions, 299 deletions
diff --git a/bluebey-studio/src/main.js b/bluebey-studio/src/main.js index 20c6c7d..f861ade 100644 --- a/bluebey-studio/src/main.js +++ b/bluebey-studio/src/main.js @@ -6,7 +6,7 @@ import { Face } from './face.js'; import { Styles, isLineStyle } from './styles.js'; import { Rig } from './rig.js'; import { Animator, createRecorder, startRecording, stopRecording } from './animation.js'; -import { defaultState, applyPatch, THEMES } from './presets.js'; +import { defaultState, applyPatch } from './presets.js'; import { buildPanel } from './panel.js'; import * as exporter from './exporter.js'; import { toast } from './ui.js'; @@ -15,12 +15,10 @@ import { createLook } from './look.js'; import { createClipper } from './clip.js'; import { buildHat } from './hats.js'; import { createBackdrop, backdropStyle } from './background.js'; -import { drawCaption, layoutCaption } from './caption.js'; -import { loadFont, captionFontStack } from './textOutlines.js'; +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 { createMouthFlap, levelToMouth } from './mouthFlap.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'; @@ -29,16 +27,13 @@ const DEG = Math.PI / 180; const RAD_TO_DEG = 180 / Math.PI; const MODEL_URL = 'assets/bluebey.glb'; -/** The caption font is only fetched when a bubble is first used. */ -const CAPTION_FONT_TTF = 'assets/fonts/bluebey-caption.ttf'; -const CAPTION_FONT_WOFF2 = 'assets/fonts/bluebey-caption.woff2'; /** How far the eyes can swing for a look-at target, in radians. */ const MAX_LOOK_YAW = 0.5; const MAX_LOOK_PITCH = 0.34; /** Undo steps are labelled by the part of the state that changed. */ const SCOPE_LABELS = { - all: '変更', render: '見た目', face: '表情', view: 'カメラ・背景', pose: 'ポーズ', caption: 'セリフ', gion: '擬音', + all: '変更', render: '見た目', face: '表情', view: 'カメラ・背景', pose: 'ポーズ', gion: '擬音', }; /** @@ -55,10 +50,6 @@ function assetUrl(file, kind) { const beards = globalThis.__BLUEBEY_BEARDS__ ?? {}; return beards[file] ?? `assets/beards/${file}`; } - if (kind === 'brow') { - const brows = globalThis.__BLUEBEY_BROWS__ ?? {}; - return brows[file] ?? `assets/brows/${file}`; - } return inlined[file] ?? `assets/fonts/${file}`; } @@ -201,15 +192,14 @@ async function init() { let resizeBurst = 0; /** Declared early: the viewport can resize before the model has loaded. */ - let captionCanvas = null; + let overlayCanvas = null; /** Set while undo/redo replays a snapshot, so it is not recorded again. */ let suspendHistory = false; // ------------------------------------------------------------------- 擬音 - // `redrawCaption` runs from the very first `resizeViewport`, so the sheet cache + // `redrawOverlay` runs from the very first `resizeViewport`, so the sheet cache // and the drag handles have to exist before it does (a `const` cannot be read - // before it is evaluated). The drawing itself lives further down, next to the - // caption overlay it is layered under. + // before it is evaluated). The drawing itself lives further down. const gionImages = new Map(); // sheet name -> loaded Image const gionLoading = new Map(); // sheet name -> in-flight Promise const gionHandles = new Map(); // stamp id -> transparent drag div @@ -241,7 +231,7 @@ async function init() { renderer.setSize(width, height, false); outline.setSize(width * quality, height * quality); view.resize(); - redrawCaption(); + redrawOverlay(); app.needsRender = true; } resizeViewport(); @@ -532,60 +522,6 @@ async function init() { }); const animator = new Animator({ state }); - // Beard drawings supplied as files (`assets/beards/<kind>.png`): a kind that has - // one is drawn from the file instead of the built-in strokes. A missing file is - // skipped, so the app works before any are added. - void (async () => { - // Each entry: a beard kind and the drawing in `assets/beards/`. A file ending - // `-right.png` is one side of a moustache; it is drawn twice (mirrored) with a - // `spacing` gap between them. A kind with no entry uses the built-in strokes. - const files = [ - { kind: 'scotch', file: 'scotch.png' }, - { kind: 'kaiser', file: 'kaiser-right.png' }, - ]; - const loaded = {}; - await Promise.all(files.map(async ({ kind, file }) => { - try { - const response = await fetch(assetUrl(file, 'beard')); - if (!response.ok) return; - loaded[kind] = { - image: await createImageBitmap(await response.blob()), - half: file.endsWith('-right.png'), - }; - } catch { - /* a missing drawing falls back to the built-in strokes */ - } - })); - if (Object.keys(loaded).length) { - face.setBeardImages(loaded); - app.needsRender = true; - } - })(); - - // Brow drawings supplied as files (`assets/brows/<kind>.png`). A file ending - // `-right.png` is one side; it is drawn twice (mirrored) at each eye. Missing - // files are skipped, so the built-in ゴル風 strokes are the fallback. - void (async () => { - const files = [{ kind: 'gol', file: 'gol-right.png' }]; - const loaded = {}; - await Promise.all(files.map(async ({ kind, file }) => { - try { - const response = await fetch(assetUrl(file, 'brow')); - if (!response.ok) return; - loaded[kind] = { - image: await createImageBitmap(await response.blob()), - half: file.endsWith('-right.png'), - }; - } catch { - /* a missing drawing falls back to the built-in strokes */ - } - })); - if (Object.keys(loaded).length) { - face.setBrowImages(loaded); - app.needsRender = true; - } - })(); - // ------------------------------------------------------------------- look const look = createLook({ renderer, @@ -645,170 +581,39 @@ async function init() { const storyThumbs = new Map(); let storySeq = 0; - // ------------------------------------------------------- caption overlay - // The bubble is drawn into its own 2D canvas laid over the WebGL one, with the - // very same routine the PNG export uses - so the preview cannot lie. - captionCanvas = document.createElement('canvas'); - captionCanvas.id = 'caption-layer'; - captionCanvas.style.cssText = + // ------------------------------------------------------- 擬音 overlay + // The 擬音 stamps are drawn into their own 2D canvas laid over the WebGL one. + overlayCanvas = document.createElement('canvas'); + overlayCanvas.id = 'overlay-layer'; + overlayCanvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:2'; - stage.append(captionCanvas); - - /** opentype font for outline export and text layout (lazily fetched). */ - let captionFont = null; - let captionFontPromise = null; - /** Where each bubble was last drawn, in CSS pixels (in state order). */ - const CAPTION_KEYS = ['caption', 'caption2', 'narration']; - const captionBoxes = new Map(); - const captionHandles = new Map(); - /** Set while a bubble is being dragged. */ - let captionDrag = null; - - // The bubbles are draggable, but the caption canvas has to stay - // `pointer-events: none`: it covers the whole viewport, and the orbit controls - // and the bone picking live underneath it. So each bubble gets a separate - // transparent drag box, parked exactly over it by `positionCaptionHandles`. - for (const key of CAPTION_KEYS) { - const handle = document.createElement('div'); - handle.id = `caption-handle-${key}`; - handle.style.cssText = 'position:absolute;display:none;cursor:move;' - + 'touch-action:none;pointer-events:auto;z-index:3'; - handle.addEventListener('pointerdown', (event) => startCaptionDrag(event, key)); - handle.addEventListener('pointermove', moveCaptionDrag); - handle.addEventListener('pointerup', endCaptionDrag); - handle.addEventListener('pointercancel', endCaptionDrag); - handle.dataset.captionHandle = '1'; - stage.append(handle); - captionHandles.set(key, handle); - } - - /** Park each drag target over its bubble (or hide it when it is not shown). */ - function positionCaptionHandles() { - for (const key of CAPTION_KEYS) { - const handle = captionHandles.get(key); - const box = captionBoxes.get(key); - if (!handle) continue; - if (!box) { - handle.style.display = 'none'; - continue; - } - // A few pixels of slack, so the rounded corners are still easy to grab. - handle.style.display = 'block'; - handle.style.left = `${box.x - 4}px`; - handle.style.top = `${box.y - 4}px`; - handle.style.width = `${box.w + 8}px`; - handle.style.height = `${box.h + 8}px`; - } - } - - function startCaptionDrag(event, key) { - const caption = state[key]; - const box = captionBoxes.get(key); - if (caption?.enabled !== true || !box) return; - // Touching a bubble also selects it in the panel, so the sliders edit the one - // you just grabbed. - app.panel?.selectCaption?.(key); - event.preventDefault(); - event.stopPropagation(); - // Not all pointers can be captured (and a synthetic one cannot), so a - // failure here must not stop the drag from starting. - try { - captionHandles.get(key)?.setPointerCapture(event.pointerId); - } catch { - /* capture is a nicety, not a requirement */ - } - captionDrag = { - key, - pointerId: event.pointerId, - fromX: event.clientX, - fromY: event.clientY, - boxX: box.x, - boxY: box.y, - }; - } - - function moveCaptionDrag(event) { - if (!captionDrag || event.pointerId !== captionDrag.pointerId) return; - const caption = state[captionDrag.key]; - if (!caption) return; - event.preventDefault(); - const width = stage.clientWidth || 1280; - const height = stage.clientHeight || 800; - caption.x = (captionDrag.boxX + (event.clientX - captionDrag.fromX)) / width; - caption.y = (captionDrag.boxY + (event.clientY - captionDrag.fromY)) / height; - // Store back what the layout will actually use, so pushing past an edge does - // not pile up an out-of-range value that has to be undone before the bubble - // moves again. `refresh` redraws the bubble and records one undo step (the - // history coalesces a run of the same label). - const layout = layoutCaption(caption, { width, height, scale: 1, font: captionFont }); - if (width > 0) caption.x = layout.box.x / width; - if (height > 0) caption.y = layout.box.y / height; - refresh('caption'); - } - - function endCaptionDrag(event) { - if (!captionDrag || (event && event.pointerId !== captionDrag.pointerId)) return; - captionDrag = null; - // The 横位置 / 縦位置 sliders catch up when the drag ends, not every pixel. - app.actions.syncPanel?.(); - } - - async function ensureCaptionFont() { - if (captionFontPromise) return captionFontPromise; - captionFontPromise = (async () => { - try { - const face = new FontFace('M PLUS Rounded 1c', `url(${assetUrl('bluebey-caption.woff2')})`); - document.fonts.add(await face.load()); - } catch (error) { - console.warn('[bluebey] caption font face failed', error); - } - try { - captionFont = await loadFont(assetUrl('bluebey-caption.ttf')); - } catch (error) { - console.warn('[bluebey] caption outlines unavailable', error); - } - redrawCaption(); - return captionFont; - })(); - return captionFontPromise; - } + stage.append(overlayCanvas); - function redrawCaption() { - if (!captionCanvas) return; + function redrawOverlay() { + if (!overlayCanvas) return; const width = stage.clientWidth || 1280; const height = stage.clientHeight || 800; const ratio = Math.min(2, window.devicePixelRatio || 1); const wantW = Math.round(width * ratio); const wantH = Math.round(height * ratio); - if (captionCanvas.width !== wantW || captionCanvas.height !== wantH) { - captionCanvas.width = wantW; - captionCanvas.height = wantH; + if (overlayCanvas.width !== wantW || overlayCanvas.height !== wantH) { + overlayCanvas.width = wantW; + overlayCanvas.height = wantH; } - const ctx = captionCanvas.getContext('2d'); + const ctx = overlayCanvas.getContext('2d'); ctx.setTransform(ratio, 0, 0, ratio, 0, 0); ctx.clearRect(0, 0, width, height); - // 擬音はセリフより背面に置く: the bubbles stay readable on top of them. const gionItems = state.gion?.items ?? []; for (const item of gionItems) ensureGionSheet(item.sheet); drawGion(ctx, gionItems, gionImages, { width, height, scale: 1 }); - for (const key of CAPTION_KEYS) { - const caption = state[key]; - captionBoxes.delete(key); - if (!caption?.enabled) continue; - // The vendored font is only fetched once a bubble is actually used; when it - // arrives `ensureCaptionFont` redraws this canvas. - if (!captionFontPromise) void ensureCaptionFont(); - captionBoxes.set(key, drawCaption(ctx, caption, { width, height, scale: 1, font: captionFont })); - } - positionCaptionHandles(); ensureGionHandles(gionItems); positionGionHandles(gionItems); } // ----------------------------------------------------------------- 擬音 - // A stamp is a crop of a sheet, drawn on the caption canvas *under* the bubbles. - // The canvas is `pointer-events: none`, so - exactly like a caption - each stamp - // gets its own transparent drag box, parked over it by `positionGionHandles`. + // A stamp is a crop of a sheet, drawn on the overlay canvas. + // The canvas is `pointer-events: none`, so each stamp gets its own transparent + // drag box, parked over it by `positionGionHandles`. /** Load a sheet once, on first use, and redraw when it arrives. */ function ensureGionSheet(name) { @@ -822,7 +627,7 @@ async function init() { gionLoading.delete(name); if (loaded) { gionImages.set(name, loaded); - redrawCaption(); + redrawOverlay(); } return loaded; }); @@ -1062,7 +867,6 @@ async function init() { function onPropPointerDown(event) { if (event.button !== 0) return; - if (event.target?.dataset?.captionHandle) return; // a bubble drags itself if (rig?.controls?.dragging) return; // the rotation gizmo is in charge const hit = propAt(event); if (hit) { @@ -1289,17 +1093,8 @@ async function init() { return aim; } - // -------------------------------------------- history, 口パク, gacha + // ---------------------------------------------------- history, gacha const history = new History({ limit: 120 }); - /** How far the mouth is open right now because of the 口パク animation. */ - let flapMouth = 0; - const mouthFlap = createMouthFlap({ - onLevel: (level) => { - flapMouth = levelToMouth(level, state.mouthFlap?.mouthGain ?? 1); - app.needsRender = true; - }, - onEnd: () => { flapMouth = 0; app.needsRender = true; }, - }); let rig = null; // The gizmo always belongs to whichever camera is currently active. @@ -1436,8 +1231,7 @@ async function init() { Object.assign(app, { renderer, scene, camera: view.camera, view, styles, face, rig, animator, key, ambient, ground, environment, halfHeight, outline, clip, - look, backdrop, history, mouthFlap, propRoot, mirrorGroup, container, - get captionFont() { return captionFont; }, + look, backdrop, history, propRoot, mirrorGroup, container, get backdropImage() { return backdropImage; }, }); @@ -1526,11 +1320,9 @@ async function init() { setCameraPreset: (id) => setCameraPreset(id), undo: () => restoreFromHistory('undo'), redo: () => restoreFromHistory('redo'), - applyTheme: (id) => applyTheme(id), rollGacha: (seed) => rollGacha(seed), copyShareLink: () => copyShareLink(), shareImage: (network) => shareImage(network), - toggleMouthFlap: () => toggleMouthFlap(), storyThumbs: () => storyThumbs, toggleCamera: (on) => toggleCamera(on), addProp: (kind) => addProp(kind), @@ -1761,20 +1553,14 @@ async function init() { }; }; - // Lip-sync and a look-at target both drive the mouth and the eyes without - // being part of the saved expression. + // A look-at target drives the eyes without being part of the saved + // expression; the mouth is used as-is. const mouth = { ...state.face.mouth }; - if (flapMouth > 0.001) mouth.open = Math.max(mouth.open ?? 0, flapMouth); - // A talking mouth has no tongue sticking out. This follows the whole flap - // session rather than the opening: the envelope dips through zero between - // syllables, and keying it off the opening made the tongue flicker back into - // view on every closed frame. - if (mouthFlap.running) mouth.tongue = 0; return { eyes: { - // Everything shared by both eyes (colours, iris scale, brows, glasses, - // ...) has to travel with the per-eye values, because the face renderer + // Everything shared by both eyes (colours, iris scale, glasses, ...) has + // to travel with the per-eye values, because the face renderer // reads them from here. Spreading `...eyes` is also what makes the face // redraw when one of them (e.g. `glasses`) changes: the loop compares the // JSON of this object against the last one it drew. @@ -1857,7 +1643,7 @@ async function init() { // after some other change (adding a prop) re-ran this. applyBackground(); } - if (scope === 'all' || scope === 'view' || scope === 'caption' || scope === 'gion') redrawCaption(); + if (scope === 'all' || scope === 'view' || scope === 'gion') redrawOverlay(); // A style change flips the face between paint and ink (`faceMode`), so the // 'render' scope has to rebuild the face too - otherwise the old drawing @@ -2091,13 +1877,13 @@ async function init() { if (drawn !== onScreen) styles.setStyle(onScreen); } - // The backdrop, the 擬音 stamps and the bubbles live in DOM layers behind - // and above the WebGL canvas, so an export has to paint them in itself - in - // the same order as the screen: backdrop, then stamps, then bubbles. + // The backdrop and the 擬音 stamps live in DOM layers behind the WebGL + // canvas, so an export has to paint them in itself - in the same order as + // the screen: backdrop, then stamps. const ctx = result.canvas.getContext('2d'); - // The canvas' pixels-per-CSS-pixel: the same factor the 擬音 stamps and the - // bubbles are drawn at below. The backdrop's tiled fit needs it too, so its - // tiles come out the size the preview's CSS paints them. + // The canvas' pixels-per-CSS-pixel: the same factor the 擬音 stamps are + // drawn at below. The backdrop's tiled fit needs it too, so its tiles come + // out the size the preview's CSS paints them. const base = canvas.clientWidth || 1280; const outputScale = result.width / Math.max(1, base); let changed = false; @@ -2112,19 +1898,6 @@ async function init() { }); changed = true; } - const bubbles = CAPTION_KEYS.map((key) => state[key]).filter((caption) => caption?.enabled); - if (bubbles.length) { - await ensureCaptionFont(); - for (const caption of bubbles) { - drawCaption(ctx, caption, { - width: result.width, - height: result.height, - scale: outputScale, - font: captionFont, - }); - } - changed = true; - } if (changed) result.blob = await exporter.canvasToBlob(result.canvas); return result; }); @@ -2153,23 +1926,18 @@ async function init() { // `face.flush(now, 26)`), so force any pending one through first or the map // would show the expression before last. face.flush(performance.now(), 0); - return kind === 'hair' ? exporter.buildHairMap(face) : exporter.buildFaceMap(face, kind); + return exporter.buildFaceMap(face, kind); } async function saveFaceMap(kind) { const canvas = faceMapCanvas(kind); if (!canvas) { - toast('このモデルには髪のプレートがありません'); + toast('この下地は書き出せませんでした'); return; } const blob = await exporter.canvasToBlob(canvas); - const label = kind === 'eyes' ? '目' : kind === 'mouth' ? '口' : '髪'; + const label = kind === 'eyes' ? '目' : '口'; exporter.downloadBlob(blob, `bluebey-face-${kind}-${exporter.timestamp()}.png`); - if (kind === 'hair') { - toast(`${label}の下地を書き出しました(${canvas.width}×${canvas.height})。` - + 'この画像に前髪を描いて渡してください'); - return; - } toast(`${label}の下地を書き出しました(${canvas.width}×${canvas.height})。` + `この画像に描いて「${label}の画像を読み込む(PNG)」で読み込めます`); } @@ -2273,14 +2041,6 @@ async function init() { toast(kind === 'undo' ? '1つ戻しました' : 'やり直しました'); } - function applyTheme(id) { - const theme = THEMES.find((item) => item.id === id); - if (!theme) return; - state.render.theme = id; - state.render.colors = { ...theme.colors }; - applyState({}, { scope: 'render', sync: true }); - } - /** おまかせ: a seeded random expression, so it can be reproduced and shared. */ function rollGacha(seedText) { const seed = seedText || String(Math.floor(Math.random() * 1e9)); @@ -2370,18 +2130,6 @@ async function init() { : '投稿画面を開きました(この環境では画像を自動で貼り付けできません)'); } - function toggleMouthFlap() { - if (mouthFlap.running) { - mouthFlap.stop(); - toast('口パクを止めました'); - return; - } - const seconds = mouthFlap.start(state.mouthFlap?.text ?? '', { - rate: state.mouthFlap?.rate ?? 1, - }); - toast(`口パクを始めました(音は出ません・約${seconds.toFixed(1)}秒)`); - } - async function toggleCamera(on) { if (!on) { backdrop.stopCamera(); @@ -2423,15 +2171,13 @@ async function init() { label: `コマ${index}`, pose: JSON.parse(JSON.stringify(state.pose)), face: JSON.parse(JSON.stringify(state.face)), - caption: JSON.parse(JSON.stringify(state.caption)), - caption2: JSON.parse(JSON.stringify(state.caption2)), // The 擬音 stamps as well, so a panel comes back with everything it showed. gion: JSON.parse(JSON.stringify(state.gion)), // The camera as well as the pose: recalling a panel should put you back // where you were looking when you recorded it. view: JSON.parse(JSON.stringify(state.view)), }]; - applyState({}, { scope: 'caption', sync: true }); + applyState({}, { scope: 'gion', sync: true }); // Then photograph what was just recorded, so the list shows the shot rather // than only its name. @@ -2463,8 +2209,7 @@ async function init() { /** * A number in the corner of a comic panel, for sheets whose reading order is - * not obvious. Drawn with the vendored caption font, so the sheet does not - * depend on whatever fonts the viewer happens to have. + * not obvious. */ function drawPanelNumber(ctx, number, x, y, cellW, cellH) { const radius = Math.max(18, Math.min(64, Math.min(cellW, cellH) * 0.075)); @@ -2505,8 +2250,6 @@ async function init() { state.pose = { bones: pose.bones ?? {}, root: pose.root ?? [0, 0, 0] }; state.face = JSON.parse(JSON.stringify(saved.face)); applyPatch(state.face, panel.face ?? {}); - state.caption = { ...saved.caption, ...(panel.caption ?? {}) }; - state.caption2 = { ...saved.caption2, ...(panel.caption2 ?? {}) }; // The camera the panel was framed with, so a comic keeps its angles. if (panel.view) state.view = JSON.parse(JSON.stringify(panel.view)); refresh('all'); @@ -2530,7 +2273,6 @@ async function init() { // obvious from the layout alone, so the panels are numbered. A single column // (or two panels) reads top to bottom without help. const numbered = columns >= 2 && captures.length >= 3; - if (numbered) await ensureCaptionFont(); // A manga page reads by its panel frames. They are drawn inside each cell so // the artwork keeps its full size, and are thick enough to survive the 1× |
