diff --git a/agent-skill.html b/agent-skill.html index 8fa10a8..16c24a0 100644 --- a/agent-skill.html +++ b/agent-skill.html @@ -118,7 +118,7 @@

diff --git a/package-lock.json b/package-lock.json index f862e71..bdb8501 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,15 +9,18 @@ "version": "0.9.0", "license": "AGPL-3.0-only", "dependencies": { + "@modelcontextprotocol/server": "^2.0.0", "gifenc": "^1.0.3", "mp4-muxer": "^5.2.2", - "obscenity": "^0.4.6" + "obscenity": "^0.4.6", + "zod": "^4.4.3" }, "devDependencies": { "@biomejs/biome": "^2.5.4", "@cloudflare/vitest-pool-workers": "^0.18.8", "@playwright/test": "^1.61.1", "@types/node": "^26.1.1", + "fontkit": "^2.0.4", "jsdom": "^29.1.1", "typescript": "^7.0.2", "vite": "^8.1.5", @@ -300,6 +303,16 @@ "vitest": "^4.1.0" } }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@cloudflare/workerd-darwin-64": { "version": "1.20260722.1", "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260722.1.tgz", @@ -385,6 +398,15 @@ "node": ">=16" } }, + "node_modules/@cloudflare/workers-types": { + "version": "5.20260729.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260729.1.tgz", + "integrity": "sha512-X5r/4y0gKMq/B72qkz/tEwNK4c3v2regT3to6Ia8qqC66E0+YIN/fU7x0JG6ej2rLxtU3TV3aVhfK9y8+jMMAw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "peer": true + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -1587,6 +1609,31 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", + "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", + "license": "MIT", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", + "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -1952,6 +1999,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -2473,6 +2530,27 @@ "node": ">=12" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -2490,6 +2568,16 @@ "dev": true, "license": "MIT" }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -2507,6 +2595,16 @@ "dev": true, "license": "MIT" }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2573,6 +2671,13 @@ "node": ">=8" } }, + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/entities": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", @@ -2665,6 +2770,13 @@ "node": ">=12.0.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2683,6 +2795,24 @@ } } }, + "node_modules/fontkit": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3137,6 +3267,13 @@ "node": ">=12.20.0" } }, + "node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "dev": true, + "license": "MIT" + }, "node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", @@ -3280,6 +3417,13 @@ "node": ">=0.10.0" } }, + "node_modules/restructure": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", + "dev": true, + "license": "MIT" + }, "node_modules/rolldown": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", @@ -3436,6 +3580,13 @@ "dev": true, "license": "MIT" }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "dev": true, + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3531,8 +3682,7 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/typescript": { "version": "7.0.2", @@ -3596,6 +3746,28 @@ "pathe": "^2.0.3" } }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, "node_modules/vite": { "version": "8.1.5", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", @@ -3925,6 +4097,24 @@ "dev": true, "license": "MIT" }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/youch": { "version": "4.1.0-beta.10", "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", @@ -3951,10 +4141,9 @@ } }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "dev": true, + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 4efdb79..37baa76 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@cloudflare/vitest-pool-workers": "^0.18.8", "@playwright/test": "^1.61.1", "@types/node": "^26.1.1", + "fontkit": "^2.0.4", "jsdom": "^29.1.1", "typescript": "^7.0.2", "vite": "^8.1.5", @@ -48,8 +49,10 @@ "wrangler": "^4.114.0" }, "dependencies": { + "@modelcontextprotocol/server": "^2.0.0", "gifenc": "^1.0.3", "mp4-muxer": "^5.2.2", - "obscenity": "^0.4.6" + "obscenity": "^0.4.6", + "zod": "^4.4.3" } } diff --git a/plugins/comic-strip/skills/comic-strip/SKILL.md b/plugins/comic-strip/skills/comic-strip/SKILL.md index b0a9a31..b5e8476 100644 --- a/plugins/comic-strip/skills/comic-strip/SKILL.md +++ b/plugins/comic-strip/skills/comic-strip/SKILL.md @@ -51,7 +51,6 @@ Add `--base http://localhost:5173` to point the link at a dev server instead of | --- | --- | --- | | strip | `version` | `2` | | | `columns` | 1 to 8 panels per row, default 4 | -| | `size` | `classic` or `modern` (default) | | | `seed` | integer, fixes the engine's random choices | | panel | `kind` | `scene` (default) or `title` | | | `background` | a backdrop name, or omit for blank | diff --git a/plugins/comic-strip/skills/comic-strip/reference/guidance.md b/plugins/comic-strip/skills/comic-strip/reference/guidance.md index 4762c20..8a8bc2b 100644 --- a/plugins/comic-strip/skills/comic-strip/reference/guidance.md +++ b/plugins/comic-strip/skills/comic-strip/reference/guidance.md @@ -65,7 +65,7 @@ MP4 and GIF exports are also available in the editor, and hold each panel about - It spends a panel on no story. A four panel strip with a card in front is three beats, so write it as (title) 1+ (comic panels) 3 or let the sheet run up to 1+7 etc (4 panels per row) if you want to fill the page. - You can name the cast that carry the strip, or leave it empty for a simple title card. - The credit line puts the script chosen names of the characters (the avatar names are generic art identifiers). If keeping it short (e.g Dan the Server Man), you could use those lines for comedic effect or framing. -- `footer` is an optional bottom line, off by default: an authorship credit ("Written by ...") or a date. Use it when the strip is signed work; leave it out otherwise. It never costs a credit row, though a five-name cast on a `classic` sized strip fills the card and drops it entirely (default `modern` works fine however) +- `footer` is an optional bottom line, off by default: an authorship credit ("Written by ...") or a date. Use it when the strip is signed work; leave it out otherwise. It never costs a credit row, though a five-name cast can fill the card and drop it entirely - Whether to use title is a creative choice. Usually it's not needed. e.g skip it for most cases. It can be used to set the framing/stage of the comic in advance where creatively beneficial, or for longer strips (8+) ## Pairings that work diff --git a/plugins/comic-strip/skills/comic-strip/scripts/strip-link.mjs b/plugins/comic-strip/skills/comic-strip/scripts/strip-link.mjs index be5d2da..394a8b2 100644 --- a/plugins/comic-strip/skills/comic-strip/scripts/strip-link.mjs +++ b/plugins/comic-strip/skills/comic-strip/scripts/strip-link.mjs @@ -51,7 +51,7 @@ function splitName(name) { function checkKeys(raw, allowed, path) { for (const key of Object.keys(raw)) if (!allowed.includes(key)) - warn(path ? `${path}.${key}` : key, `unknown field "${key}"`); + fail(path ? `${path}.${key}` : key, `unknown field "${key}"`); } function checkEnum(raw, key, allowed, path) { @@ -313,7 +313,8 @@ function checkStrip(raw) { checkKeys(raw, STRIP_KEYS, ""); if (raw.version !== catalog.version) warn("version", `expected version ${catalog.version}`); - checkEnum(raw, "size", catalog.sizes, ""); + if (raw.size !== undefined) + fail("size", 'strips render "modern"; drop the size field'); if (raw.seed !== undefined && !Number.isInteger(raw.seed)) fail("seed", "seed must be an integer"); @@ -336,14 +337,11 @@ function checkStrip(raw) { checkShape(raw.panels); } -// ?s= is deflated, ?script= plain; the studio reads both +// ?s= carries the deflated strip function link(strip, base) { const json = Buffer.from(JSON.stringify(strip)); const packed = deflateRawSync(json, { level: 9 }).toString("base64url"); - const plain = json.toString("base64url"); - const query = - packed.length < plain.length ? `s=${packed}` : `script=${plain}`; - return `${base.replace(/\/$/, "")}/studio.html?${query}`; + return `${base.replace(/\/$/, "")}/studio.html?s=${packed}`; } const args = process.argv.slice(2); diff --git a/scripts/generate-font-metrics.mjs b/scripts/generate-font-metrics.mjs new file mode 100644 index 0000000..c1b3b54 --- /dev/null +++ b/scripts/generate-font-metrics.mjs @@ -0,0 +1,63 @@ +// Emits src/render/comicNeueMetrics.ts from the shipped woff2 faces, so the worker measures text without a canvas. + +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { create } from "fontkit"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); + +// ASCII, Latin-1, and the punctuation strips actually carry +const CODEPOINTS = [ + ...range(0x20, 0x7e), + ...range(0xa0, 0xff), + ...[0x2013, 0x2014, 0x2018, 0x2019, 0x201c, 0x201d, 0x2026], +]; + +function range(lo, hi) { + return Array.from({ length: hi - lo + 1 }, (_, i) => lo + i); +} + +function faceMetrics(file) { + const font = create(readFileSync(join(root, file))); + const advances = {}; + const inkDescents = {}; + for (const cp of CODEPOINTS) { + if (!font.hasGlyphForCodePoint(cp)) continue; + const glyph = font.glyphForCodePoint(cp); + const char = String.fromCodePoint(cp); + advances[char] = Math.round(glyph.advanceWidth); + const below = Math.round(-glyph.bbox.minY); + if (Number.isFinite(below) && below > 0) inkDescents[char] = below; + } + return { + unitsPerEm: font.unitsPerEm, + ascent: Math.round(font.ascent), + descent: Math.round(Math.abs(font.descent)), + fallbackAdvance: advances.m ?? Math.round(font.unitsPerEm / 2), + advances, + inkDescents, + }; +} + +const regular = faceMetrics("public/assets/fonts/comic-neue-400.woff2"); +const italic = faceMetrics("public/assets/fonts/comic-neue-400-italic.woff2"); + +const out = `// Generated by scripts/generate-font-metrics.mjs; do not edit. + +export interface FaceMetrics { + unitsPerEm: number; + ascent: number; + descent: number; + fallbackAdvance: number; + advances: Record; + inkDescents: Record; +} + +export const COMIC_NEUE_REGULAR: FaceMetrics = ${JSON.stringify(regular)}; + +export const COMIC_NEUE_ITALIC: FaceMetrics = ${JSON.stringify(italic)}; +`; + +writeFileSync(join(root, "src/render/comicNeueMetrics.ts"), out); +console.log("wrote src/render/comicNeueMetrics.ts"); diff --git a/src/browser/canvasRenderer.ts b/src/browser/canvasRenderer.ts index 236a367..7e6a173 100644 --- a/src/browser/canvasRenderer.ts +++ b/src/browser/canvasRenderer.ts @@ -1,678 +1,9 @@ -import type { Avatar, AvatarBody, PoseData } from "../engine/avatar.js"; -import { - type Arc, - boxBalloonTraj, - normalBalloonTraj, -} from "../engine/balloonTail.js"; -import type { PanelBalloon, UnitPanel } from "../engine/panel.js"; -import { SM_THINK, SM_WHISPER } from "../engine/panel.js"; -import { BalloonRuntime } from "../engine/panelBalloon.js"; -import { BetaSpline } from "../engine/spline.js"; -import { - BELOW_STARRING, - FOOTER_MARGIN, - FOOTER_MIN_SCALE, - STAR_ICON_SIZE, - STAR_ICON_SPACE, - STARRING, - shoutFontCell, - TITLE_LEADING, - TITLE_UNIT, - type TitleCard, - titleFontCell, -} from "../engine/titlePanel.js"; -import { - angleBetweenVecs, - angleToVector, - dpointNorm, - dpointScalmult, - dpointToPoint, - PI, - pointAdd, - pointMagn, - pointScalmult, - pointSub, - pointToDpoint, - round, - vectorToAngle, -} from "../engine/vector2d.js"; -import type { AvatarAtlasCache } from "./avatarAssets.js"; -import { BALLOON_FONT_SIZE, gdiCellFont } from "./canvasText.js"; - -const TORSOFIRST = 4; - -function starRowHeight(unit: number, scale: number): number { - return Math.max( - Math.round(STAR_ICON_SIZE * scale), - shoutFontCell(unit, BALLOON_FONT_SIZE), - ); -} - -function starRows( - card: TitleCard, - top: number, - height: number, - rowHeight: number, -): number { - return Math.min( - card.stars.length, - Math.max(0, Math.trunc((height - top) / rowHeight)), - ); -} - -export interface SpriteLayer { - pose: PoseData; - left: number; - right: number; - top: number; - bottom: number; -} - -function poseByID(avatar: Avatar, poseID: number): PoseData { - const pose = avatar.data.poses.find( - (candidate) => candidate.poseID === poseID, - ); - if (!pose) throw new Error(`avatar ${avatar.avatarID} has no pose ${poseID}`); - return pose; -} - -function flipLayer( - layer: SpriteLayer, - fullLeft: number, - fullRight: number, -): void { - const width = layer.right - layer.left; - layer.left = fullRight - (layer.left - fullLeft); - layer.right = layer.left - width; -} - -export function bodySpriteLayers( - avatar: Avatar, - body: AvatarBody, -): SpriteLayer[] { - const client = body.bbox; - const clientWidth = client.right - client.left; - const clientHeight = client.top - client.bottom; - if (body.kind === "simple") { - const bodyRec = avatar.data.bodies[body.bodyIndex]; - if (!bodyRec) - throw new Error( - `avatar ${avatar.avatarID} has no body ${body.bodyIndex}`, - ); - const pose = poseByID(avatar, bodyRec.poseID); - const widthScale = clientWidth / pose.width; - const heightScale = clientHeight / pose.height; - let fullWidth: number; - let fullHeight: number; - if (widthScale <= heightScale) { - fullWidth = clientWidth; - fullHeight = Math.trunc(widthScale * pose.height); - } else { - fullHeight = clientHeight; - fullWidth = Math.trunc(heightScale * pose.width); - } - const layer: SpriteLayer = { - pose, - left: client.left + Math.trunc((clientWidth - fullWidth) / 2), - right: 0, - // bodycam.cpp:594 adds the slack to top even in y-up space, floating the body above its bbox - top: client.top + (clientHeight - fullHeight), - bottom: 0, - }; - layer.right = layer.left + fullWidth; - layer.bottom = layer.top - fullHeight; - if (body.flip) { - const oldLeft = layer.left; - layer.left = layer.right; - layer.right = oldLeft; - } - return [layer]; - } - - const face = avatar.data.faces[body.faceIndex]; - const torso = avatar.data.torsos[body.torsoIndex]; - if (!face || !torso) - throw new Error(`avatar ${avatar.avatarID} has incomplete body records`); - const headPose = poseByID(avatar, face.poseID); - const torsoPose = poseByID(avatar, torso.poseID); - const xOffset = torso.xCX + face.deltaXCX - face.xCX; - const yOffset = torso.yCX + face.deltaYCX - face.yCX; - const bitLeft = Math.min(0, xOffset); - const bitRight = Math.max(torsoPose.width, xOffset + headPose.width); - const bitTop = Math.min(0, yOffset); - const bitBottom = Math.max(torsoPose.height, yOffset + headPose.height); - const bitWidth = bitRight - bitLeft; - const bitHeight = bitBottom - bitTop; - const scale = Math.min(clientWidth / bitWidth, clientHeight / bitHeight); - const fullHeight = round(scale * bitHeight); - const fullWidth = round(scale * bitWidth); - const fullLeft = client.left + Math.trunc((clientWidth - fullWidth) / 2); - // bodycam.cpp:559 "centered on bottom" adds the slack upward in y-up space - const fullTop = client.top + (clientHeight - fullHeight); - const fullRight = fullLeft + fullWidth; - const head: SpriteLayer = { - pose: headPose, - left: round((xOffset - bitLeft) * scale) + fullLeft, - right: 0, - top: round((yOffset - bitTop) * scale) + fullTop, - bottom: 0, - }; - head.right = head.left + round(headPose.width * scale) + 1; - head.bottom = head.top - (round(headPose.height * scale) + 1); - const torsoLayer: SpriteLayer = { - pose: torsoPose, - left: round(-bitLeft * scale) + fullLeft, - right: 0, - top: fullTop - round(-bitTop * scale), - bottom: 0, - }; - torsoLayer.right = torsoLayer.left + round(torsoPose.width * scale) + 1; - torsoLayer.bottom = torsoLayer.top - (round(torsoPose.height * scale) + 1); - if (body.flip) { - flipLayer(head, fullLeft, fullRight); - flipLayer(torsoLayer, fullLeft, fullRight); - } - return avatar.data.flags && avatar.data.flags & TORSOFIRST - ? [torsoLayer, head] - : [head, torsoLayer]; -} - -function addArc( - context: CanvasRenderingContext2D, - arc: Arc, - ox: number, - oy: number, -): void { - if (arc.altitude < 1 && arc.altitude > -1) { - context.lineTo(arc.hi.x + ox, -(arc.hi.y + oy)); - return; - } - const mid = pointScalmult(0.5, pointAdd(arc.lo, arc.hi)); - const endToMid = pointSub(mid, arc.hi); - const endToMidDist = pointMagn(endToMid); - const radius = - (endToMidDist * endToMidDist + arc.altitude * arc.altitude) / - (2 * arc.altitude); - const midToCenterDist = radius - arc.altitude; - const perpendicular = { x: endToMid.y, y: -endToMid.x }; - const perpendicularMagnitude = pointMagn(perpendicular); - const midToCenter = pointScalmult( - midToCenterDist / perpendicularMagnitude, - perpendicular, - ); - const center = pointAdd(pointAdd(arc.hi, endToMid), midToCenter); - let a = pointToDpoint(pointSub(arc.lo, center)); - const finalC = pointToDpoint(pointSub(arc.hi, center)); - const scanRadius = Math.hypot(a.x, a.y); - let trueAngle = angleBetweenVecs(finalC, a); - const ccw = arc.altitude > 0; - if (ccw) trueAngle = -trueAngle; - if (trueAngle <= 0) trueAngle += 2 * PI; - let nextEnd = vectorToAngle(a); - const direction = ccw ? 1 : -1; - while (true) { - const finalSegment = trueAngle <= PI / 2; - const step = finalSegment ? trueAngle : PI / 2; - let c = finalC; - if (!finalSegment) { - nextEnd += direction * (PI / 2); - const vector = angleToVector(nextEnd); - c = { x: vector.x * scanRadius, y: vector.y * scanRadius }; - } - const s = Math.cos(step / 2); - const tau = (4 * s) / (3 * (s + 1)); - const divisor = (a.x * c.y - a.y * c.x) / (scanRadius * scanRadius); - const b = { x: (c.y - a.y) / divisor, y: (a.x - c.x) / divisor }; - const p1 = { - x: round((1 - tau) * a.x + tau * b.x) + center.x, - y: round((1 - tau) * a.y + tau * b.y) + center.y, - }; - const p2 = { - x: round((1 - tau) * c.x + tau * b.x) + center.x, - y: round((1 - tau) * c.y + tau * b.y) + center.y, - }; - const p3 = pointAdd(dpointToPoint(c), center); - context.bezierCurveTo( - p1.x + ox, - -(p1.y + oy), - p2.x + ox, - -(p2.y + oy), - p3.x + ox, - -(p3.y + oy), - ); - if (finalSegment) break; - a = c; - trueAngle -= PI / 2; - } -} - -function addBezierPath( - context: CanvasRenderingContext2D, - bezier: readonly { x: number; y: number }[], - ox: number, - oy: number, -): boolean { - const first = bezier[0]; - if (!first) return false; - context.moveTo(first.x + ox, -(first.y + oy)); - for (let i = 1; i + 2 < bezier.length; i += 3) { - const p1 = bezier[i]; - const p2 = bezier[i + 1]; - const p3 = bezier[i + 2]; - if (!p1 || !p2 || !p3) continue; - context.bezierCurveTo( - p1.x + ox, - -(p1.y + oy), - p2.x + ox, - -(p2.y + oy), - p3.x + ox, - -(p3.y + oy), - ); - } - return true; -} - -export function drawSpriteLayer( - context: CanvasRenderingContext2D, - assets: AvatarAtlasCache, - layer: SpriteLayer, -): void { - if (!layer.pose.sprite) - throw new Error(`pose ${layer.pose.poseID} has no sprite`); - const image = assets.get(layer.pose); - const x = Math.min(layer.left, layer.right); - const y = -Math.max(layer.top, layer.bottom); - const width = Math.abs(layer.right - layer.left); - const height = Math.abs(layer.top - layer.bottom); - context.save(); - if (layer.left > layer.right) { - context.translate(2 * x + width, 0); - context.scale(-1, 1); - } - context.drawImage( - image, - layer.pose.sprite.x, - layer.pose.sprite.y, - layer.pose.width, - layer.pose.height, - x, - y, - width, - height, - ); - context.restore(); -} - -export interface CanvasPanelRendererOptions { - unitWidth: number; - unitHeight: number; - background?: string; - foreground?: string; - normalFont?: string; - whisperFont?: string; - resolveBackdrop?: (name: string) => CanvasImageSource | undefined; -} - -export class CanvasPanelRenderer { - private normalFontCache?: string; - private whisperFontCache?: string; - private titleFontCache?: string; - private shoutFontCache?: string; - - constructor( - readonly context: CanvasRenderingContext2D, - readonly assets: AvatarAtlasCache, - readonly avatars: readonly Avatar[], - readonly options: CanvasPanelRendererOptions, - ) {} - - private get normalFont(): string { - this.normalFontCache ??= - this.options.normalFont ?? - gdiCellFont(this.context, "400", BALLOON_FONT_SIZE); - return this.normalFontCache; - } - - private get whisperFont(): string { - this.whisperFontCache ??= - this.options.whisperFont ?? - gdiCellFont(this.context, "italic 400", BALLOON_FONT_SIZE); - return this.whisperFontCache; - } - - private drawBody(body: AvatarBody): void { - const avatar = this.avatars.find( - (candidate) => candidate.avatarID === body.avatarID, - ); - if (!avatar) throw new Error(`missing avatar ${body.avatarID}`); - for (const layer of bodySpriteLayers(avatar, body)) - drawSpriteLayer(this.context, this.assets, layer); - } - - private balloonPath(balloon: PanelBalloon, runtime: BalloonRuntime): void { - if (!runtime.fInfo) return; - const ox = runtime.bbox.left; - const oy = runtime.bbox.top; - this.context.beginPath(); - if (runtime.isBox) { - const lines = boxBalloonTraj(runtime.fInfo.fbox); - const first = lines[0]?.lo; - if (!first) return; - this.context.moveTo(first.x + ox, -(first.y + oy)); - for (const line of lines) - this.context.lineTo(line.hi.x + ox, -(line.hi.y + oy)); - this.context.closePath(); - return; - } - if (balloon.mode === SM_THINK && runtime.spline) { - const cloud = new BetaSpline( - runtime.spline.cps, - runtime.spline.cps.length, - true, - ); - addBezierPath(this.context, cloud.bezpts ?? [], ox, oy); - this.context.closePath(); - return; - } - const cloud = runtime.cloudBBox(); - const traj = normalBalloonTraj(runtime.fInfo, runtime.style.font, { - speakerArrowX: balloon.speaker.arrowX, - speakerTop: balloon.speaker.bbox.top, - balloonLeft: runtime.bbox.left, - balloonTop: runtime.bbox.top, - routeLeft: runtime.routeRgn.left, - routeRight: runtime.routeRgn.right, - cloudBottom: cloud.bottom, - }); - const bezier = traj.spline.bezpts ?? []; - if (!addBezierPath(this.context, bezier, ox, oy)) return; - for (const arc of traj.arcs) addArc(this.context, arc, ox, oy); - this.context.closePath(); - } - - private drawThinkBubbles( - balloon: PanelBalloon, - runtime: BalloonRuntime, - ): void { - if (balloon.mode !== SM_THINK || !runtime.fInfo) return; - const entry = { - x: Math.trunc((runtime.routeRgn.left + runtime.routeRgn.right) / 2), - y: runtime.fInfo.fbox.bottom + runtime.bbox.top, - }; - const tail = { - x: balloon.speaker.arrowX, - y: balloon.speaker.bbox.top + 200, - }; - const deltaY = entry.y - tail.y; - if (deltaY < 0) return; - const count = Math.trunc((deltaY + 100) / 250); - if (count <= 0) return; - const spacing = - count > 1 ? Math.trunc((deltaY - 150 * count) / (count - 1)) : 0; - const direction = dpointNorm({ x: entry.x - tail.x, y: entry.y - tail.y }); - let center = pointAdd(tail, dpointToPoint(dpointScalmult(75, direction))); - const increment = dpointToPoint(dpointScalmult(150 + spacing, direction)); - const widthDelta = - count > 1 ? Math.trunc((400 - 150) / (2 * (count - 1))) : 0; - let widthAdjustment = 0; - for (let i = 0; i < count; i++) { - this.context.beginPath(); - this.context.ellipse( - center.x, - -center.y, - 75 + widthAdjustment, - 75, - 0, - 0, - 2 * Math.PI, - ); - this.context.fill(); - this.context.stroke(); - center = pointAdd(center, increment); - widthAdjustment += widthDelta; - } - } - - private drawBalloon(balloon: PanelBalloon): void { - if (!(balloon.runtime instanceof BalloonRuntime)) return; - const runtime = balloon.runtime; - const fInfo = runtime.fInfo; - if (!fInfo) return; - this.context.save(); - this.context.fillStyle = this.options.background ?? "#fff"; - this.context.strokeStyle = this.options.foreground ?? "#000"; - this.context.lineJoin = "round"; - this.context.lineCap = "round"; - this.balloonPath(balloon, runtime); - if (balloon.mode === SM_WHISPER) { - this.context.lineWidth = 100; - this.context.strokeStyle = this.options.background ?? "#fff"; - this.context.fill(); - this.context.stroke(); - this.context.strokeStyle = this.options.foreground ?? "#000"; - this.context.lineWidth = 28; - this.context.setLineDash([100, 100]); - this.context.stroke(); - this.context.setLineDash([]); - } else { - this.context.lineWidth = 28; - this.context.fill(); - this.context.stroke(); - } - this.drawThinkBubbles(balloon, runtime); - - this.context.fillStyle = this.options.foreground ?? "#000"; - this.context.font = - balloon.mode === SM_WHISPER ? this.whisperFont : this.normalFont; - this.context.textBaseline = "top"; - for (let i = 0; i < fInfo.nLines; i++) { - const line = fInfo.lines[i]; - if (!line) continue; - this.context.fillText( - balloon.text.slice(line.start, line.start + line.length), - runtime.bbox.left + (fInfo.leftX[i] ?? 0), - -(runtime.bbox.top - i * runtime.style.font.lineHeight), - ); - } - this.context.restore(); - } - - private wrapTitle(text: string, maxWidth: number): string[] { - const words = text.split(/\s+/).filter((word) => word.length > 0); - const lines: string[] = []; - let line = ""; - for (const word of words) { - const candidate = line === "" ? word : `${line} ${word}`; - if (line !== "" && this.context.measureText(candidate).width > maxWidth) { - lines.push(line); - line = word; - } else line = candidate; - } - if (line !== "") lines.push(line); - return lines; - } - - private drawStarIcon( - avatarID: number, - left: number, - top: number, - size: number, - ): void { - const avatar = this.avatars.find( - (candidate) => candidate.avatarID === avatarID, - ); - if (!avatar) return; - const pose = avatar.data.poses.find( - (candidate) => candidate.poseID === avatar.data.iconPoseID, - ); - if (!pose?.sprite) return; - const scale = Math.min(size / pose.width, size / pose.height); - const width = Math.round(pose.width * scale); - const height = Math.round(pose.height * scale); - this.context.drawImage( - this.assets.get(pose), - pose.sprite.x, - pose.sprite.y, - pose.width, - pose.height, - left + Math.trunc((size - width) / 2), - top + Math.trunc((size - height) / 2), - width, - height, - ); - } - - // AddStars (panel.cpp:1391-1446): icon+name rows, centered as a block on the widest name - private drawStars(card: TitleCard, top: number): void { - const context = this.context; - const unit = this.options.unitWidth; - const scale = unit / TITLE_UNIT; - const iconSize = Math.round(STAR_ICON_SIZE * scale); - const iconSpace = Math.round(STAR_ICON_SPACE * scale); - const shoutCell = shoutFontCell(unit, BALLOON_FONT_SIZE); - const rowHeight = starRowHeight(unit, scale); - const stars = card.stars.slice( - 0, - starRows(card, top, this.options.unitHeight, rowHeight), - ); - if (stars.length === 0) return; - let maxWidth = 0; - for (const star of stars) - maxWidth = Math.max(maxWidth, context.measureText(star.label).width); - const blockWidth = maxWidth + iconSize + iconSpace; - const iconOffset = Math.max(0, Math.trunc((unit - blockWidth) / 2)); - const textOffset = iconOffset + iconSize + iconSpace; - const iconVdisp = Math.trunc((rowHeight - iconSize) / 2); - const textVdisp = Math.trunc((rowHeight - shoutCell) / 2); - let y = top; - for (const star of stars) { - this.drawStarIcon(star.avatarID, iconOffset, y + iconVdisp, iconSize); - context.fillText(star.label, textOffset, y + textVdisp); - y += rowHeight; - } - } - - // how far a line's ink hangs below its baseline - private descender(text: string): number { - const metrics = this.context.measureText(text); - return Math.max( - 0, - (metrics.actualBoundingBoxDescent ?? 0) - - (metrics.fontBoundingBoxAscent ?? 0), - ); - } - - // AddTitle (panel.cpp:1279-1297): title, STARRING beneath, then the cast - private drawTitle(card: TitleCard): void { - const context = this.context; - const unit = this.options.unitWidth; - const scale = unit / TITLE_UNIT; - this.titleFontCache ??= gdiCellFont( - context, - "400", - titleFontCell(unit, BALLOON_FONT_SIZE), - ); - this.shoutFontCache ??= gdiCellFont( - context, - "400", - shoutFontCell(unit, BALLOON_FONT_SIZE), - ); - context.save(); - context.fillStyle = this.options.foreground ?? "#000"; - context.textBaseline = "top"; - context.textAlign = "center"; - context.font = this.titleFontCache; - let y = Math.round(100 * scale); - const lineHeight = - titleFontCell(unit, BALLOON_FONT_SIZE) + - Math.round(TITLE_LEADING * scale); - for (const line of this.wrapTitle(card.text, unit)) { - context.fillText(line, unit / 2, y); - y += lineHeight + this.descender(line); - } - context.font = this.shoutFontCache; - const starring = card.starring?.trim() ? card.starring : STARRING; - context.fillText(starring, unit / 2, y); - const shoutCell = shoutFontCell(unit, BALLOON_FONT_SIZE); - y += - shoutCell + Math.round(BELOW_STARRING * scale) + this.descender(starring); - const footer = card.footer?.trim(); - context.textAlign = "left"; - this.drawStars(card, y); - if (footer) this.drawFooter(footer, card, y, scale, shoutCell); - context.restore(); - } - - // the cast keeps every row it would draw without a footer; the line takes what is left under it - private drawFooter( - footer: string, - card: TitleCard, - starsTop: number, - scale: number, - shoutCell: number, - ): void { - const context = this.context; - const unit = this.options.unitWidth; - const rowHeight = starRowHeight(unit, scale); - const rows = starRows(card, starsTop, this.options.unitHeight, rowHeight); - const drop = this.descender(footer); - const free = this.options.unitHeight - (starsTop + rows * rowHeight) - drop; - const floor = Math.round(shoutCell * FOOTER_MIN_SCALE); - if (free < floor) return; - let margin = Math.round(FOOTER_MARGIN * scale); - let cell = Math.min(shoutCell, free - margin * 2); - if (cell < floor) { - cell = floor; - margin = Math.trunc((free - floor) / 2); - } - let font = gdiCellFont(context, "400", cell); - context.font = font; - const maxWidth = unit - margin * 2; - const width = context.measureText(footer).width; - if (width > maxWidth) { - cell = Math.round((cell * maxWidth) / width); - font = gdiCellFont(context, "400", cell); - } - context.textAlign = "center"; - context.font = font; - context.fillText( - footer, - unit / 2, - this.options.unitHeight - margin - cell - drop, - ); - } - - render(panel: UnitPanel): void { - const context = this.context; - context.save(); - context.fillStyle = this.options.background ?? "#fff"; - context.fillRect(0, 0, this.options.unitWidth, this.options.unitHeight); - context.imageSmoothingEnabled = true; - context.imageSmoothingQuality = "high"; - // CBackDrop::Draw StretchBlts the DIB across the whole panel rect (backdrop.cpp:330) - const backdrop = panel.backdrop - ? this.options.resolveBackdrop?.(panel.backdrop) - : undefined; - if (backdrop) - context.drawImage( - backdrop, - 0, - 0, - this.options.unitWidth, - this.options.unitHeight, - ); - if (panel.title) this.drawTitle(panel.title); - for (const body of panel.bodies) this.drawBody(body); - for (let i = panel.balloons.length - 1; i >= 0; i--) { - const balloon = panel.balloons[i]; - if (balloon) this.drawBalloon(balloon); - } - if (panel.hasBorder) { - context.strokeStyle = this.options.foreground ?? "#000"; - // panel.cpp:1252 strokes a 2*60-wide pen on the panel edge path; the outer half clips away - context.lineWidth = 120; - context.strokeRect(0, 0, this.options.unitWidth, this.options.unitHeight); - } - context.restore(); - } -} +// The panel painter lives in src/render so the worker can drive it; browser callers import it from here. + +export { + bodySpriteLayers, + CanvasPanelRenderer, + type CanvasPanelRendererOptions, + drawSpriteLayer, + type SpriteLayer, +} from "../render/panelRenderer.js"; diff --git a/src/browser/canvasText.ts b/src/browser/canvasText.ts index 03f1ccb..9366d54 100644 --- a/src/browser/canvasText.ts +++ b/src/browser/canvasText.ts @@ -1,153 +1,20 @@ -// Browser text metrics adapter. Engine geometry remains in v1 logical units. - -import type { - FontMetrics, - TextMeasure, - TextSize, -} from "../engine/balloonText.js"; -import { SM_WHISPER } from "../engine/panel.js"; -import type { - BalloonStyle, - BalloonStyleResolver, -} from "../engine/panelBalloon.js"; - -export const BALLOON_FONT_SIZE = 400; -export const BALLOON_FONT_FAMILIES = - '"Comic Sans MS", "Comic Sans", "Comic Neue", cursive'; -export const NORMAL_FONT_CSS = `400 400px ${BALLOON_FONT_FAMILIES}`; -export const WHISPER_FONT_CSS = `italic 400 400px ${BALLOON_FONT_FAMILIES}`; -export const SYSTEM_FONT_CSS = "400 212px system-ui, sans-serif"; - -// GDI lfHeight requests a character cell (ascent + descent) while CSS px sets the em, so scale the em until the bounding box matches the cell -export function gdiCellFont( - context: CanvasMeasureContext, - style: string, - cellHeight: number, - families: string = BALLOON_FONT_FAMILIES, -): string { - const probe = `${style} ${cellHeight}px ${families}`.trim(); - context.font = probe; - const metrics = context.measureText("Mg"); - const box = - (metrics.fontBoundingBoxAscent ?? 0) + - (metrics.fontBoundingBoxDescent ?? 0); - if (!Number.isFinite(box) || box <= 0) return probe; - const size = Math.round((cellHeight * cellHeight) / box); - return `${style} ${size}px ${families}`.trim(); -} - -export const NORMAL_FONT_METRICS: FontMetrics = { - lineHeight: 327, - baseAdd: 60, - leading: -70, -}; - -export const WHISPER_FONT_METRICS: FontMetrics = { - lineHeight: 314, - baseAdd: 60, - leading: -70, -}; - -export interface CanvasMeasureContext { - font: string; - measureText(text: string): { - width: number; - fontBoundingBoxAscent?: number; - fontBoundingBoxDescent?: number; - }; -} - -export interface CanvasTextMeasurerOptions { - maxEntries?: number; - normalFont?: string; - whisperFont?: string; - systemFont?: string; -} - -function canvasSafeText(text: string): string { - // GDI's explicit trailing NUL has no advance; canvas otherwise renders a - // replacement glyph in some engines. - return text.replaceAll("\0", ""); -} - -export class CanvasTextMeasurer { - readonly context: CanvasMeasureContext; - readonly maxEntries: number; - readonly systemFont: string; - private readonly options: CanvasTextMeasurerOptions; - private normalFontCache?: string; - private whisperFontCache?: string; - private readonly widths = new Map(); - - constructor( - context: CanvasMeasureContext, - options: CanvasTextMeasurerOptions = {}, - ) { - this.context = context; - this.options = options; - this.maxEntries = Math.max(1, options.maxEntries ?? 4096); - this.systemFont = options.systemFont ?? SYSTEM_FONT_CSS; - } - - get normalFont(): string { - this.normalFontCache ??= - this.options.normalFont ?? - gdiCellFont(this.context, "400", BALLOON_FONT_SIZE); - return this.normalFontCache; - } - - get whisperFont(): string { - this.whisperFontCache ??= - this.options.whisperFont ?? - gdiCellFont(this.context, "italic 400", BALLOON_FONT_SIZE); - return this.whisperFontCache; - } - - get size(): number { - return this.widths.size; - } - - clear(): void { - this.widths.clear(); - } - - measure(font: string, height: number): TextMeasure { - return (text: string): TextSize => { - const safeText = canvasSafeText(text); - const key = `${font}\0${safeText}`; - const cached = this.widths.get(key); - if (cached !== undefined) { - this.widths.delete(key); - this.widths.set(key, cached); - return { cx: cached, cy: height }; - } - - this.context.font = font; - const width = Math.round(this.context.measureText(safeText).width); - this.widths.set(key, width); - if (this.widths.size > this.maxEntries) { - const oldest = this.widths.keys().next().value; - if (oldest !== undefined) this.widths.delete(oldest); - } - return { cx: width, cy: height }; - }; - } - - styleResolver(): BalloonStyleResolver { - const systemMeasure = this.measure(this.systemFont, 212); - const normal: BalloonStyle = { - measure: this.measure(this.normalFont, 397), - widestMeasure: systemMeasure, - font: NORMAL_FONT_METRICS, - }; - const whisper: BalloonStyle = { - measure: this.measure(this.whisperFont, 384), - widestMeasure: systemMeasure, - font: WHISPER_FONT_METRICS, - }; - return (_text, mode) => (mode === SM_WHISPER ? whisper : normal); - } -} +// Browser text measurement: a real canvas context behind the shared metrics adapter in src/render. + +export { + BALLOON_FONT_FAMILIES, + BALLOON_FONT_SIZE, + type CanvasMeasureContext, + CanvasTextMeasurer, + type CanvasTextMeasurerOptions, + gdiCellFont, + NORMAL_FONT_CSS, + NORMAL_FONT_METRICS, + SYSTEM_FONT_CSS, + WHISPER_FONT_CSS, + WHISPER_FONT_METRICS, +} from "../render/textMetrics.js"; + +import { NORMAL_FONT_CSS, WHISPER_FONT_CSS } from "../render/textMetrics.js"; export function createCanvasMeasureContext(): CanvasRenderingContext2D { if (typeof OffscreenCanvas !== "undefined") { diff --git a/src/browser/dom.ts b/src/browser/dom.ts index 5e247ef..8024154 100644 --- a/src/browser/dom.ts +++ b/src/browser/dom.ts @@ -6,9 +6,7 @@ export function element(id: string): T { return found as T; } -export function displayName(name: string): string { - return name.charAt(0).toUpperCase() + name.slice(1); -} +export { displayName } from "../render/displayName.js"; // how far a scroller must move to bring an item into view, matching scrollIntoView's "nearest" export function scrollDelta( diff --git a/src/browser/stripMark.ts b/src/browser/stripMark.ts index be22066..6a38063 100644 --- a/src/browser/stripMark.ts +++ b/src/browser/stripMark.ts @@ -1,57 +1,12 @@ -// The credit line every exported sheet carries, drawn into the band under the last panel row. +// Browser wrappers over the shared mark painter, defaulting the host to the page's own. -const MARK_COLOR = "#222"; -const MARK_FAMILY = '"Segoe UI", system-ui, sans-serif'; -// a one-column sheet is the reference; wider sheets scale by the square root, enough to stay legible without taking over -const MARK_BASE_WIDTH = 700; -const MARK_FONT_PX = 12; -const MARK_MIN_FONT_PX = 9; +import { + drawMarkLine as markLine, + drawStripMark as stripMark, +} from "../render/stripMark.js"; -// the host comes off the page, so a fork's exports name the fork -export function stripMarkParts(host: string): { left: string; right: string } { - return { - left: "Comic Chat 1996 port · original character design by Jim Woodring", - right: host ? `created at ${host}` : "", - }; -} - -function markFont(width: number): number { - const scale = Math.max(1, Math.sqrt(width / MARK_BASE_WIDTH)); - return Math.round(MARK_FONT_PX * scale); -} - -// both ends share one line, so step down until four ems of daylight separate them -function fitFont( - context: CanvasRenderingContext2D, - left: string, - right: string, - width: number, - gap: number, - start: number = gap, -): number { - const room = width - start - gap; - let px = markFont(width); - while (px > MARK_MIN_FONT_PX) { - context.font = `${px}px ${MARK_FAMILY}`; - const used = - context.measureText(left).width + context.measureText(right).width; - if (used + px * 4 <= room) break; - px -= 1; - } - return px; -} - -// the band gives the type twice its own height; a small sheet already has that much gutter -function markBand(width: number, gap: number): number { - return Math.max(gap, markFont(width) * 2); -} - -// extra sheet height the band needs beyond the gutter under the last row -export function stripMarkGrowth(width: number, gap: number): number { - return markBand(width, gap) - gap; -} +export { stripMarkGrowth, stripMarkParts } from "../render/stripMark.js"; -// one baseline, each end set flush against its margin; start lets the credit skip a borderless lead panel export function drawMarkLine( context: CanvasRenderingContext2D, width: number, @@ -60,16 +15,7 @@ export function drawMarkLine( host: string = location.host, start: number = margin, ): void { - const { left, right } = stripMarkParts(host); - context.save(); - context.font = `${fitFont(context, left, right, width, margin, start)}px ${MARK_FAMILY}`; - context.fillStyle = MARK_COLOR; - context.textBaseline = "middle"; - context.textAlign = "left"; - context.fillText(left, start, y); - context.textAlign = "right"; - context.fillText(right, width - margin, y); - context.restore(); + markLine(context, width, margin, y, host, start); } export function drawStripMark( @@ -80,7 +26,5 @@ export function drawStripMark( host: string = location.host, start: number = gap, ): void { - // whole pixels, so "middle" never lands the glyphs on a subpixel boundary - const y = height - Math.round(markBand(width, gap) / 2); - drawMarkLine(context, width, gap, y, host, start); + stripMark(context, width, height, gap, host, start); } diff --git a/src/render/comicNeueMetrics.ts b/src/render/comicNeueMetrics.ts new file mode 100644 index 0000000..709ec4a --- /dev/null +++ b/src/render/comicNeueMetrics.ts @@ -0,0 +1,719 @@ +// Generated by scripts/generate-font-metrics.mjs; do not edit. + +export interface FaceMetrics { + unitsPerEm: number; + ascent: number; + descent: number; + fallbackAdvance: number; + advances: Record; + inkDescents: Record; +} + +export const COMIC_NEUE_REGULAR: FaceMetrics = { + unitsPerEm: 1000, + ascent: 900, + descent: 250, + fallbackAdvance: 827, + advances: { + "0": 609, + "1": 396, + "2": 523, + "3": 506, + "4": 484, + "5": 542, + "6": 542, + "7": 473, + "8": 515, + "9": 531, + " ": 334, + "!": 245, + '"': 275, + "#": 740, + $: 602, + "%": 720, + "&": 644, + "'": 196, + "(": 346, + ")": 346, + "*": 394, + "+": 444, + ",": 164, + "-": 402, + ".": 153, + "/": 436, + ":": 181, + ";": 199, + "<": 511, + "=": 462, + ">": 541, + "?": 501, + "@": 832, + A: 581, + B: 620, + C: 614, + D: 629, + E: 603, + F: 577, + G: 716, + H: 723, + I: 392, + J: 457, + K: 560, + L: 500, + M: 838, + N: 658, + O: 643, + P: 560, + Q: 708, + R: 596, + S: 552, + T: 522, + U: 691, + V: 536, + W: 867, + X: 685, + Y: 594, + Z: 582, + "[": 318, + "\\": 549, + "]": 318, + "^": 504, + _: 494, + "`": 181, + a: 516, + b: 535, + c: 482, + d: 530, + e: 501, + f: 279, + g: 530, + h: 536, + i: 227, + j: 236, + k: 484, + l: 219, + m: 827, + n: 553, + o: 529, + p: 531, + q: 520, + r: 397, + s: 416, + t: 286, + u: 511, + v: 418, + w: 598, + x: 473, + y: 447, + z: 434, + "{": 373, + "|": 197, + "}": 373, + "~": 615, + " ": 334, + "¡": 245, + "¢": 480, + "£": 661, + "¤": 507, + "¥": 494, + "¦": 223, + "§": 421, + "¨": 265, + "©": 547, + ª: 302, + "«": 563, + "¬": 478, + "­": 402, + "®": 555, + "¯": 263, + "°": 247, + "±": 445, + "²": 249, + "³": 245, + "´": 181, + µ: 504, + "¶": 557, + "·": 176, + "¸": 172, + "¹": 176, + º: 281, + "»": 559, + "¼": 685, + "½": 741, + "¾": 734, + "¿": 485, + À: 581, + Á: 581, + Â: 581, + Ã: 581, + Ä: 581, + Å: 581, + Æ: 1044, + Ç: 614, + È: 603, + É: 603, + Ê: 603, + Ë: 603, + Ì: 392, + Í: 392, + Î: 392, + Ï: 392, + Ð: 634, + Ñ: 658, + Ò: 643, + Ó: 643, + Ô: 643, + Õ: 643, + Ö: 643, + "×": 376, + Ø: 635, + Ù: 691, + Ú: 691, + Û: 691, + Ü: 691, + Ý: 594, + Þ: 542, + ß: 526, + à: 516, + á: 516, + â: 516, + ã: 516, + ä: 516, + å: 516, + æ: 822, + ç: 482, + è: 501, + é: 501, + ê: 501, + ë: 501, + ì: 228, + í: 228, + î: 228, + ï: 228, + ð: 470, + ñ: 553, + ò: 529, + ó: 529, + ô: 529, + õ: 529, + ö: 529, + "÷": 432, + ø: 529, + ù: 511, + ú: 511, + û: 511, + ü: 511, + ý: 447, + þ: 531, + ÿ: 447, + "–": 600, + "—": 824, + "‘": 185, + "’": 185, + "“": 308, + "”": 326, + "…": 664, + }, + inkDescents: { + "0": 6, + "3": 6, + "4": 6, + "5": 6, + "6": 6, + "7": 6, + "8": 6, + "9": 6, + "!": 6, + "#": 6, + $: 80, + "%": 14, + "&": 10, + "(": 112, + ")": 112, + ",": 98, + ".": 6, + "/": 122, + ":": 6, + ";": 98, + "?": 6, + "@": 100, + A: 7, + B: 8, + C: 6, + D: 7, + F: 7, + G: 7, + H: 6, + J: 6, + K: 7, + L: 6, + M: 6, + N: 6, + O: 6, + P: 6, + Q: 29, + R: 6, + S: 6, + T: 6, + U: 6, + V: 6, + W: 6, + X: 6, + Y: 6, + Z: 1, + "[": 128, + "\\": 7, + "]": 128, + _: 61, + a: 14, + b: 6, + c: 6, + d: 6, + e: 6, + f: 6, + g: 188, + h: 7, + i: 6, + j: 184, + k: 6, + l: 6, + m: 6, + n: 6, + o: 6, + p: 198, + q: 186, + r: 6, + s: 8, + t: 7, + u: 9, + v: 7, + w: 6, + x: 6, + y: 200, + z: 5, + "{": 128, + "|": 172, + "}": 128, + "¡": 200, + "¢": 6, + "£": 16, + "¥": 6, + "¦": 103, + "§": 6, + "±": 7, + µ: 184, + "¶": 85, + "¸": 207, + "¼": 72, + "½": 69, + "¾": 72, + "¿": 190, + À: 7, + Á: 7, + Â: 7, + Ã: 7, + Ä: 7, + Å: 7, + Æ: 9, + Ç: 215, + Ð: 26, + Ñ: 6, + Ò: 6, + Ó: 6, + Ô: 6, + Õ: 6, + Ö: 6, + Ø: 38, + Ù: 6, + Ú: 6, + Û: 6, + Ü: 6, + Ý: 6, + Þ: 6, + ß: 31, + à: 14, + á: 14, + â: 14, + ã: 14, + ä: 14, + å: 14, + æ: 6, + ç: 207, + è: 6, + é: 6, + ê: 6, + ë: 6, + ì: 6, + í: 6, + î: 6, + ï: 6, + ð: 5, + ñ: 6, + ò: 6, + ó: 6, + ô: 6, + õ: 6, + ö: 6, + ø: 41, + ù: 9, + ú: 9, + û: 9, + ü: 9, + ý: 200, + þ: 198, + ÿ: 200, + "…": 6, + }, +}; + +export const COMIC_NEUE_ITALIC: FaceMetrics = { + unitsPerEm: 1000, + ascent: 900, + descent: 250, + fallbackAdvance: 829, + advances: { + "0": 612, + "1": 396, + "2": 526, + "3": 508, + "4": 485, + "5": 544, + "6": 544, + "7": 477, + "8": 516, + "9": 533, + " ": 333, + "!": 244, + '"': 291, + "#": 750, + $: 603, + "%": 706, + "&": 647, + "'": 174, + "(": 345, + ")": 345, + "*": 374, + "+": 444, + ",": 162, + "-": 411, + ".": 144, + "/": 478, + ":": 174, + ";": 198, + "<": 479, + "=": 462, + ">": 474, + "?": 487, + "@": 812, + A: 583, + B: 621, + C: 614, + D: 630, + E: 606, + F: 577, + G: 715, + H: 724, + I: 392, + J: 455, + K: 558, + L: 493, + M: 842, + N: 660, + O: 635, + P: 558, + Q: 712, + R: 596, + S: 549, + T: 518, + U: 694, + V: 535, + W: 869, + X: 622, + Y: 595, + Z: 582, + "[": 314, + "\\": 486, + "]": 315, + "^": 389, + _: 612, + "`": 231, + a: 514, + b: 536, + c: 483, + d: 531, + e: 503, + f: 294, + g: 523, + h: 538, + i: 226, + j: 259, + k: 486, + l: 219, + m: 829, + n: 555, + o: 529, + p: 534, + q: 525, + r: 397, + s: 415, + t: 316, + u: 524, + v: 418, + w: 590, + x: 476, + y: 446, + z: 417, + "{": 367, + "|": 216, + "}": 366, + "~": 596, + " ": 333, + "¡": 237, + "¢": 483, + "£": 678, + "¤": 487, + "¥": 499, + "¦": 189, + "§": 461, + "¨": 262, + "©": 545, + ª: 346, + "«": 532, + "¬": 472, + "­": 411, + "®": 542, + "¯": 260, + "°": 280, + "±": 434, + "²": 371, + "³": 371, + "´": 171, + µ: 526, + "¶": 527, + "·": 175, + "¸": 173, + "¹": 308, + º: 387, + "»": 515, + "¼": 674, + "½": 738, + "¾": 630, + "¿": 471, + À: 583, + Á: 583, + Â: 583, + Ã: 583, + Ä: 583, + Å: 583, + Æ: 1028, + Ç: 614, + È: 606, + É: 606, + Ê: 606, + Ë: 606, + Ì: 392, + Í: 392, + Î: 392, + Ï: 392, + Ð: 630, + Ñ: 660, + Ò: 635, + Ó: 635, + Ô: 635, + Õ: 635, + Ö: 635, + "×": 375, + Ø: 634, + Ù: 694, + Ú: 694, + Û: 694, + Ü: 694, + Ý: 595, + Þ: 550, + ß: 528, + à: 514, + á: 514, + â: 514, + ã: 514, + ä: 514, + å: 514, + æ: 830, + ç: 483, + è: 503, + é: 503, + ê: 503, + ë: 503, + ì: 226, + í: 226, + î: 226, + ï: 226, + ð: 474, + ñ: 555, + ò: 529, + ó: 529, + ô: 529, + õ: 529, + ö: 529, + "÷": 432, + ø: 533, + ù: 524, + ú: 524, + û: 524, + ü: 524, + ý: 446, + þ: 533, + ÿ: 446, + "–": 540, + "—": 852, + "‘": 175, + "’": 175, + "“": 312, + "”": 310, + "…": 735, + }, + inkDescents: { + "0": 6, + "3": 6, + "4": 6, + "5": 6, + "6": 6, + "7": 6, + "8": 6, + "9": 6, + "!": 6, + "#": 6, + $: 79, + "%": 14, + "&": 6, + "(": 113, + ")": 114, + ",": 92, + "/": 1, + ":": 6, + ";": 92, + "@": 100, + A: 6, + B: 8, + C: 8, + D: 16, + F: 6, + G: 7, + H: 6, + J: 6, + K: 7, + L: 6, + M: 6, + N: 6, + O: 7, + P: 6, + Q: 30, + R: 6, + S: 6, + T: 6, + U: 6, + V: 6, + W: 6, + X: 6, + Y: 6, + "[": 128, + "\\": 1, + "]": 128, + _: 60, + a: 14, + b: 6, + c: 6, + d: 6, + e: 6, + f: 6, + g: 189, + h: 7, + i: 6, + j: 185, + k: 6, + l: 6, + m: 6, + n: 6, + o: 6, + p: 199, + q: 193, + r: 6, + s: 6, + t: 7, + u: 9, + v: 7, + w: 6, + x: 6, + y: 201, + z: 2, + "{": 129, + "|": 172, + "}": 129, + "¡": 196, + "£": 6, + "¥": 6, + "¦": 104, + "§": 6, + "±": 1, + µ: 185, + "¶": 85, + "¸": 200, + "¼": 5, + "½": 2, + "¾": 29, + "¿": 185, + À: 6, + Á: 6, + Â: 6, + Ã: 6, + Ä: 6, + Å: 6, + Æ: 9, + Ç: 206, + Ð: 22, + Ñ: 6, + Ò: 7, + Ó: 7, + Ô: 7, + Õ: 7, + Ö: 7, + Ø: 39, + Ù: 6, + Ú: 6, + Û: 6, + Ü: 6, + Ý: 6, + Þ: 6, + ß: 30, + à: 14, + á: 14, + â: 14, + ã: 14, + ä: 14, + å: 14, + æ: 13, + ç: 206, + è: 6, + é: 6, + ê: 6, + ë: 6, + ì: 6, + í: 6, + î: 6, + ï: 6, + ñ: 6, + ò: 6, + ó: 6, + ô: 6, + õ: 6, + ö: 6, + ø: 41, + ù: 9, + ú: 9, + û: 9, + ü: 9, + ý: 201, + þ: 199, + ÿ: 201, + "…": 6, + }, +}; diff --git a/src/render/displayName.ts b/src/render/displayName.ts new file mode 100644 index 0000000..1cdc2b5 --- /dev/null +++ b/src/render/displayName.ts @@ -0,0 +1,3 @@ +export function displayName(name: string): string { + return name.charAt(0).toUpperCase() + name.slice(1); +} diff --git a/src/render/panelRenderer.ts b/src/render/panelRenderer.ts new file mode 100644 index 0000000..c2ff06b --- /dev/null +++ b/src/render/panelRenderer.ts @@ -0,0 +1,682 @@ +import type { Avatar, AvatarBody, PoseData } from "../engine/avatar.js"; +import { + type Arc, + boxBalloonTraj, + normalBalloonTraj, +} from "../engine/balloonTail.js"; +import type { PanelBalloon, UnitPanel } from "../engine/panel.js"; +import { SM_THINK, SM_WHISPER } from "../engine/panel.js"; +import { BalloonRuntime } from "../engine/panelBalloon.js"; +import { BetaSpline } from "../engine/spline.js"; +import { + BELOW_STARRING, + FOOTER_MARGIN, + FOOTER_MIN_SCALE, + STAR_ICON_SIZE, + STAR_ICON_SPACE, + STARRING, + shoutFontCell, + TITLE_LEADING, + TITLE_UNIT, + type TitleCard, + titleFontCell, +} from "../engine/titlePanel.js"; +import { + angleBetweenVecs, + angleToVector, + dpointNorm, + dpointScalmult, + dpointToPoint, + PI, + pointAdd, + pointMagn, + pointScalmult, + pointSub, + pointToDpoint, + round, + vectorToAngle, +} from "../engine/vector2d.js"; +import type { + AtlasSource, + PanelRenderContext, + RenderImage, +} from "./renderContext.js"; +import { BALLOON_FONT_SIZE, gdiCellFont } from "./textMetrics.js"; + +const TORSOFIRST = 4; + +function starRowHeight(unit: number, scale: number): number { + return Math.max( + Math.round(STAR_ICON_SIZE * scale), + shoutFontCell(unit, BALLOON_FONT_SIZE), + ); +} + +function starRows( + card: TitleCard, + top: number, + height: number, + rowHeight: number, +): number { + return Math.min( + card.stars.length, + Math.max(0, Math.trunc((height - top) / rowHeight)), + ); +} + +export interface SpriteLayer { + pose: PoseData; + left: number; + right: number; + top: number; + bottom: number; +} + +function poseByID(avatar: Avatar, poseID: number): PoseData { + const pose = avatar.data.poses.find( + (candidate) => candidate.poseID === poseID, + ); + if (!pose) throw new Error(`avatar ${avatar.avatarID} has no pose ${poseID}`); + return pose; +} + +function flipLayer( + layer: SpriteLayer, + fullLeft: number, + fullRight: number, +): void { + const width = layer.right - layer.left; + layer.left = fullRight - (layer.left - fullLeft); + layer.right = layer.left - width; +} + +export function bodySpriteLayers( + avatar: Avatar, + body: AvatarBody, +): SpriteLayer[] { + const client = body.bbox; + const clientWidth = client.right - client.left; + const clientHeight = client.top - client.bottom; + if (body.kind === "simple") { + const bodyRec = avatar.data.bodies[body.bodyIndex]; + if (!bodyRec) + throw new Error( + `avatar ${avatar.avatarID} has no body ${body.bodyIndex}`, + ); + const pose = poseByID(avatar, bodyRec.poseID); + const widthScale = clientWidth / pose.width; + const heightScale = clientHeight / pose.height; + let fullWidth: number; + let fullHeight: number; + if (widthScale <= heightScale) { + fullWidth = clientWidth; + fullHeight = Math.trunc(widthScale * pose.height); + } else { + fullHeight = clientHeight; + fullWidth = Math.trunc(heightScale * pose.width); + } + const layer: SpriteLayer = { + pose, + left: client.left + Math.trunc((clientWidth - fullWidth) / 2), + right: 0, + // bodycam.cpp:594 adds the slack to top even in y-up space, floating the body above its bbox + top: client.top + (clientHeight - fullHeight), + bottom: 0, + }; + layer.right = layer.left + fullWidth; + layer.bottom = layer.top - fullHeight; + if (body.flip) { + const oldLeft = layer.left; + layer.left = layer.right; + layer.right = oldLeft; + } + return [layer]; + } + + const face = avatar.data.faces[body.faceIndex]; + const torso = avatar.data.torsos[body.torsoIndex]; + if (!face || !torso) + throw new Error(`avatar ${avatar.avatarID} has incomplete body records`); + const headPose = poseByID(avatar, face.poseID); + const torsoPose = poseByID(avatar, torso.poseID); + const xOffset = torso.xCX + face.deltaXCX - face.xCX; + const yOffset = torso.yCX + face.deltaYCX - face.yCX; + const bitLeft = Math.min(0, xOffset); + const bitRight = Math.max(torsoPose.width, xOffset + headPose.width); + const bitTop = Math.min(0, yOffset); + const bitBottom = Math.max(torsoPose.height, yOffset + headPose.height); + const bitWidth = bitRight - bitLeft; + const bitHeight = bitBottom - bitTop; + const scale = Math.min(clientWidth / bitWidth, clientHeight / bitHeight); + const fullHeight = round(scale * bitHeight); + const fullWidth = round(scale * bitWidth); + const fullLeft = client.left + Math.trunc((clientWidth - fullWidth) / 2); + // bodycam.cpp:559 "centered on bottom" adds the slack upward in y-up space + const fullTop = client.top + (clientHeight - fullHeight); + const fullRight = fullLeft + fullWidth; + const head: SpriteLayer = { + pose: headPose, + left: round((xOffset - bitLeft) * scale) + fullLeft, + right: 0, + top: round((yOffset - bitTop) * scale) + fullTop, + bottom: 0, + }; + head.right = head.left + round(headPose.width * scale) + 1; + head.bottom = head.top - (round(headPose.height * scale) + 1); + const torsoLayer: SpriteLayer = { + pose: torsoPose, + left: round(-bitLeft * scale) + fullLeft, + right: 0, + top: fullTop - round(-bitTop * scale), + bottom: 0, + }; + torsoLayer.right = torsoLayer.left + round(torsoPose.width * scale) + 1; + torsoLayer.bottom = torsoLayer.top - (round(torsoPose.height * scale) + 1); + if (body.flip) { + flipLayer(head, fullLeft, fullRight); + flipLayer(torsoLayer, fullLeft, fullRight); + } + return avatar.data.flags && avatar.data.flags & TORSOFIRST + ? [torsoLayer, head] + : [head, torsoLayer]; +} + +function addArc( + context: PanelRenderContext, + arc: Arc, + ox: number, + oy: number, +): void { + if (arc.altitude < 1 && arc.altitude > -1) { + context.lineTo(arc.hi.x + ox, -(arc.hi.y + oy)); + return; + } + const mid = pointScalmult(0.5, pointAdd(arc.lo, arc.hi)); + const endToMid = pointSub(mid, arc.hi); + const endToMidDist = pointMagn(endToMid); + const radius = + (endToMidDist * endToMidDist + arc.altitude * arc.altitude) / + (2 * arc.altitude); + const midToCenterDist = radius - arc.altitude; + const perpendicular = { x: endToMid.y, y: -endToMid.x }; + const perpendicularMagnitude = pointMagn(perpendicular); + const midToCenter = pointScalmult( + midToCenterDist / perpendicularMagnitude, + perpendicular, + ); + const center = pointAdd(pointAdd(arc.hi, endToMid), midToCenter); + let a = pointToDpoint(pointSub(arc.lo, center)); + const finalC = pointToDpoint(pointSub(arc.hi, center)); + const scanRadius = Math.hypot(a.x, a.y); + let trueAngle = angleBetweenVecs(finalC, a); + const ccw = arc.altitude > 0; + if (ccw) trueAngle = -trueAngle; + if (trueAngle <= 0) trueAngle += 2 * PI; + let nextEnd = vectorToAngle(a); + const direction = ccw ? 1 : -1; + while (true) { + const finalSegment = trueAngle <= PI / 2; + const step = finalSegment ? trueAngle : PI / 2; + let c = finalC; + if (!finalSegment) { + nextEnd += direction * (PI / 2); + const vector = angleToVector(nextEnd); + c = { x: vector.x * scanRadius, y: vector.y * scanRadius }; + } + const s = Math.cos(step / 2); + const tau = (4 * s) / (3 * (s + 1)); + const divisor = (a.x * c.y - a.y * c.x) / (scanRadius * scanRadius); + const b = { x: (c.y - a.y) / divisor, y: (a.x - c.x) / divisor }; + const p1 = { + x: round((1 - tau) * a.x + tau * b.x) + center.x, + y: round((1 - tau) * a.y + tau * b.y) + center.y, + }; + const p2 = { + x: round((1 - tau) * c.x + tau * b.x) + center.x, + y: round((1 - tau) * c.y + tau * b.y) + center.y, + }; + const p3 = pointAdd(dpointToPoint(c), center); + context.bezierCurveTo( + p1.x + ox, + -(p1.y + oy), + p2.x + ox, + -(p2.y + oy), + p3.x + ox, + -(p3.y + oy), + ); + if (finalSegment) break; + a = c; + trueAngle -= PI / 2; + } +} + +function addBezierPath( + context: PanelRenderContext, + bezier: readonly { x: number; y: number }[], + ox: number, + oy: number, +): boolean { + const first = bezier[0]; + if (!first) return false; + context.moveTo(first.x + ox, -(first.y + oy)); + for (let i = 1; i + 2 < bezier.length; i += 3) { + const p1 = bezier[i]; + const p2 = bezier[i + 1]; + const p3 = bezier[i + 2]; + if (!p1 || !p2 || !p3) continue; + context.bezierCurveTo( + p1.x + ox, + -(p1.y + oy), + p2.x + ox, + -(p2.y + oy), + p3.x + ox, + -(p3.y + oy), + ); + } + return true; +} + +export function drawSpriteLayer( + context: PanelRenderContext, + assets: AtlasSource, + layer: SpriteLayer, +): void { + if (!layer.pose.sprite) + throw new Error(`pose ${layer.pose.poseID} has no sprite`); + const image = assets.get(layer.pose); + const x = Math.min(layer.left, layer.right); + const y = -Math.max(layer.top, layer.bottom); + const width = Math.abs(layer.right - layer.left); + const height = Math.abs(layer.top - layer.bottom); + context.save(); + if (layer.left > layer.right) { + context.translate(2 * x + width, 0); + context.scale(-1, 1); + } + context.drawImage( + image, + layer.pose.sprite.x, + layer.pose.sprite.y, + layer.pose.width, + layer.pose.height, + x, + y, + width, + height, + ); + context.restore(); +} + +export interface CanvasPanelRendererOptions { + unitWidth: number; + unitHeight: number; + background?: string; + foreground?: string; + normalFont?: string; + whisperFont?: string; + resolveBackdrop?: (name: string) => RenderImage | undefined; +} + +export class CanvasPanelRenderer { + private normalFontCache?: string; + private whisperFontCache?: string; + private titleFontCache?: string; + private shoutFontCache?: string; + + constructor( + readonly context: PanelRenderContext, + readonly assets: AtlasSource, + readonly avatars: readonly Avatar[], + readonly options: CanvasPanelRendererOptions, + ) {} + + private get normalFont(): string { + this.normalFontCache ??= + this.options.normalFont ?? + gdiCellFont(this.context, "400", BALLOON_FONT_SIZE); + return this.normalFontCache; + } + + private get whisperFont(): string { + this.whisperFontCache ??= + this.options.whisperFont ?? + gdiCellFont(this.context, "italic 400", BALLOON_FONT_SIZE); + return this.whisperFontCache; + } + + private drawBody(body: AvatarBody): void { + const avatar = this.avatars.find( + (candidate) => candidate.avatarID === body.avatarID, + ); + if (!avatar) throw new Error(`missing avatar ${body.avatarID}`); + for (const layer of bodySpriteLayers(avatar, body)) + drawSpriteLayer(this.context, this.assets, layer); + } + + private balloonPath(balloon: PanelBalloon, runtime: BalloonRuntime): void { + if (!runtime.fInfo) return; + const ox = runtime.bbox.left; + const oy = runtime.bbox.top; + this.context.beginPath(); + if (runtime.isBox) { + const lines = boxBalloonTraj(runtime.fInfo.fbox); + const first = lines[0]?.lo; + if (!first) return; + this.context.moveTo(first.x + ox, -(first.y + oy)); + for (const line of lines) + this.context.lineTo(line.hi.x + ox, -(line.hi.y + oy)); + this.context.closePath(); + return; + } + if (balloon.mode === SM_THINK && runtime.spline) { + const cloud = new BetaSpline( + runtime.spline.cps, + runtime.spline.cps.length, + true, + ); + addBezierPath(this.context, cloud.bezpts ?? [], ox, oy); + this.context.closePath(); + return; + } + const cloud = runtime.cloudBBox(); + const traj = normalBalloonTraj(runtime.fInfo, runtime.style.font, { + speakerArrowX: balloon.speaker.arrowX, + speakerTop: balloon.speaker.bbox.top, + balloonLeft: runtime.bbox.left, + balloonTop: runtime.bbox.top, + routeLeft: runtime.routeRgn.left, + routeRight: runtime.routeRgn.right, + cloudBottom: cloud.bottom, + }); + const bezier = traj.spline.bezpts ?? []; + if (!addBezierPath(this.context, bezier, ox, oy)) return; + for (const arc of traj.arcs) addArc(this.context, arc, ox, oy); + this.context.closePath(); + } + + private drawThinkBubbles( + balloon: PanelBalloon, + runtime: BalloonRuntime, + ): void { + if (balloon.mode !== SM_THINK || !runtime.fInfo) return; + const entry = { + x: Math.trunc((runtime.routeRgn.left + runtime.routeRgn.right) / 2), + y: runtime.fInfo.fbox.bottom + runtime.bbox.top, + }; + const tail = { + x: balloon.speaker.arrowX, + y: balloon.speaker.bbox.top + 200, + }; + const deltaY = entry.y - tail.y; + if (deltaY < 0) return; + const count = Math.trunc((deltaY + 100) / 250); + if (count <= 0) return; + const spacing = + count > 1 ? Math.trunc((deltaY - 150 * count) / (count - 1)) : 0; + const direction = dpointNorm({ x: entry.x - tail.x, y: entry.y - tail.y }); + let center = pointAdd(tail, dpointToPoint(dpointScalmult(75, direction))); + const increment = dpointToPoint(dpointScalmult(150 + spacing, direction)); + const widthDelta = + count > 1 ? Math.trunc((400 - 150) / (2 * (count - 1))) : 0; + let widthAdjustment = 0; + for (let i = 0; i < count; i++) { + this.context.beginPath(); + this.context.ellipse( + center.x, + -center.y, + 75 + widthAdjustment, + 75, + 0, + 0, + 2 * Math.PI, + ); + this.context.fill(); + this.context.stroke(); + center = pointAdd(center, increment); + widthAdjustment += widthDelta; + } + } + + private drawBalloon(balloon: PanelBalloon): void { + if (!(balloon.runtime instanceof BalloonRuntime)) return; + const runtime = balloon.runtime; + const fInfo = runtime.fInfo; + if (!fInfo) return; + this.context.save(); + this.context.fillStyle = this.options.background ?? "#fff"; + this.context.strokeStyle = this.options.foreground ?? "#000"; + this.context.lineJoin = "round"; + this.context.lineCap = "round"; + this.balloonPath(balloon, runtime); + if (balloon.mode === SM_WHISPER) { + this.context.lineWidth = 100; + this.context.strokeStyle = this.options.background ?? "#fff"; + this.context.fill(); + this.context.stroke(); + this.context.strokeStyle = this.options.foreground ?? "#000"; + this.context.lineWidth = 28; + this.context.setLineDash([100, 100]); + this.context.stroke(); + this.context.setLineDash([]); + } else { + this.context.lineWidth = 28; + this.context.fill(); + this.context.stroke(); + } + this.drawThinkBubbles(balloon, runtime); + + this.context.fillStyle = this.options.foreground ?? "#000"; + this.context.font = + balloon.mode === SM_WHISPER ? this.whisperFont : this.normalFont; + this.context.textBaseline = "top"; + for (let i = 0; i < fInfo.nLines; i++) { + const line = fInfo.lines[i]; + if (!line) continue; + this.context.fillText( + balloon.text.slice(line.start, line.start + line.length), + runtime.bbox.left + (fInfo.leftX[i] ?? 0), + -(runtime.bbox.top - i * runtime.style.font.lineHeight), + ); + } + this.context.restore(); + } + + private wrapTitle(text: string, maxWidth: number): string[] { + const words = text.split(/\s+/).filter((word) => word.length > 0); + const lines: string[] = []; + let line = ""; + for (const word of words) { + const candidate = line === "" ? word : `${line} ${word}`; + if (line !== "" && this.context.measureText(candidate).width > maxWidth) { + lines.push(line); + line = word; + } else line = candidate; + } + if (line !== "") lines.push(line); + return lines; + } + + private drawStarIcon( + avatarID: number, + left: number, + top: number, + size: number, + ): void { + const avatar = this.avatars.find( + (candidate) => candidate.avatarID === avatarID, + ); + if (!avatar) return; + const pose = avatar.data.poses.find( + (candidate) => candidate.poseID === avatar.data.iconPoseID, + ); + if (!pose?.sprite) return; + const scale = Math.min(size / pose.width, size / pose.height); + const width = Math.round(pose.width * scale); + const height = Math.round(pose.height * scale); + this.context.drawImage( + this.assets.get(pose), + pose.sprite.x, + pose.sprite.y, + pose.width, + pose.height, + left + Math.trunc((size - width) / 2), + top + Math.trunc((size - height) / 2), + width, + height, + ); + } + + // AddStars (panel.cpp:1391-1446): icon+name rows, centered as a block on the widest name + private drawStars(card: TitleCard, top: number): void { + const context = this.context; + const unit = this.options.unitWidth; + const scale = unit / TITLE_UNIT; + const iconSize = Math.round(STAR_ICON_SIZE * scale); + const iconSpace = Math.round(STAR_ICON_SPACE * scale); + const shoutCell = shoutFontCell(unit, BALLOON_FONT_SIZE); + const rowHeight = starRowHeight(unit, scale); + const stars = card.stars.slice( + 0, + starRows(card, top, this.options.unitHeight, rowHeight), + ); + if (stars.length === 0) return; + let maxWidth = 0; + for (const star of stars) + maxWidth = Math.max(maxWidth, context.measureText(star.label).width); + const blockWidth = maxWidth + iconSize + iconSpace; + const iconOffset = Math.max(0, Math.trunc((unit - blockWidth) / 2)); + const textOffset = iconOffset + iconSize + iconSpace; + const iconVdisp = Math.trunc((rowHeight - iconSize) / 2); + const textVdisp = Math.trunc((rowHeight - shoutCell) / 2); + let y = top; + for (const star of stars) { + this.drawStarIcon(star.avatarID, iconOffset, y + iconVdisp, iconSize); + context.fillText(star.label, textOffset, y + textVdisp); + y += rowHeight; + } + } + + // how far a line's ink hangs below its baseline + private descender(text: string): number { + const metrics = this.context.measureText(text); + return Math.max( + 0, + (metrics.actualBoundingBoxDescent ?? 0) - + (metrics.fontBoundingBoxAscent ?? 0), + ); + } + + // AddTitle (panel.cpp:1279-1297): title, STARRING beneath, then the cast + private drawTitle(card: TitleCard): void { + const context = this.context; + const unit = this.options.unitWidth; + const scale = unit / TITLE_UNIT; + this.titleFontCache ??= gdiCellFont( + context, + "400", + titleFontCell(unit, BALLOON_FONT_SIZE), + ); + this.shoutFontCache ??= gdiCellFont( + context, + "400", + shoutFontCell(unit, BALLOON_FONT_SIZE), + ); + context.save(); + context.fillStyle = this.options.foreground ?? "#000"; + context.textBaseline = "top"; + context.textAlign = "center"; + context.font = this.titleFontCache; + let y = Math.round(100 * scale); + const lineHeight = + titleFontCell(unit, BALLOON_FONT_SIZE) + + Math.round(TITLE_LEADING * scale); + for (const line of this.wrapTitle(card.text, unit)) { + context.fillText(line, unit / 2, y); + y += lineHeight + this.descender(line); + } + context.font = this.shoutFontCache; + const starring = card.starring?.trim() ? card.starring : STARRING; + context.fillText(starring, unit / 2, y); + const shoutCell = shoutFontCell(unit, BALLOON_FONT_SIZE); + y += + shoutCell + Math.round(BELOW_STARRING * scale) + this.descender(starring); + const footer = card.footer?.trim(); + context.textAlign = "left"; + this.drawStars(card, y); + if (footer) this.drawFooter(footer, card, y, scale, shoutCell); + context.restore(); + } + + // the cast keeps every row it would draw without a footer; the line takes what is left under it + private drawFooter( + footer: string, + card: TitleCard, + starsTop: number, + scale: number, + shoutCell: number, + ): void { + const context = this.context; + const unit = this.options.unitWidth; + const rowHeight = starRowHeight(unit, scale); + const rows = starRows(card, starsTop, this.options.unitHeight, rowHeight); + const drop = this.descender(footer); + const free = this.options.unitHeight - (starsTop + rows * rowHeight) - drop; + const floor = Math.round(shoutCell * FOOTER_MIN_SCALE); + if (free < floor) return; + let margin = Math.round(FOOTER_MARGIN * scale); + let cell = Math.min(shoutCell, free - margin * 2); + if (cell < floor) { + cell = floor; + margin = Math.trunc((free - floor) / 2); + } + let font = gdiCellFont(context, "400", cell); + context.font = font; + const maxWidth = unit - margin * 2; + const width = context.measureText(footer).width; + if (width > maxWidth) { + cell = Math.round((cell * maxWidth) / width); + font = gdiCellFont(context, "400", cell); + } + context.textAlign = "center"; + context.font = font; + context.fillText( + footer, + unit / 2, + this.options.unitHeight - margin - cell - drop, + ); + } + + render(panel: UnitPanel): void { + const context = this.context; + context.save(); + context.fillStyle = this.options.background ?? "#fff"; + context.fillRect(0, 0, this.options.unitWidth, this.options.unitHeight); + context.imageSmoothingEnabled = true; + context.imageSmoothingQuality = "high"; + // CBackDrop::Draw StretchBlts the DIB across the whole panel rect (backdrop.cpp:330) + const backdrop = panel.backdrop + ? this.options.resolveBackdrop?.(panel.backdrop) + : undefined; + if (backdrop) + context.drawImage( + backdrop, + 0, + 0, + this.options.unitWidth, + this.options.unitHeight, + ); + if (panel.title) this.drawTitle(panel.title); + for (const body of panel.bodies) this.drawBody(body); + for (let i = panel.balloons.length - 1; i >= 0; i--) { + const balloon = panel.balloons[i]; + if (balloon) this.drawBalloon(balloon); + } + if (panel.hasBorder) { + context.strokeStyle = this.options.foreground ?? "#000"; + // panel.cpp:1252 strokes a 2*60-wide pen on the panel edge path; the outer half clips away + context.lineWidth = 120; + context.strokeRect(0, 0, this.options.unitWidth, this.options.unitHeight); + } + context.restore(); + } +} diff --git a/src/render/renderContext.ts b/src/render/renderContext.ts new file mode 100644 index 0000000..4234829 --- /dev/null +++ b/src/render/renderContext.ts @@ -0,0 +1,90 @@ +// The slice of Canvas 2D the panel painters touch, so an SVG recorder can stand in for a real context. + +import type { PoseData } from "../engine/avatar.js"; + +// opaque to the renderer: a decoded bitmap in the browser, an href record in the SVG recorder +export type RenderImage = object; + +export interface AtlasSource { + get(pose: PoseData): RenderImage; +} + +export interface PanelRenderMetrics { + width: number; + fontBoundingBoxAscent?: number; + fontBoundingBoxDescent?: number; + actualBoundingBoxDescent?: number; +} + +export interface PanelRenderContext { + fillStyle: string | object; + strokeStyle: string | object; + lineWidth: number; + lineJoin: string; + lineCap: string; + font: string; + textAlign: string; + textBaseline: string; + imageSmoothingEnabled: boolean; + imageSmoothingQuality: string; + save(): void; + restore(): void; + translate(x: number, y: number): void; + scale(x: number, y: number): void; + setTransform( + a: number, + b: number, + c: number, + d: number, + e: number, + f: number, + ): void; + beginPath(): void; + moveTo(x: number, y: number): void; + lineTo(x: number, y: number): void; + bezierCurveTo( + cp1x: number, + cp1y: number, + cp2x: number, + cp2y: number, + x: number, + y: number, + ): void; + ellipse( + x: number, + y: number, + radiusX: number, + radiusY: number, + rotation: number, + startAngle: number, + endAngle: number, + ): void; + rect(x: number, y: number, w: number, h: number): void; + closePath(): void; + clip(): void; + fill(): void; + stroke(): void; + setLineDash(segments: number[]): void; + fillRect(x: number, y: number, w: number, h: number): void; + strokeRect(x: number, y: number, w: number, h: number): void; + fillText(text: string, x: number, y: number): void; + measureText(text: string): PanelRenderMetrics; + drawImage( + image: RenderImage, + dx: number, + dy: number, + dw: number, + dh: number, + ): void; + drawImage( + image: RenderImage, + sx: number, + sy: number, + sw: number, + sh: number, + dx: number, + dy: number, + dw: number, + dh: number, + ): void; +} diff --git a/src/render/stripMark.ts b/src/render/stripMark.ts new file mode 100644 index 0000000..55b3281 --- /dev/null +++ b/src/render/stripMark.ts @@ -0,0 +1,88 @@ +// The credit line every exported sheet carries, drawn into the band under the last panel row. + +import type { PanelRenderContext } from "./renderContext.js"; + +const MARK_COLOR = "#222"; +const MARK_FAMILY = '"Segoe UI", system-ui, sans-serif'; +// a one-column sheet is the reference; wider sheets scale by the square root, enough to stay legible without taking over +const MARK_BASE_WIDTH = 700; +const MARK_FONT_PX = 12; +const MARK_MIN_FONT_PX = 9; + +// the host comes off the page, so a fork's exports name the fork +export function stripMarkParts(host: string): { left: string; right: string } { + return { + left: "Comic Chat 1996 port · original character design by Jim Woodring", + right: host ? `created at ${host}` : "", + }; +} + +function markFont(width: number): number { + const scale = Math.max(1, Math.sqrt(width / MARK_BASE_WIDTH)); + return Math.round(MARK_FONT_PX * scale); +} + +// both ends share one line, so step down until four ems of daylight separate them +function fitFont( + context: PanelRenderContext, + left: string, + right: string, + width: number, + gap: number, + start: number = gap, +): number { + const room = width - start - gap; + let px = markFont(width); + while (px > MARK_MIN_FONT_PX) { + context.font = `${px}px ${MARK_FAMILY}`; + const used = + context.measureText(left).width + context.measureText(right).width; + if (used + px * 4 <= room) break; + px -= 1; + } + return px; +} + +// the band gives the type twice its own height; a small sheet already has that much gutter +function markBand(width: number, gap: number): number { + return Math.max(gap, markFont(width) * 2); +} + +// extra sheet height the band needs beyond the gutter under the last row +export function stripMarkGrowth(width: number, gap: number): number { + return markBand(width, gap) - gap; +} + +// one baseline, each end set flush against its margin; start lets the credit skip a borderless lead panel +export function drawMarkLine( + context: PanelRenderContext, + width: number, + margin: number, + y: number, + host: string, + start: number = margin, +): void { + const { left, right } = stripMarkParts(host); + context.save(); + context.font = `${fitFont(context, left, right, width, margin, start)}px ${MARK_FAMILY}`; + context.fillStyle = MARK_COLOR; + context.textBaseline = "middle"; + context.textAlign = "left"; + context.fillText(left, start, y); + context.textAlign = "right"; + context.fillText(right, width - margin, y); + context.restore(); +} + +export function drawStripMark( + context: PanelRenderContext, + width: number, + height: number, + gap: number, + host: string, + start: number = gap, +): void { + // whole pixels, so "middle" never lands the glyphs on a subpixel boundary + const y = height - Math.round(markBand(width, gap) / 2); + drawMarkLine(context, width, gap, y, host, start); +} diff --git a/src/render/svgContext.ts b/src/render/svgContext.ts new file mode 100644 index 0000000..9c59027 --- /dev/null +++ b/src/render/svgContext.ts @@ -0,0 +1,395 @@ +// Records the panel painters' canvas calls as SVG elements; transforms here are always scale plus translate, never rotation. + +import { COMIC_NEUE_REGULAR } from "./comicNeueMetrics.js"; +import type { + PanelRenderContext, + PanelRenderMetrics, +} from "./renderContext.js"; +import { parseFontPx, TableMeasureContext } from "./textTable.js"; + +export interface SvgImage { + href: string; + width: number; + height: number; +} + +interface Matrix { + sx: number; + sy: number; + tx: number; + ty: number; +} + +interface DrawState { + fillStyle: string; + strokeStyle: string; + lineWidth: number; + lineJoin: string; + lineCap: string; + font: string; + textAlign: string; + textBaseline: string; + dash: number[]; +} + +interface Frame { + matrix: Matrix; + state: DrawState; + openGroups: number; +} + +function fmt(value: number): string { + const rounded = Math.round(value * 100) / 100; + return String(Object.is(rounded, -0) ? 0 : rounded); +} + +function escapeXml(text: string): string { + return text + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +// balloon text was measured as Comic Neue, so the embedded face leads and local Comic Sans stays a fallback +function fontFamily(font: string): string { + return font.includes("Comic") + ? "'Comic Neue', 'Comic Sans MS', cursive" + : "'Segoe UI', system-ui, sans-serif"; +} + +export class SvgRenderContext implements PanelRenderContext { + imageSmoothingEnabled = true; + imageSmoothingQuality = "high"; + private matrix: Matrix = { sx: 1, sy: 1, tx: 0, ty: 0 }; + private state: DrawState = { + fillStyle: "#000", + strokeStyle: "#000", + lineWidth: 1, + lineJoin: "miter", + lineCap: "butt", + font: "10px sans-serif", + textAlign: "left", + textBaseline: "alphabetic", + dash: [], + }; + private readonly stack: Frame[] = []; + private path: string[] = []; + private pathRects: { x: number; y: number; w: number; h: number }[] = []; + private pathIsRects = true; + private openGroups = 0; + private clipCount = 0; + private readonly defs: string[] = []; + private readonly body: string[] = []; + private readonly measurer = new TableMeasureContext(); + + get fillStyle(): string { + return this.state.fillStyle; + } + set fillStyle(value: string | object) { + this.state.fillStyle = String(value); + } + get strokeStyle(): string { + return this.state.strokeStyle; + } + set strokeStyle(value: string | object) { + this.state.strokeStyle = String(value); + } + get lineWidth(): number { + return this.state.lineWidth; + } + set lineWidth(value: number) { + this.state.lineWidth = value; + } + get lineJoin(): string { + return this.state.lineJoin; + } + set lineJoin(value: string) { + this.state.lineJoin = value; + } + get lineCap(): string { + return this.state.lineCap; + } + set lineCap(value: string) { + this.state.lineCap = value; + } + get font(): string { + return this.state.font; + } + set font(value: string) { + this.state.font = value; + } + get textAlign(): string { + return this.state.textAlign; + } + set textAlign(value: string) { + this.state.textAlign = value; + } + get textBaseline(): string { + return this.state.textBaseline; + } + set textBaseline(value: string) { + this.state.textBaseline = value; + } + + save(): void { + this.stack.push({ + matrix: { ...this.matrix }, + state: { ...this.state, dash: [...this.state.dash] }, + openGroups: this.openGroups, + }); + } + + restore(): void { + const frame = this.stack.pop(); + if (!frame) return; + while (this.openGroups > frame.openGroups) { + this.body.push(""); + this.openGroups--; + } + this.matrix = frame.matrix; + this.state = frame.state; + } + + translate(x: number, y: number): void { + this.matrix.tx += this.matrix.sx * x; + this.matrix.ty += this.matrix.sy * y; + } + + scale(x: number, y: number): void { + this.matrix.sx *= x; + this.matrix.sy *= y; + } + + setTransform( + a: number, + b: number, + c: number, + d: number, + e: number, + f: number, + ): void { + if (b !== 0 || c !== 0) + throw new Error("SvgRenderContext does not support rotation or skew"); + this.matrix = { sx: a, sy: d, tx: e, ty: f }; + } + + private mapX(x: number): number { + return this.matrix.sx * x + this.matrix.tx; + } + + private mapY(y: number): number { + return this.matrix.sy * y + this.matrix.ty; + } + + beginPath(): void { + this.path = []; + this.pathRects = []; + this.pathIsRects = true; + } + + moveTo(x: number, y: number): void { + this.path.push(`M${fmt(this.mapX(x))} ${fmt(this.mapY(y))}`); + this.pathIsRects = false; + } + + lineTo(x: number, y: number): void { + this.path.push(`L${fmt(this.mapX(x))} ${fmt(this.mapY(y))}`); + this.pathIsRects = false; + } + + bezierCurveTo( + cp1x: number, + cp1y: number, + cp2x: number, + cp2y: number, + x: number, + y: number, + ): void { + this.path.push( + `C${fmt(this.mapX(cp1x))} ${fmt(this.mapY(cp1y))} ${fmt(this.mapX(cp2x))} ${fmt(this.mapY(cp2y))} ${fmt(this.mapX(x))} ${fmt(this.mapY(y))}`, + ); + this.pathIsRects = false; + } + + ellipse( + x: number, + y: number, + radiusX: number, + radiusY: number, + _rotation: number, + _startAngle: number, + _endAngle: number, + ): void { + const cx = this.mapX(x); + const cy = this.mapY(y); + const rx = Math.abs(this.matrix.sx * radiusX); + const ry = Math.abs(this.matrix.sy * radiusY); + this.path.push( + `M${fmt(cx + rx)} ${fmt(cy)}`, + `A${fmt(rx)} ${fmt(ry)} 0 1 0 ${fmt(cx - rx)} ${fmt(cy)}`, + `A${fmt(rx)} ${fmt(ry)} 0 1 0 ${fmt(cx + rx)} ${fmt(cy)}`, + "Z", + ); + this.pathIsRects = false; + } + + rect(x: number, y: number, w: number, h: number): void { + const left = this.mapX(x); + const top = this.mapY(y); + const width = this.matrix.sx * w; + const height = this.matrix.sy * h; + this.path.push( + `M${fmt(left)} ${fmt(top)}`, + `L${fmt(left + width)} ${fmt(top)}`, + `L${fmt(left + width)} ${fmt(top + height)}`, + `L${fmt(left)} ${fmt(top + height)}`, + "Z", + ); + this.pathRects.push({ x: left, y: top, w: width, h: height }); + } + + closePath(): void { + this.path.push("Z"); + } + + clip(): void { + if (!this.pathIsRects || this.pathRects.length === 0) + throw new Error("SvgRenderContext clips rectangular paths only"); + const id = `clip${this.clipCount++}`; + const rects = this.pathRects + .map( + (r) => + ``, + ) + .join(""); + this.defs.push(`${rects}`); + this.body.push(``); + this.openGroups++; + } + + private strokeAttrs(): string { + const scale = Math.abs(this.matrix.sy); + const parts = [ + `stroke="${this.state.strokeStyle}"`, + `stroke-width="${fmt(this.state.lineWidth * scale)}"`, + ]; + if (this.state.lineJoin !== "miter") + parts.push(`stroke-linejoin="${this.state.lineJoin}"`); + if (this.state.lineCap !== "butt") + parts.push(`stroke-linecap="${this.state.lineCap}"`); + if (this.state.dash.length > 0) + parts.push( + `stroke-dasharray="${this.state.dash.map((d) => fmt(d * scale)).join(" ")}"`, + ); + return parts.join(" "); + } + + fill(): void { + if (this.path.length === 0) return; + this.body.push( + ``, + ); + } + + stroke(): void { + if (this.path.length === 0) return; + this.body.push( + ``, + ); + } + + setLineDash(segments: number[]): void { + this.state.dash = [...segments]; + } + + fillRect(x: number, y: number, w: number, h: number): void { + this.body.push( + ``, + ); + } + + strokeRect(x: number, y: number, w: number, h: number): void { + this.body.push( + ``, + ); + } + + fillText(text: string, x: number, y: number): void { + if (text === "") return; + const size = parseFontPx(this.state.font) * Math.abs(this.matrix.sy); + const { ascent, descent, unitsPerEm } = COMIC_NEUE_REGULAR; + let baseline = this.mapY(y); + if (this.state.textBaseline === "top") + baseline += (ascent / unitsPerEm) * size; + else if (this.state.textBaseline === "middle") + baseline += ((ascent - descent) / 2 / unitsPerEm) * size; + const anchor = + this.state.textAlign === "center" + ? "middle" + : this.state.textAlign === "right" + ? "end" + : "start"; + const style = this.state.font.includes("italic") + ? ' font-style="italic"' + : ""; + this.body.push( + `${escapeXml(text)}`, + ); + } + + measureText(text: string): PanelRenderMetrics { + this.measurer.font = this.state.font; + if ("textBaseline" in this.measurer) + this.measurer.textBaseline = this.state.textBaseline; + return this.measurer.measureText(text); + } + + drawImage(image: SvgImage, ...rest: number[]): void { + const { sx, sy, tx, ty } = this.matrix; + const transform = + sx === 1 && sy === 1 && tx === 0 && ty === 0 + ? "" + : ` transform="matrix(${fmt(sx)} 0 0 ${fmt(sy)} ${fmt(tx)} ${fmt(ty)})"`; + if (rest.length === 4) { + const [dx, dy, dw, dh] = rest as [number, number, number, number]; + this.body.push( + ``, + ); + return; + } + const [srcX, srcY, srcW, srcH, dx, dy, dw, dh] = rest as [ + number, + number, + number, + number, + number, + number, + number, + number, + ]; + const scaleX = dw / srcW; + const scaleY = dh / srcH; + const id = `clip${this.clipCount++}`; + this.defs.push( + ``, + ); + this.body.push( + ``, + ); + } + + svg(width: number, height: number, extraDefs = ""): string { + while (this.openGroups > 0) { + this.body.push(""); + this.openGroups--; + } + const defs = this.defs.join("") + extraDefs; + return [ + ``, + defs ? `${defs}` : "", + this.body.join(""), + "", + ].join(""); + } +} diff --git a/src/render/svgSheet.ts b/src/render/svgSheet.ts new file mode 100644 index 0000000..12e68ae --- /dev/null +++ b/src/render/svgSheet.ts @@ -0,0 +1,89 @@ +// Lays the composed panels onto one SVG sheet, mirroring the studio's PNG export geometry. + +import { + type AvatarData, + AvatarRegistry, + type PoseData, +} from "../engine/avatar.js"; +import type { ComposeResult } from "../studio/compose.js"; +import { CanvasPanelRenderer } from "./panelRenderer.js"; +import { drawStripMark, stripMarkGrowth } from "./stripMark.js"; +import { type SvgImage, SvgRenderContext } from "./svgContext.js"; + +const CARD_PIXELS = 600; +const EXPORT_GAP = 31; + +export interface SvgSheetAssets { + atlas(url: string): SvgImage; + backdrop(name: string): SvgImage | undefined; +} + +export interface SvgSheetOptions { + avatars: readonly AvatarData[]; + columns: number; + host: string; + assets: SvgSheetAssets; + // a ", + }); +} + +describe("renderSheetSvg", () => { + it("lays two panels on one sheet with balloons, backdrop, and the credit", () => { + const svg = render({ + version: 2, + panels: [ + { + background: "volcano", + actors: [{ avatar: "anna", text: "Hello there" }], + }, + { actors: [{ avatar: "bolo", text: "Hi", mode: "whisper" }] }, + ], + }); + expect(svg.startsWith('HELLO"); + expect(svg).toContain(">THERE"); + expect(svg).toContain('font-style="italic"'); + expect(svg).toContain("Comic Chat 1996 port"); + expect(svg).toContain("created at comics.test"); + expect(svg).toContain("@font-face"); + // each panel clips itself, plus one clip per sprite draw + expect(svg.match(/ { + const svg = render({ + version: 2, + panels: [ + { + kind: "title", + title: "The Test", + footer: "a vitest production", + actors: [{ avatar: "anna" }, { avatar: "bolo", text: "Bolo" }], + }, + { actors: [{ avatar: "anna", text: "Hi" }] }, + ], + }); + expect(svg).toContain(">The Test"); + expect(svg).toContain(">STARRING"); + expect(svg).toContain(">Anna"); + expect(svg).toContain(">Bolo"); + expect(svg).toContain(">a vitest production"); + }); + + it("wraps rows by the requested column count", () => { + const panel = { actors: [{ avatar: "anna", text: "Hi" }] }; + const svg = render( + { version: 2, columns: 2, panels: [panel, panel, panel] }, + 2, + ); + // two columns wide, two rows tall + expect(svg).toContain('viewBox="0 0 1293 1294"'); + }); +}); diff --git a/test/textTable.test.ts b/test/textTable.test.ts new file mode 100644 index 0000000..2e33714 --- /dev/null +++ b/test/textTable.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { + COMIC_NEUE_ITALIC, + COMIC_NEUE_REGULAR, +} from "../src/render/comicNeueMetrics.js"; +import { gdiCellFont } from "../src/render/textMetrics.js"; +import { parseFontPx, TableMeasureContext } from "../src/render/textTable.js"; + +describe("TableMeasureContext", () => { + const context = new TableMeasureContext(); + + it("scales advance widths by the css pixel size", () => { + context.font = "400 100px 'Comic Neue'"; + const single = context.measureText("M").width; + context.font = "400 200px 'Comic Neue'"; + expect(context.measureText("M").width).toBeCloseTo(single * 2, 6); + expect(context.measureText("MM").width).toBeCloseTo(single * 4, 6); + }); + + it("reads the italic face for italic font strings", () => { + const scale = 400 / COMIC_NEUE_REGULAR.unitsPerEm; + context.font = "400 400px 'Comic Neue'"; + expect(context.measureText("g").width).toBeCloseTo( + (COMIC_NEUE_REGULAR.advances.g ?? 0) * scale, + 6, + ); + context.font = "italic 400 400px 'Comic Neue'"; + expect(context.measureText("g").width).toBeCloseTo( + (COMIC_NEUE_ITALIC.advances.g ?? 0) * + (400 / COMIC_NEUE_ITALIC.unitsPerEm), + 6, + ); + }); + + it("falls back to a stand-in advance for glyphs outside the table", () => { + context.font = "400 100px 'Comic Neue'"; + expect(context.measureText("世").width).toBeCloseTo( + (COMIC_NEUE_REGULAR.fallbackAdvance / COMIC_NEUE_REGULAR.unitsPerEm) * + 100, + 6, + ); + }); + + it("anchors metrics to the current textBaseline like a real canvas", () => { + context.font = "400 100px 'Comic Neue'"; + const scale = 100 / COMIC_NEUE_REGULAR.unitsPerEm; + const flat = context.measureText("m"); + const deep = context.measureText("g"); + expect(flat.actualBoundingBoxDescent).toBeLessThan(1); + expect(deep.actualBoundingBoxDescent).toBeCloseTo( + (COMIC_NEUE_REGULAR.inkDescents.g ?? 0) * scale, + 6, + ); + context.textBaseline = "top"; + const top = context.measureText("g"); + expect(top.fontBoundingBoxAscent).toBeCloseTo(0, 6); + expect( + top.actualBoundingBoxDescent - top.fontBoundingBoxAscent, + ).toBeCloseTo( + (COMIC_NEUE_REGULAR.ascent + (COMIC_NEUE_REGULAR.inkDescents.g ?? 0)) * + scale, + 6, + ); + context.textBaseline = "alphabetic"; + }); + + it("drives gdiCellFont to a smaller em when the font box outgrows the cell", () => { + const font = gdiCellFont(context, "400", 400); + const px = parseFontPx(font); + const box = COMIC_NEUE_REGULAR.ascent + COMIC_NEUE_REGULAR.descent; + expect(px).toBe( + Math.round((400 * 400) / ((box / COMIC_NEUE_REGULAR.unitsPerEm) * 400)), + ); + expect(px).toBeLessThan(400); + }); +}); diff --git a/test/worker/shortlink.test.ts b/test/worker/shortlink.test.ts new file mode 100644 index 0000000..bf6702a --- /dev/null +++ b/test/worker/shortlink.test.ts @@ -0,0 +1,108 @@ +import { env, SELF } from "cloudflare:test"; +import { describe, expect, it } from "vitest"; +import { screenStrip } from "../../worker/mcp/screen.js"; +import { isProhibited } from "../../worker/moderation.js"; +import { mintShortLink } from "../../worker/shortlink.js"; + +describe("short links", () => { + it("mints a slug and redirects to the packed studio link", async () => { + const slug = await mintShortLink(env.SHORTLINKS, "s=abc123", 100); + expect(slug).toMatch(/^[a-z]+-[a-z]+-\d{1,4}$/); + const response = await SELF.fetch(`https://comic.test/studio/s/${slug}`, { + redirect: "manual", + }); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe( + "https://comic.test/studio.html?s=abc123", + ); + expect(response.headers.get("cache-control")).toBe("no-store"); + }); + + it("redirects the .svg variant to the strip renderer", async () => { + const slug = await mintShortLink(env.SHORTLINKS, "s=abc123", 100); + const response = await SELF.fetch( + `https://comic.test/studio/s/${slug}.svg`, + { redirect: "manual" }, + ); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe( + "https://comic.test/studio/strip.svg?s=abc123", + ); + }); + + it("answers an expired .svg with an svg placeholder", async () => { + const response = await SELF.fetch( + "https://comic.test/studio/s/bravo-gerbil-1.svg", + ); + expect(response.status).toBe(404); + expect(response.headers.get("content-type")).toBe("image/svg+xml"); + expect(await response.text()).toContain("link expired"); + }); + + it("serves the expired page for unknown slugs", async () => { + const response = await SELF.fetch( + "https://comic.test/studio/s/bravo-gerbil-1", + ); + expect(response.status).toBe(404); + expect(await response.text()).toContain("Link expired"); + }); + + it("rejects malformed slugs without touching the store", async () => { + const response = await SELF.fetch( + "https://comic.test/studio/s/UPPER_case!!", + ); + expect(response.status).toBe(404); + }); + + it("refuses non-GET methods", async () => { + const response = await SELF.fetch( + "https://comic.test/studio/s/bravo-gerbil-1", + { + method: "POST", + }, + ); + expect(response.status).toBe(405); + }); + + it("stops minting once the live-link cap is reached", async () => { + await env.SHORTLINKS.put("link:filler-vole-1", "s=a"); + await env.SHORTLINKS.put("link:filler-vole-2", "s=b"); + const live = (await env.SHORTLINKS.list({ prefix: "link:" })).keys.length; + expect(await mintShortLink(env.SHORTLINKS, "s=blocked", live)).toBeNull(); + expect( + await mintShortLink(env.SHORTLINKS, "s=allowed", live + 2), + ).not.toBeNull(); + }); + + it("stores each mint under its own link key", async () => { + const first = await mintShortLink(env.SHORTLINKS, "s=first", 100); + const second = await mintShortLink(env.SHORTLINKS, "s=second", 100); + expect(first).not.toBeNull(); + expect(second).not.toBeNull(); + expect(await env.SHORTLINKS.get(`link:${first}`)).toBe("s=first"); + expect(await env.SHORTLINKS.get(`link:${second}`)).toBe("s=second"); + }); +}); + +describe("screenStrip", () => { + it("flags balloon and title text through the moderation matcher", () => { + const flagged = screenStrip( + { + panels: [ + { title: "4rse", actors: [{ text: "clean words" }] }, + { actors: [{ text: "what the ärse" }] }, + ], + }, + isProhibited, + ); + expect(flagged).toEqual(["panels[0].title", "panels[1].actors[0].text"]); + }); + + it("passes a clean strip untouched", () => { + const flagged = screenStrip( + { panels: [{ title: "The Test", actors: [{ text: "hello there" }] }] }, + isProhibited, + ); + expect(flagged).toEqual([]); + }); +}); diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index b5333f8..98962b3 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,10 +1,12 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 8b6b5c727d0d62904f198b845193268d) +// Generated by Wrangler by running `wrangler types` (hash: ec8d915fb8b1fcced11ff645cff6288c) // Runtime types generated with workerd@1.20260722.1 2026-07-01 interface __BaseEnv_Env { + SHORTLINKS: KVNamespace; ASSETS: Fetcher; ROOMS: ["lobby","tech-news","weather-chat"]; MODERATION: "on"; + SHORTLINK_CAP: 100; AUTH_REQUIRED: string; CLERK_PUBLISHABLE_KEY: string; CLERK_SECRET_KEY: string; diff --git a/worker/index.ts b/worker/index.ts index 355763a..303ce12 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -1,18 +1,23 @@ // Worker entry: routes room WebSockets to their Durable Object, everything else to static assets. +import { createMcpHandler } from "@modelcontextprotocol/server"; import { type RoomListing, resolveRoomAllowlist, roomNameFromPath, } from "../src/protocol/room.js"; import { RoomDirectoryDO } from "./directory.js"; +import { createStudioServer } from "./mcp/server.js"; import { isProhibited, screeningEnabled } from "./moderation.js"; +import { stripSvgResponse } from "./render/stripSvg.js"; import { ChatRoomDO } from "./room.js"; +import { SHORT_LINK_PATH, shortLinkResponse } from "./shortlink.js"; export { ChatRoomDO, RoomDirectoryDO }; const MAX_SCREEN_TEXTS = 200; const MAX_SCREEN_CHARS = 2000; +const MAX_MCP_BODY = 256 * 1024; // the studio's profanity screen; the wordlist stays here so it never ships to a browser async function moderate(request: Request, env: Env): Promise { @@ -56,6 +61,25 @@ async function roomDirectory( export default { async fetch(request, env): Promise { const url = new URL(request.url); + if (url.pathname === "/studio/strip.svg") + return stripSvgResponse(request, env.ASSETS); + if (url.pathname.startsWith(SHORT_LINK_PATH)) + return shortLinkResponse(request, env.SHORTLINKS); + if (url.pathname === "/mcp" || url.pathname.startsWith("/mcp/")) { + if (Number(request.headers.get("content-length")) > MAX_MCP_BODY) + return Response.json({ error: "request too large" }, { status: 413 }); + const mcp = createMcpHandler(() => + createStudioServer({ + assets: env.ASSETS, + base: url.origin, + shortLinks: env.SHORTLINKS, + // a missing or malformed cap fails closed + cap: Number(env.SHORTLINK_CAP) || 0, + ...(screeningEnabled(env) ? { prohibited: isProhibited } : {}), + }), + ); + return mcp.fetch(request); + } if (url.pathname.startsWith("/api/")) { if (url.pathname === "/api/moderate") return moderate(request, env); const allowed = resolveRoomAllowlist(env.ROOMS); diff --git a/worker/mcp/catalog.ts b/worker/mcp/catalog.ts new file mode 100644 index 0000000..0bab45a --- /dev/null +++ b/worker/mcp/catalog.ts @@ -0,0 +1,36 @@ +export interface CatalogAvatar { + name: string; + emotions: Record; + aliases?: Record; + gestures?: string[]; +} + +export interface Catalog { + version: number; + emotions: string[]; + gestures: string[]; + modes: string[]; + cameras: string[]; + facings: string[]; + sizes: string[]; + kinds: string[]; + backgrounds: string[]; + limits: { + actors: number; + balloons: number; + zoom: [number, number]; + columns: [number, number]; + }; + avatars: CatalogAvatar[]; +} + +let cached: Catalog | undefined; + +export async function loadCatalog(assets: Fetcher): Promise { + if (cached) return cached; + const response = await assets.fetch( + new Request("https://placeholder/assets/catalog.json"), + ); + cached = (await response.json()) as Catalog; + return cached; +} diff --git a/worker/mcp/link.ts b/worker/mcp/link.ts new file mode 100644 index 0000000..fd6ccf5 --- /dev/null +++ b/worker/mcp/link.ts @@ -0,0 +1,38 @@ +function toBase64Url(data: Uint8Array): string { + const binary = Array.from(data, (byte) => String.fromCharCode(byte)).join(""); + return btoa(binary) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +async function deflateRaw(data: Uint8Array): Promise { + const stream = new Blob([data]) + .stream() + .pipeThrough(new CompressionStream("deflate-raw")); + return new Uint8Array(await new Response(stream).arrayBuffer()); +} + +export async function stripQuery(strip: unknown): Promise { + const json = new TextEncoder().encode(JSON.stringify(strip)); + return `s=${toBase64Url(await deflateRaw(json))}`; +} + +export interface StripLinks { + studio: string; + image: string; + query: string; +} + +export async function stripLinks( + strip: unknown, + base: string, +): Promise { + const query = await stripQuery(strip); + const root = base.replace(/\/$/, ""); + return { + studio: `${root}/studio.html?${query}`, + image: `${root}/studio/strip.svg?${query}`, + query, + }; +} diff --git a/worker/mcp/screen.ts b/worker/mcp/screen.ts new file mode 100644 index 0000000..9a3d6bd --- /dev/null +++ b/worker/mcp/screen.ts @@ -0,0 +1,32 @@ +interface ScreenActor { + text?: string; +} + +interface ScreenPanel { + title?: string; + starring?: string; + footer?: string; + actors: ScreenActor[]; +} + +export interface ScreenStrip { + panels: ScreenPanel[]; +} + +export function screenStrip( + strip: ScreenStrip, + prohibited: (text: string) => boolean, +): string[] { + const flagged: string[] = []; + strip.panels.forEach((panel, p) => { + for (const key of ["title", "starring", "footer"] as const) { + const value = panel[key]; + if (value && prohibited(value)) flagged.push(`panels[${p}].${key}`); + } + panel.actors.forEach((actor, a) => { + if (actor.text && prohibited(actor.text)) + flagged.push(`panels[${p}].actors[${a}].text`); + }); + }); + return flagged; +} diff --git a/worker/mcp/server.ts b/worker/mcp/server.ts new file mode 100644 index 0000000..9d9f204 --- /dev/null +++ b/worker/mcp/server.ts @@ -0,0 +1,469 @@ +import { McpServer } from "@modelcontextprotocol/server"; +import { z } from "zod"; +import { + type LinkStore, + mintShortLink, + SHORT_LINK_PATH, +} from "../shortlink.js"; +import type { Catalog, CatalogAvatar } from "./catalog.js"; +import { loadCatalog } from "./catalog.js"; +import { stripLinks } from "./link.js"; +import { screenStrip } from "./screen.js"; +import { checkStrip } from "./validate.js"; + +// hard caps sized from catalog names and the warn thresholds in validate.ts +const actorSchema = z.strictObject({ + avatar: z.string().max(32).describe("Character name from the catalog"), + text: z.string().max(200).optional().describe("Balloon text"), + mode: z + .enum(["say", "think", "whisper", "action"]) + .optional() + .describe("Balloon style, default say"), + emotion: z + .string() + .max(32) + .optional() + .describe("Face name, or name_N to pin a variant"), + gesture: z + .string() + .max(32) + .optional() + .describe("Pose name from the character's inventory"), + facing: z + .enum(["left", "right"]) + .optional() + .describe("Direction the character faces"), +}); + +const panelSchema = z.strictObject({ + kind: z + .enum(["scene", "title"]) + .optional() + .describe("Panel type, default scene"), + title: z.string().max(80).optional().describe("Heading, title panels only"), + starring: z + .string() + .max(80) + .optional() + .describe("Subheading, title panels only"), + footer: z + .string() + .max(80) + .optional() + .describe("Footer text, title panels only"), + background: z + .string() + .max(32) + .optional() + .describe("Background name from the catalog"), + camera: z + .enum(["wide", "close"]) + .optional() + .describe("Shot framing, default wide"), + zoom: z.number().optional().describe("Zoom level, 0.5 to 2"), + border: z.boolean().optional().describe("Draw panel border"), + actors: z.array(actorSchema).max(8).describe("Characters in this panel"), +}); + +const stripSchema = z.strictObject({ + version: z.number().describe("Schema version, currently 2"), + seed: z + .number() + .int() + .optional() + .describe("Random seed for deterministic layout"), + columns: z.number().int().optional().describe("Grid columns, 1 to 8"), + panels: z.array(panelSchema).max(32).describe("Ordered list of panels"), +}); + +function isSpeaking(a: CatalogAvatar): boolean { + return Object.values(a.emotions).reduce((sum, n) => sum + n, 0) > 1; +} + +function describeAvatar(entry: CatalogAvatar): string { + const faces = Object.entries(entry.emotions) + .filter(([, count]) => count > 0) + .map(([face, count]) => { + const alias = entry.aliases?.[face]; + return alias + ? `${face} ${count} (drawn as ${alias})` + : `${face} ${count}`; + }) + .join(", "); + return [ + entry.name, + ` faces: ${faces}`, + ` poses: ${(entry.gestures ?? []).join(", ") || "none"}`, + ].join("\n"); +} + +function summarizeAvatar(entry: CatalogAvatar): string { + const faces = Object.keys(entry.emotions).filter( + (e) => (entry.emotions[e] ?? 0) > 0, + ); + const poses = (entry.gestures ?? []).length; + return ` ${entry.name}: ${faces.join(" ")}${poses ? `, ${poses} poses` : ""}`; +} + +function buildBearings(catalog: Catalog): string { + return [ + `backgrounds: ${catalog.backgrounds.join(", ")}`, + `cameras: ${catalog.cameras.join(", ")} (default wide)`, + `facings: ${catalog.facings.join(", ")}`, + `modes: ${catalog.modes.join(", ")} (default say)`, + `panel kinds: ${catalog.kinds.join(", ")} (default scene)`, + `gestures (shared): ${catalog.gestures.join(", ")}`, + " characters may also have unique poses; query_cast shows each inventory.", + "", + `limits: ${catalog.limits.actors} actors per panel, ${catalog.limits.balloons} balloons per panel, zoom ${catalog.limits.zoom[0]} to ${catalog.limits.zoom[1]}, ${catalog.limits.columns[0]} to ${catalog.limits.columns[1]} columns`, + "", + "strip shape:", + " {version: 2, seed?, columns?, panels: [panel, ...]}", + "", + "scene panel:", + " {background?, camera?, zoom?, border?, actors: [actor, ...]}", + "", + "title panel:", + ' {kind: "title", title?, starring?, footer?, actors: [{avatar, text?}, ...]}', + " credit rows only. mode/emotion/gesture/facing are ignored on title actors.", + "", + "actor:", + " {avatar, text?, mode?, emotion?, gesture?, facing?}", + ' emotion: "happy" picks randomly among variants. "happy_2" pins variant 2.', + " gesture: a named pose from the character's inventory.", + " if a character lacks the emotion or gesture, it renders neutral.", + "", + "aliases: some characters share art between emotions.", + ' "angry (drawn as sad)" means the angry face reuses sad\'s art.', + " both names work, but the result looks the same.", + ].join("\n"); +} + +const MAX_SHORT_QUERY = 8192; + +export interface StudioServerOptions { + assets: Fetcher; + base: string; + shortLinks?: LinkStore; + cap: number; + prohibited?: (text: string) => boolean; +} + +export function createStudioServer(options: StudioServerOptions) { + const { assets } = options; + const server = new McpServer({ + name: "comic-chat-studio", + version: "1.0.0", + }); + + server.registerPrompt( + "comic-strip", + { + title: "Comic strip authoring guide", + description: + "Vocabulary, schema, cast overview, and workflow for authoring comic strips.", + }, + async () => { + const catalog = await loadCatalog(assets); + const bearings = buildBearings(catalog); + const speaking = catalog.avatars.filter(isSpeaking); + const castBlock = [ + `speaking cast (${speaking.length} of ${catalog.avatars.length}):`, + ...speaking.map(summarizeAvatar), + ].join("\n"); + const workflow = [ + "next steps:", + " 1. query_cast({avatars: [...]}) for detailed art on your chosen characters", + " 2. create_strip to validate and get the studio link", + " use validate_strip to iterate without minting a link.", + ].join("\n"); + return { + messages: [ + { + role: "user" as const, + content: { + type: "text" as const, + text: [bearings, "", castBlock, "", workflow].join("\n"), + }, + }, + ], + }; + }, + ); + + server.registerResource( + "cast", + "comic-chat://cast", + { + title: "Full cast inventory", + description: + "Every speaking character with variant counts and pose names.", + mimeType: "text/plain", + }, + async (uri) => { + const catalog = await loadCatalog(assets); + const speaking = catalog.avatars.filter(isSpeaking); + const legend = + 'legend: "happy 3" = 3 variants; use emotion "happy" or "happy_1" through "happy_3" to pin one. "(drawn as sad)" = reuses sad\'s art. poses are literal names.'; + const lines = speaking.map(describeAvatar); + return { + contents: [ + { + uri: uri.href, + mimeType: "text/plain", + text: `${legend}\n\n${lines.join("\n\n")}`, + }, + ], + }; + }, + ); + + server.registerTool( + "get_bearings", + { + title: "Get bearings", + description: + "Vocabulary, limits, strip schema, and workflow. Call once at the start of a conversation. Redundant if the comic-strip prompt is loaded.", + inputSchema: {}, + annotations: { readOnlyHint: true }, + }, + async () => { + const catalog = await loadCatalog(assets); + const workflow = [ + "", + "workflow:", + " 1. get_bearings (once per conversation)", + " 2. query_cast({speaks: true}) to list characters who can react", + " 3. query_cast({avatars: [...]}) to dump art inventories for your picks", + " 4. create_strip to validate and get the studio link", + " create_strip validates internally. use validate_strip to iterate without minting a link.", + ].join("\n"); + return { + content: [ + { + type: "text" as const, + text: buildBearings(catalog) + workflow, + }, + ], + }; + }, + ); + + server.registerTool( + "query_cast", + { + title: "Query cast", + description: + "Look up which characters own a gesture, an emotion, or both. With character names, dump each one's full art inventory.", + inputSchema: { + avatars: z + .array(z.string()) + .optional() + .describe("Dump these characters in full"), + gesture: z + .string() + .optional() + .describe("Filter to characters that own this pose"), + emotion: z + .string() + .optional() + .describe("Filter to characters that own this face"), + speaks: z + .boolean() + .optional() + .describe( + "Filter to characters with more than one drawing, so they can react", + ), + }, + annotations: { readOnlyHint: true }, + }, + async ({ avatars: names, gesture, emotion, speaks }) => { + const catalog = await loadCatalog(assets); + + if (names && names.length > 0) { + const byName = new Map(catalog.avatars.map((a) => [a.name, a])); + const unknown = names.filter((n) => !byName.has(n)); + if (unknown.length > 0) { + return { + content: [ + { + type: "text" as const, + text: `unknown: ${unknown.join(", ")}. catalog has: ${catalog.avatars.map((a) => a.name).join(", ")}`, + }, + ], + isError: true, + }; + } + const legend = + 'legend: "happy 3" = 3 variants; use emotion "happy" or "happy_1" through "happy_3" to pin one. "(drawn as sad)" = reuses sad\'s art. poses are literal names.'; + const lines = names + .map((n) => byName.get(n)) + .filter((a) => a !== undefined) + .map(describeAvatar); + return { + content: [ + { + type: "text" as const, + text: `${legend}\n\n${lines.join("\n\n")}`, + }, + ], + }; + } + + let matched = catalog.avatars; + if (gesture) + matched = matched.filter((a) => (a.gestures ?? []).includes(gesture)); + if (emotion) + matched = matched.filter((a) => (a.emotions[emotion] ?? 0) > 0); + if (speaks) matched = matched.filter(isSpeaking); + + const filters = [ + gesture && `gesture=${gesture}`, + emotion && `emotion=${emotion}`, + speaks && "speaks", + ] + .filter(Boolean) + .join(" "); + const header = `${filters || "all"}: ${matched.length} of ${catalog.avatars.length} characters`; + const lines = matched.map(summarizeAvatar); + + return { + content: [ + { + type: "text" as const, + text: [header, ...lines].join("\n"), + }, + ], + }; + }, + ); + + server.registerTool( + "validate_strip", + { + title: "Validate strip", + description: + "Check a strip document against the catalog. Returns errors and warnings. create_strip validates internally; use this tool to iterate without minting a link.", + inputSchema: { + strip: z + .record(z.string(), z.unknown()) + .describe("The strip JSON document (any shape accepted)"), + }, + annotations: { readOnlyHint: true }, + }, + async ({ strip }) => { + const catalog = await loadCatalog(assets); + const issues = checkStrip(strip, catalog); + const errors = issues.filter((i) => i.severity === "error"); + const warnings = issues.filter((i) => i.severity === "warning"); + + if (issues.length === 0) { + return { + content: [{ type: "text" as const, text: "ok: no issues found" }], + }; + } + + const lines = issues.map( + (i) => `${i.severity}: ${i.path ? `${i.path}: ` : ""}${i.message}`, + ); + lines.push(`\n${errors.length} error(s), ${warnings.length} warning(s)`); + return { + content: [{ type: "text" as const, text: lines.join("\n") }], + isError: errors.length > 0, + }; + }, + ); + + server.registerTool( + "create_strip", + { + title: "Create strip", + description: + "Validate a strip document and return the link that opens it in the studio editor. Fails on errors; warnings are included alongside the link.", + inputSchema: { + strip: stripSchema.describe("The strip JSON document"), + }, + // minting a short link writes a KV row + annotations: { readOnlyHint: false, destructiveHint: false }, + }, + async ({ strip }) => { + const catalog = await loadCatalog(assets); + const issues = checkStrip(strip, catalog); + const errors = issues.filter((i) => i.severity === "error"); + + if (errors.length > 0) { + const lines = issues.map( + (i) => `${i.severity}: ${i.path ? `${i.path}: ` : ""}${i.message}`, + ); + lines.push( + `\n${errors.length} error(s); fix them before creating the link`, + ); + return { + content: [{ type: "text" as const, text: lines.join("\n") }], + isError: true, + }; + } + + if (options.prohibited) { + const flagged = screenStrip(strip, options.prohibited); + if (flagged.length > 0) { + return { + content: [ + { + type: "text" as const, + text: `blocked: prohibited language in ${flagged.join(", ")}`, + }, + ], + isError: true, + }; + } + } + + const links = await stripLinks(strip, options.base); + let editor = links.studio; + let image = links.image; + let expires = false; + if (options.shortLinks && links.query.length <= MAX_SHORT_QUERY) { + // minting is best effort; the full links stand on their own + try { + const slug = await mintShortLink( + options.shortLinks, + links.query, + options.cap, + ); + if (slug) { + editor = `${options.base}${SHORT_LINK_PATH}${slug}`; + image = `${options.base}${SHORT_LINK_PATH}${slug}.svg`; + expires = true; + } + } catch {} + } + + const warnings = issues.filter((i) => i.severity === "warning"); + const cast = new Set( + strip.panels + .filter((p) => p.kind !== "title") + .flatMap((p) => + p.actors.filter((a) => a.avatar).map((a) => a.avatar), + ), + ); + const cards = strip.panels.filter((p) => p.kind === "title").length; + + const summary = `ok: ${strip.panels.length} panel(s)${cards ? ` including ${cards} title card` : ""}, ${cast.size} character(s)`; + const parts: string[] = [summary]; + if (warnings.length > 0) { + parts.push(""); + for (const w of warnings) + parts.push(`warning: ${w.path ? `${w.path}: ` : ""}${w.message}`); + } + parts.push("", `editor: ${editor}`, `image: ${image}`); + if (expires) parts.push("links expire in 24h"); + + return { + content: [{ type: "text" as const, text: parts.join("\n") }], + }; + }, + ); + + return server; +} diff --git a/worker/mcp/validate.ts b/worker/mcp/validate.ts new file mode 100644 index 0000000..8cbe591 --- /dev/null +++ b/worker/mcp/validate.ts @@ -0,0 +1,531 @@ +import type { Catalog, CatalogAvatar } from "./catalog.js"; + +const LONG_BALLOON = 90; +const LONG_TITLE = 30; +const LONG_CREDIT = 24; +const LONG_FOOTER = 40; + +const STRIP_KEYS = ["version", "size", "seed", "columns", "panels"]; +const PANEL_KEYS = [ + "kind", + "title", + "starring", + "footer", + "background", + "camera", + "zoom", + "border", + "actors", +]; +const ACTOR_KEYS = ["avatar", "text", "mode", "emotion", "gesture", "facing"]; +const SCENE_KEYS = ["background", "camera", "zoom", "border"]; +const TITLE_KEYS = ["title", "starring", "footer"]; + +export interface StripIssue { + path: string; + message: string; + severity: "error" | "warning"; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function splitName(name: string): { base: string; index?: number } { + const match = /^([a-z]+)_(\d+)$/.exec(name); + return match?.[1] + ? { base: match[1], index: Number(match[2]) } + : { base: name }; +} + +function checkKeys( + raw: Record, + allowed: string[], + path: string, + issues: StripIssue[], +): void { + for (const key of Object.keys(raw)) + if (!allowed.includes(key)) + issues.push({ + path: path ? `${path}.${key}` : key, + message: `unknown field "${key}"`, + severity: "error", + }); +} + +function checkEnum( + raw: Record, + key: string, + allowed: readonly string[], + path: string, + issues: StripIssue[], +): void { + if (raw[key] === undefined) return; + if (!allowed.includes(raw[key] as string)) + issues.push({ + path: `${path}.${key}`, + message: `unknown ${key} "${String(raw[key])}"; use one of ${allowed.join(", ")}`, + severity: "error", + }); +} + +function checkActor( + raw: unknown, + path: string, + catalog: Catalog, + avatars: Map, + issues: StripIssue[], +): void { + if (!isRecord(raw)) { + issues.push({ + path, + message: "actor must be an object", + severity: "error", + }); + return; + } + checkKeys(raw, ACTOR_KEYS, path, issues); + const entry = avatars.get(raw.avatar as string); + if (typeof raw.avatar !== "string" || raw.avatar === "") + issues.push({ + path: `${path}.avatar`, + message: "avatar is required", + severity: "error", + }); + else if (!entry) + issues.push({ + path: `${path}.avatar`, + message: `unknown character "${raw.avatar}"`, + severity: "error", + }); + + if (raw.text !== undefined && typeof raw.text !== "string") + issues.push({ + path: `${path}.text`, + message: "text must be a string", + severity: "error", + }); + else if (typeof raw.text === "string" && raw.text.length > LONG_BALLOON) + issues.push({ + path: `${path}.text`, + message: `${raw.text.length} characters will crowd the art; split it across panels`, + severity: "warning", + }); + + checkEnum(raw, "mode", catalog.modes, path, issues); + checkEnum(raw, "facing", catalog.facings, path, issues); + + if (raw.emotion !== undefined) { + const { base, index } = splitName(String(raw.emotion)); + const count = entry?.emotions[base] ?? 0; + if (!catalog.emotions.includes(base)) + issues.push({ + path: `${path}.emotion`, + message: `unknown emotion "${String(raw.emotion)}"`, + severity: "error", + }); + else if (entry && count === 0) { + const available = Object.entries(entry.emotions) + .filter(([, n]) => n > 0) + .map(([e]) => e) + .join(", "); + issues.push({ + path: `${path}.emotion`, + message: `${entry.name} has no "${base}" art and will pose neutral. available: ${available}`, + severity: "warning", + }); + } else if (index !== undefined && (index < 1 || index > count)) + issues.push({ + path: `${path}.emotion`, + message: `${entry?.name} draws ${count} "${base}" face(s), so "${base}" or "${base}_1" to "${base}_${count}"`, + severity: "error", + }); + const alias = entry?.aliases?.[base]; + if (alias !== undefined) + issues.push({ + path: `${path}.emotion`, + message: `${entry?.name} draws "${base}" with its "${alias}" faces, so the two look the same`, + severity: "warning", + }); + } + + if (raw.gesture !== undefined) { + const gesture = String(raw.gesture); + const { base, index } = splitName(gesture); + const named = + catalog.gestures.includes(gesture) || + (catalog.emotions.includes(base) && index !== undefined); + if (!named) + issues.push({ + path: `${path}.gesture`, + message: `unknown gesture "${gesture}"`, + severity: "error", + }); + else if (entry && !(entry.gestures ?? []).includes(gesture)) { + const available = (entry.gestures ?? []).join(", "); + issues.push({ + path: `${path}.gesture`, + message: `${entry.name} has no "${gesture}" art and will pose neutral. available: ${available}`, + severity: "warning", + }); + } + } +} + +function checkStar( + raw: unknown, + path: string, + avatars: Map, + issues: StripIssue[], +): void { + if (!isRecord(raw)) { + issues.push({ + path, + message: "actor must be an object", + severity: "error", + }); + return; + } + checkKeys(raw, ACTOR_KEYS, path, issues); + if (typeof raw.avatar !== "string" || raw.avatar === "") + issues.push({ + path: `${path}.avatar`, + message: "avatar is required", + severity: "error", + }); + else if (!avatars.has(raw.avatar)) + issues.push({ + path: `${path}.avatar`, + message: `unknown character "${raw.avatar}"`, + severity: "error", + }); + + if (raw.text !== undefined && typeof raw.text !== "string") + issues.push({ + path: `${path}.text`, + message: "text must be a string", + severity: "error", + }); + else if (typeof raw.text === "string" && raw.text.length > LONG_CREDIT) + issues.push({ + path: `${path}.text`, + message: `${raw.text.length} characters is a long credit; the rows centre on the widest one`, + severity: "warning", + }); + + for (const key of ["mode", "emotion", "gesture", "facing"]) + if ((raw as Record)[key] !== undefined) + issues.push({ + path: `${path}.${key}`, + message: `a credit row ignores ${key}`, + severity: "warning", + }); +} + +function checkTitlePanel( + raw: Record, + path: string, + catalog: Catalog, + avatars: Map, + issues: StripIssue[], +): void { + for (const key of SCENE_KEYS) + if (raw[key] !== undefined) + issues.push({ + path: `${path}.${key}`, + message: `a title card ignores ${key}`, + severity: "warning", + }); + + for (const key of TITLE_KEYS) + if (raw[key] !== undefined && typeof raw[key] !== "string") + issues.push({ + path: `${path}.${key}`, + message: `${key} must be a string`, + severity: "error", + }); + + if ( + typeof raw.title === "string" && + (raw.title as string).length > LONG_TITLE + ) + issues.push({ + path: `${path}.title`, + message: `${(raw.title as string).length} characters wraps the title over several lines and crowds the cast off the card`, + severity: "warning", + }); + if ( + typeof raw.footer === "string" && + (raw.footer as string).length > LONG_FOOTER + ) + issues.push({ + path: `${path}.footer`, + message: `${(raw.footer as string).length} characters is a long footer; it draws on one line`, + severity: "warning", + }); + + if (!Array.isArray(raw.actors)) { + issues.push({ + path: `${path}.actors`, + message: "actors must be an array", + severity: "error", + }); + return; + } + if (raw.actors.length === 0) + issues.push({ + path: `${path}.actors`, + message: "title card credits nobody", + severity: "warning", + }); + if (raw.actors.length > catalog.limits.actors) + issues.push({ + path: `${path}.actors`, + message: `a card holds at most ${catalog.limits.actors} credit rows`, + severity: "error", + }); + (raw.actors as unknown[]).forEach((actor, index) => { + checkStar(actor, `${path}.actors[${index}]`, avatars, issues); + }); +} + +function checkPanel( + raw: unknown, + path: string, + catalog: Catalog, + avatars: Map, + issues: StripIssue[], +): void { + if (!isRecord(raw)) { + issues.push({ + path, + message: "panel must be an object", + severity: "error", + }); + return; + } + checkKeys(raw, PANEL_KEYS, path, issues); + checkEnum(raw, "kind", catalog.kinds, path, issues); + if (raw.kind === "title") { + checkTitlePanel(raw, path, catalog, avatars, issues); + return; + } + for (const key of TITLE_KEYS) + if (raw[key] !== undefined) + issues.push({ + path: `${path}.${key}`, + message: `${key} draws only on a "title" panel`, + severity: "warning", + }); + if (raw.background !== undefined && raw.background !== "") + checkEnum(raw, "background", catalog.backgrounds, path, issues); + checkEnum(raw, "camera", catalog.cameras, path, issues); + + const [zoomMin, zoomMax] = catalog.limits.zoom; + if (raw.zoom !== undefined) { + if (typeof raw.zoom !== "number" || !Number.isFinite(raw.zoom)) + issues.push({ + path: `${path}.zoom`, + message: "zoom must be a number", + severity: "error", + }); + else if (raw.zoom < zoomMin || raw.zoom > zoomMax) + issues.push({ + path: `${path}.zoom`, + message: `zoom must be ${zoomMin} to ${zoomMax}`, + severity: "error", + }); + } + if (raw.border !== undefined && typeof raw.border !== "boolean") + issues.push({ + path: `${path}.border`, + message: "border must be a boolean", + severity: "error", + }); + + if (!Array.isArray(raw.actors)) { + issues.push({ + path: `${path}.actors`, + message: "actors must be an array", + severity: "error", + }); + return; + } + if (raw.actors.length === 0) + issues.push({ + path: `${path}.actors`, + message: "panel draws nobody", + severity: "warning", + }); + if (raw.actors.length > catalog.limits.actors) + issues.push({ + path: `${path}.actors`, + message: `a panel holds at most ${catalog.limits.actors} characters`, + severity: "error", + }); + const balloons = (raw.actors as unknown[]).filter( + (actor) => isRecord(actor) && actor.text, + ).length; + if (balloons > catalog.limits.balloons) + issues.push({ + path: `${path}.actors`, + message: `a panel holds at most ${catalog.limits.balloons} balloons`, + severity: "error", + }); + (raw.actors as unknown[]).forEach((actor, index) => { + checkActor(actor, `${path}.actors[${index}]`, catalog, avatars, issues); + }); +} + +function checkShape(panels: unknown[], issues: StripIssue[]): void { + panels.forEach((panel, index) => { + if (isRecord(panel) && panel.kind === "title" && index > 0) + issues.push({ + path: `panels[${index}]`, + message: "a title card opens a strip; this one interrupts one", + severity: "warning", + }); + }); + const drawn = panels + .map((panel, index) => ({ panel, index })) + .filter(({ panel }) => isRecord(panel) && panel.kind !== "title"); + if (drawn.length < 3) return; + const actorsOf = ({ panel }: { panel: unknown }) => + (Array.isArray((panel as Record).actors) + ? ((panel as Record).actors as unknown[]) + : [] + ).filter(isRecord); + + if ( + drawn.every(({ panel }) => !(panel as Record).background) + ) + issues.push({ + path: "panels", + message: "no panel has a backdrop; a blank strip reads unfinished", + severity: "warning", + }); + + const close = drawn.filter( + ({ panel }) => (panel as Record).camera === "close", + ).length; + if (close * 2 >= drawn.length) + issues.push({ + path: "panels", + message: `${close} of ${drawn.length} panels are close shots; wide should carry most of a strip`, + severity: "warning", + }); + + if (!drawn.some((entry) => actorsOf(entry).some((actor) => actor.gesture))) + issues.push({ + path: "panels", + message: + "no panel names a gesture, so every figure holds the same standing pose", + severity: "warning", + }); + + const faces = new Map(); + drawn.forEach((entry) => { + for (const actor of actorsOf(entry)) { + if (typeof actor.avatar !== "string") continue; + const held = faces.get(actor.avatar) ?? []; + const face = + actor.emotion !== undefined + ? String(actor.emotion) + : actor.text + ? `` + : "neutral"; + held.push({ index: entry.index, face }); + faces.set(actor.avatar, held); + } + }); + for (const [avatar, held] of faces) { + let run = 1; + for (let i = 1; i < held.length; i++) { + const previous = held[i - 1]; + const current = held[i]; + if (!previous || !current) continue; + run = + current.face === previous.face && current.index === previous.index + 1 + ? run + 1 + : 1; + if (run === 3) + issues.push({ + path: "panels", + message: `${avatar} wears "${current.face}" in panels[${current.index - 2}] through panels[${current.index}]; vary the face`, + severity: "warning", + }); + } + } +} + +export function checkStrip(raw: unknown, catalog: Catalog): StripIssue[] { + const issues: StripIssue[] = []; + const avatars = new Map(catalog.avatars.map((a) => [a.name, a])); + + if (!isRecord(raw)) { + issues.push({ + path: "", + message: "strip must be a JSON object", + severity: "error", + }); + return issues; + } + checkKeys(raw, STRIP_KEYS, "", issues); + if (raw.version !== catalog.version) + issues.push({ + path: "version", + message: `expected version ${catalog.version}`, + severity: "warning", + }); + if (raw.size !== undefined) + issues.push({ + path: "size", + message: 'strips render "modern"; drop the size field', + severity: "error", + }); + if (raw.seed !== undefined && !Number.isInteger(raw.seed)) + issues.push({ + path: "seed", + message: "seed must be an integer", + severity: "error", + }); + + const [colMin, colMax] = catalog.limits.columns; + if (raw.columns !== undefined) { + if (!Number.isInteger(raw.columns)) + issues.push({ + path: "columns", + message: "columns must be an integer", + severity: "error", + }); + else if ( + (raw.columns as number) < colMin || + (raw.columns as number) > colMax + ) + issues.push({ + path: "columns", + message: `columns must be ${colMin} to ${colMax}`, + severity: "error", + }); + } + + if (!Array.isArray(raw.panels)) { + issues.push({ + path: "panels", + message: "panels must be an array", + severity: "error", + }); + return issues; + } + if (raw.panels.length === 0) + issues.push({ + path: "panels", + message: "strip has no panels", + severity: "warning", + }); + (raw.panels as unknown[]).forEach((panel, index) => { + checkPanel(panel, `panels[${index}]`, catalog, avatars, issues); + }); + checkShape(raw.panels as unknown[], issues); + return issues; +} diff --git a/worker/render/assets.ts b/worker/render/assets.ts new file mode 100644 index 0000000..c2ad247 --- /dev/null +++ b/worker/render/assets.ts @@ -0,0 +1,85 @@ +// Static-asset readers for server-side rendering: manifests, atlas data URIs, and the embedded fonts. + +import type { AvatarData } from "../../src/engine/avatar.js"; +import type { SvgImage } from "../../src/render/svgContext.js"; + +export interface BackdropEntry { + name: string; + url: string; +} + +let avatarsCache: Promise | undefined; +let backdropsCache: Promise | undefined; +let fontDefsCache: Promise | undefined; +const imageCache = new Map>(); + +async function fetchAsset(assets: Fetcher, path: string): Promise { + const response = await assets.fetch( + new Request(`https://placeholder${path}`), + ); + if (!response.ok) throw new Error(`asset ${path}: ${response.status}`); + return response; +} + +export function loadAvatars(assets: Fetcher): Promise { + avatarsCache ??= fetchAsset(assets, "/assets/avatars/manifest.json") + .then((response) => response.json()) + .then((manifest) => (manifest as { avatars: AvatarData[] }).avatars); + return avatarsCache; +} + +export function loadBackdrops(assets: Fetcher): Promise { + backdropsCache ??= fetchAsset(assets, "/assets/backgrounds/manifest.json") + .then((response) => response.json()) + .then((manifest) => (manifest as { backdrops: BackdropEntry[] }).backdrops); + return backdropsCache; +} + +function toBase64(bytes: Uint8Array): string { + let binary = ""; + for (let i = 0; i < bytes.length; i += 0x8000) + binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); + return btoa(binary); +} + +// width and height straight from the IHDR chunk, which always opens a PNG +function pngSize(bytes: Uint8Array): { width: number; height: number } { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return { width: view.getUint32(16), height: view.getUint32(20) }; +} + +export function loadImage(assets: Fetcher, url: string): Promise { + let image = imageCache.get(url); + if (!image) { + image = fetchAsset(assets, url) + .then((response) => response.arrayBuffer()) + .then((buffer) => { + const bytes = new Uint8Array(buffer); + return { + href: `data:image/png;base64,${toBase64(bytes)}`, + ...pngSize(bytes), + }; + }); + imageCache.set(url, image); + image.catch(() => { + if (imageCache.get(url) === image) imageCache.delete(url); + }); + } + return image; +} + +async function fontData(assets: Fetcher, path: string): Promise { + const buffer = await (await fetchAsset(assets, path)).arrayBuffer(); + return toBase64(new Uint8Array(buffer)); +} + +export function fontFaceDefs(assets: Fetcher): Promise { + fontDefsCache ??= Promise.all([ + fontData(assets, "/assets/fonts/comic-neue-400.woff2"), + fontData(assets, "/assets/fonts/comic-neue-400-italic.woff2"), + ]).then( + ([regular, italic]) => + ``, + ); + return fontDefsCache; +} diff --git a/worker/render/stripSvg.ts b/worker/render/stripSvg.ts new file mode 100644 index 0000000..a62af43 --- /dev/null +++ b/worker/render/stripSvg.ts @@ -0,0 +1,152 @@ +// GET /studio/strip.svg?s=: composes the strip server-side and answers a self-contained SVG sheet. + +import type { SvgImage } from "../../src/render/svgContext.js"; +import { renderSheetSvg } from "../../src/render/svgSheet.js"; +import { CanvasTextMeasurer } from "../../src/render/textMetrics.js"; +import { TableMeasureContext } from "../../src/render/textTable.js"; +import { emptyArt } from "../../src/studio/art.js"; +import { composeStrip } from "../../src/studio/compose.js"; +import { + buildCatalog, + COLUMNS_DEFAULT, + parseStrip, +} from "../../src/studio/script.js"; +import { + fontFaceDefs, + loadAvatars, + loadBackdrops, + loadImage, +} from "./assets.js"; + +// bounds the compose work a single request can ask for +const MAX_SCRIPT_BYTES = 65536; + +function decodeBytes(encoded: string): Uint8Array { + const padded = encoded.replace(/-/g, "+").replace(/_/g, "/"); + return Uint8Array.from(atob(padded), (char) => char.charCodeAt(0)); +} + +async function inflate(bytes: Uint8Array): Promise { + const stream = new Blob([bytes]) + .stream() + .pipeThrough(new DecompressionStream("deflate-raw")); + return new Response(stream).text(); +} + +async function scriptFromQuery(url: URL): Promise { + const packed = url.searchParams.get("s"); + if (packed) return inflate(decodeBytes(packed)); + // legacy plain-base64 alias + const inline = url.searchParams.get("script"); + if (inline) return new TextDecoder().decode(decodeBytes(inline)); + return null; +} + +function plain(status: number, text: string): Response { + return new Response(text, { + status, + headers: { "content-type": "text/plain; charset=utf-8" }, + }); +} + +export async function stripSvgResponse( + request: Request, + assets: Fetcher, +): Promise { + if (request.method !== "GET" && request.method !== "HEAD") + return plain(405, "method not allowed"); + const url = new URL(request.url); + + let text: string | null; + try { + text = await scriptFromQuery(url); + } catch { + return plain(400, "the script parameter does not decode"); + } + if (!text) return plain(400, "pass the strip as ?s="); + if (text.length > MAX_SCRIPT_BYTES) return plain(413, "script too large"); + + let raw: unknown; + try { + raw = JSON.parse(text); + } catch { + return plain(400, "the script is not valid JSON"); + } + + const [avatars, backdrops] = await Promise.all([ + loadAvatars(assets), + loadBackdrops(assets), + ]); + const catalog = buildCatalog( + avatars, + backdrops.map((entry) => entry.name), + ); + const { strip, issues } = parseStrip(raw, catalog); + const errors = issues.filter((issue) => issue.severity === "error"); + if (errors.length > 0) + return plain( + 422, + errors + .map((e) => `error: ${e.path ? `${e.path}: ` : ""}${e.message}`) + .join("\n"), + ); + if (strip.panels.length === 0) return plain(422, "strip has no panels"); + + const byName = new Map(catalog.avatars.map((entry) => [entry.name, entry])); + const measurer = new CanvasTextMeasurer(new TableMeasureContext()); + const composed = composeStrip(strip, { + avatars, + resolveStyle: measurer.styleResolver(), + artFor: (name) => byName.get(name)?.art ?? emptyArt(), + }); + + const cast = new Set( + strip.panels.flatMap((panel) => panel.actors.map((actor) => actor.avatar)), + ); + const atlasUrls = new Set(); + for (const data of avatars) + if (cast.has(data.name)) + for (const pose of data.poses) + if (pose.sprite) atlasUrls.add(pose.sprite.atlasUrl); + const backdropByName = new Map(backdrops.map((entry) => [entry.name, entry])); + const usedBackdrops = new Set( + strip.panels.flatMap((panel) => + panel.background ? [panel.background] : [], + ), + ); + + const atlasImages = new Map(); + const backdropImages = new Map(); + const [fontDefs] = await Promise.all([ + fontFaceDefs(assets), + ...[...atlasUrls].map(async (atlasUrl) => { + atlasImages.set(atlasUrl, await loadImage(assets, atlasUrl)); + }), + ...[...usedBackdrops].map(async (name) => { + const entry = backdropByName.get(name); + if (entry) backdropImages.set(name, await loadImage(assets, entry.url)); + }), + ]); + + const svg = renderSheetSvg(composed, { + avatars, + columns: strip.columns ?? COLUMNS_DEFAULT, + host: url.host, + fontDefs, + assets: { + atlas: (atlasUrl) => { + const image = atlasImages.get(atlasUrl); + if (!image) throw new Error(`atlas ${atlasUrl} was not preloaded`); + return image; + }, + backdrop: (name) => backdropImages.get(name), + }, + }); + + return new Response(svg, { + headers: { + "content-type": "image/svg+xml; charset=utf-8", + "cache-control": "public, max-age=86400", + }, + }); +} diff --git a/worker/shortlink.ts b/worker/shortlink.ts new file mode 100644 index 0000000..3eae005 --- /dev/null +++ b/worker/shortlink.ts @@ -0,0 +1,163 @@ +// Temporary studio short links in KV: link: rows with 24h TTL, capped by live-link count. opens the editor, .svg the rendered strip. + +export interface LinkStore { + get(key: string): Promise; + put( + key: string, + value: string, + options?: { expirationTtl?: number }, + ): Promise; + list(options: { + prefix: string; + limit: number; + }): Promise<{ keys: { name: string }[] }>; +} + +export const SHORT_LINK_PATH = "/studio/s/"; +export const LINK_TTL_SECONDS = 86400; +const MINT_ATTEMPTS = 3; +const SLUG_PATTERN = /^[a-z]+-[a-z]+-\d{1,4}$/; + +const ADJECTIVES = [ + "amber", + "bold", + "brave", + "bravo", + "breezy", + "calm", + "cheery", + "clever", + "cosmic", + "crafty", + "daring", + "deft", + "eager", + "fleet", + "gentle", + "giddy", + "golden", + "jolly", + "keen", + "lively", + "lucky", + "mellow", + "merry", + "nifty", + "peppy", + "plucky", + "proud", + "quick", + "snappy", + "sunny", + "swift", + "witty", +] as const; + +const ANIMALS = [ + "badger", + "beaver", + "bison", + "camel", + "condor", + "coyote", + "crane", + "dingo", + "falcon", + "ferret", + "finch", + "gecko", + "gerbil", + "gibbon", + "heron", + "ibex", + "jackal", + "koala", + "lemur", + "llama", + "magpie", + "marmot", + "ocelot", + "otter", + "panda", + "puffin", + "quokka", + "robin", + "tapir", + "toucan", + "walrus", + "wombat", +] as const; + +function randomSlug(): string { + const r = new Uint32Array(3); + crypto.getRandomValues(r); + const adjective = ADJECTIVES[(r[0] as number) % ADJECTIVES.length]; + const animal = ANIMALS[(r[1] as number) % ANIMALS.length]; + return `${adjective}-${animal}-${((r[2] as number) % 9999) + 1}`; +} + +// cap = max live links; KV lists at most 1000 keys, the ceiling any cap can enforce +export async function mintShortLink( + store: LinkStore, + query: string, + cap: number, +): Promise { + if (cap <= 0) return null; + const effectiveCap = Math.min(cap, 1000); + const live = await store.list({ prefix: "link:", limit: effectiveCap }); + if (live.keys.length >= effectiveCap) return null; + for (let attempt = 0; attempt < MINT_ATTEMPTS; attempt++) { + const slug = randomSlug(); + if ((await store.get(`link:${slug}`)) !== null) continue; + await store.put(`link:${slug}`, query, { + expirationTtl: LINK_TTL_SECONDS, + }); + return slug; + } + return null; +} + +const EXPIRED_SVG = `link expired`; + +const EXPIRED_PAGE = ` + +Link expired + +

