aboutsummaryrefslogtreecommitdiffhomepage
path: root/bluebey-studio/src/main.js
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-09 08:33:42 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-09 08:33:42 +0900
commit9deb6762ef5794b340943e2d2845da79c437dce5 (patch)
tree817a24ae56d7b06f5964f68682432ccb9febff02 /bluebey-studio/src/main.js
parent1b883a70b9d3427e7cb65eaf99d2aa7ca5a1cb1c (diff)
bluebey-studio: 準拠版(フキダシ・眉毛・髪・ひげ・ほっぺ・体の色を削除)
ぶるべーはしゃべらない・姿を変えない方針に合わせた準拠版。セリフ/ナレーション/吹き出し・口パク・眉毛(ゴルゴ等)・髪(頭の模様)・ひげ・ほっぺ・体の色/テーマを削除。目・口・眼鏡・帽子・鼻ちょうちん・ポーズ・背景・小物・光・書き出しは維持。caption.js / mouthFlap.js は削除。
Diffstat (limited to 'bluebey-studio/src/main.js')
-rw-r--r--bluebey-studio/src/main.js340
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×