Link expired

+

Short links only last 24 hours. Ask for a fresh one, or use the full studio link if you saved it.

+

Open the studio

+ +`; + +export async function shortLinkResponse( + request: Request, + store: LinkStore, +): Promise { + if (request.method !== "GET" && request.method !== "HEAD") + return new Response("method not allowed", { status: 405 }); + const url = new URL(request.url); + let slug = url.pathname.slice(SHORT_LINK_PATH.length); + const svg = slug.endsWith(".svg"); + if (svg) slug = slug.slice(0, -4); + const query = SLUG_PATTERN.test(slug) + ? await store.get(`link:${slug}`) + : null; + if (query === null) + return svg + ? new Response(EXPIRED_SVG, { + status: 404, + headers: { "content-type": "image/svg+xml" }, + }) + : new Response(EXPIRED_PAGE, { + status: 404, + headers: { "content-type": "text/html; charset=utf-8" }, + }); + const target = svg ? "/studio/strip.svg" : "/studio.html"; + return new Response(null, { + status: 302, + headers: { + location: `${url.origin}${target}?${query}`, + "cache-control": "no-store", + }, + }); +} diff --git a/wrangler.jsonc b/wrangler.jsonc index a74861c..3f8e8fd 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -9,14 +9,30 @@ "assets": { "directory": "./dist", "binding": "ASSETS", - "not_found_handling": "single-page-application" + "not_found_handling": "single-page-application", + // unlisted routes get asset handling, so every dynamic route must appear here + "run_worker_first": [ + "/api/*", + "/mcp", + "/mcp/*", + "/studio/s/*", + "/studio/strip.svg" + ] }, // bounded default room set: only these rooms accept connections; keep it small to cap how many DOs a public deploy can create "vars": { "ROOMS": ["lobby", "tech-news", "weather-chat"], // "off" drops the profanity screen from the chat room and the studio alike - "MODERATION": "on" + "MODERATION": "on", + "SHORTLINK_CAP": 100 }, + // temporary studio short links, 24h TTL + "kv_namespaces": [ + { + "binding": "SHORTLINKS", + "id": "9355ffc000694c72818b5e28ad5a9a68" + } + ], "durable_objects": { "bindings": [ {