diff --git a/apps/studio/src/app/api/config/route.ts b/apps/studio/src/app/api/config/route.ts index e8131c9..5dd56df 100644 --- a/apps/studio/src/app/api/config/route.ts +++ b/apps/studio/src/app/api/config/route.ts @@ -21,14 +21,12 @@ // the loaded-checkpoint hint. The browser never talks to ComfyUI directly. import { readConfig, type ToonyConfig, writeConfig } from "@toony/project-io"; +import { probeComfyui } from "@/lib/comfyui-probe"; import { safeErrorMessage } from "@/lib/errors"; import { workspaceRoot } from "@/lib/workspace"; export const dynamic = "force-dynamic"; -/** How long to wait for ComfyUI's /system_stats before calling it unreachable. */ -const PING_TIMEOUT_MS = 4_000; - interface SavePayload { comfyui: { endpoint: string; @@ -37,13 +35,6 @@ interface SavePayload { }; } -/** Connection probe outcome for the status badge. */ -interface ConnectionStatus { - state: "reachable" | "unreachable" | "unconfigured"; - /** Present only when reachable and the endpoint reported a system summary. */ - detail?: string; -} - function badRequest(message: string): Response { return Response.json({ ok: false, error: message }, { status: 400 }); } @@ -68,57 +59,6 @@ function isSavePayload(value: unknown): value is SavePayload { ); } -/** - * Probe the configured ComfyUI endpoint's `/system_stats`. Returns a coarse - * reachable/unreachable verdict; any network/HTTP error is "unreachable" (the - * page shows that without treating it as a server error). An unset endpoint is - * "unconfigured" so the badge can prompt the operator to set one. - */ -async function probeConnection(endpoint: string | null): Promise { - if (endpoint === null) return { state: "unconfigured" }; - let base: URL; - try { - base = new URL(endpoint); - } catch { - return { state: "unreachable" }; - } - // SSRF posture (#82): this is a server-side fetch of the OPERATOR's own - // ComfyUI endpoint, and Studio is a local-first, single-user, localhost-only - // tool — the operator sets and trusts this address (typically 127.0.0.1:8188), - // so the risk is accepted for the intended deployment. As a basic guard we only - // probe http/https (no file:, etc.) and keep the short timeout below; we do NOT - // attempt to enumerate/block internal addresses, which would break the common - // case of pointing at a LAN ComfyUI host. - if (base.protocol !== "http:" && base.protocol !== "https:") { - return { state: "unreachable" }; - } - const url = new URL("/system_stats", base); - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), PING_TIMEOUT_MS); - try { - const response = await fetch(url, { signal: controller.signal }); - if (!response.ok) return { state: "unreachable" }; - // ComfyUI returns a JSON summary; surface a compact, safe detail if present. - let detail: string | undefined; - try { - const stats = (await response.json()) as { - system?: { comfyui_version?: unknown; os?: unknown }; - }; - const version = stats.system?.comfyui_version; - if (typeof version === "string" && version.length > 0) { - detail = `ComfyUI ${version}`; - } - } catch { - // A reachable endpoint that returns non-JSON is still reachable. - } - return { state: "reachable", detail }; - } catch { - return { state: "unreachable" }; - } finally { - clearTimeout(timer); - } -} - /** Read the workspace config and probe the configured endpoint. */ export async function GET(): Promise { let config: ToonyConfig; @@ -130,7 +70,7 @@ export async function GET(): Promise { { status: 500 }, ); } - const connection = await probeConnection(config.comfyui.endpoint); + const connection = await probeComfyui(config.comfyui.endpoint); return Response.json({ ok: true, config, connection }); } @@ -176,6 +116,6 @@ export async function POST(request: Request): Promise { ); } - const connection = await probeConnection(config.comfyui.endpoint); + const connection = await probeComfyui(config.comfyui.endpoint); return Response.json({ ok: true, config, connection }); } diff --git a/apps/studio/src/app/page.tsx b/apps/studio/src/app/page.tsx index 905295c..41ad6ad 100644 --- a/apps/studio/src/app/page.tsx +++ b/apps/studio/src/app/page.tsx @@ -7,19 +7,15 @@ // the server-only `@/lib/workspace` wrapper; no wallet/account/publish surfaces — // this is a production tool. +import { join } from "node:path"; import Link from "next/link"; import { NewWorkButton } from "@/components/new-work-button"; -import { listWorks } from "@/lib/workspace"; +import { assetUrl } from "@/lib/project"; +import { listWorks, workspaceRoot } from "@/lib/workspace"; // The workspace is scanned from disk per request, so this page must not be cached. export const dynamic = "force-dynamic"; -/** A project-relative cover path becomes a scoped, path-safe asset URL. */ -function coverUrl(workId: string, coverImagePath: string | null): string | null { - if (!coverImagePath) return null; - return `/api/asset?work=${encodeURIComponent(workId)}&path=${encodeURIComponent(coverImagePath)}`; -} - /** Human-readable "last edited" from an ISO timestamp (date only, locale-free). */ function formatUpdated(iso: string): string { const date = new Date(iso); @@ -29,6 +25,7 @@ function formatUpdated(iso: string): string { export default async function LibraryPage() { const works = await listWorks(); + const wsRoot = workspaceRoot(); const totalEpisodes = works.reduce((sum, work) => sum + work.episodeCount, 0); const totalCuts = works.reduce((sum, work) => sum + work.cutCount, 0); @@ -69,7 +66,9 @@ export default async function LibraryPage() { ) : (
    {works.map((work) => { - const cover = coverUrl(work.id, work.coverImagePath); + // Reuse the shared, path-safe `assetUrl` (its `resolveWorkAsset` + // pre-check) instead of re-building the URL here (#154). + const cover = assetUrl(work.id, join(wsRoot, work.id), work.coverImagePath); return (
  • { - if (endpoint === null) return { state: "unconfigured" }; - let base: URL; - try { - base = new URL(endpoint); - } catch { - return { state: "unreachable" }; - } - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), PING_TIMEOUT_MS); - try { - const response = await fetch(new URL("/system_stats", base), { signal: controller.signal }); - if (!response.ok) return { state: "unreachable" }; - let detail: string | undefined; - try { - const stats = (await response.json()) as { system?: { comfyui_version?: unknown } }; - const version = stats.system?.comfyui_version; - if (typeof version === "string" && version.length > 0) detail = `ComfyUI ${version}`; - } catch { - // Reachable but non-JSON is still reachable. - } - return { state: "reachable", detail }; - } catch { - return { state: "unreachable" }; - } finally { - clearTimeout(timer); - } -} - export default async function SettingsPage() { const config = await readConfig(workspaceRoot()); - const connection = await probe(config.comfyui.endpoint); + // Single-sourced probe (#154), so the first-paint badge matches /api/config + // exactly — including the http/https SSRF guard the settings page now adopts. + const connection = await probeComfyui(config.comfyui.endpoint); return (
    diff --git a/apps/studio/src/app/w/[workId]/episodes/[id]/page.tsx b/apps/studio/src/app/w/[workId]/episodes/[id]/page.tsx index 2aee789..97d6a8f 100644 --- a/apps/studio/src/app/w/[workId]/episodes/[id]/page.tsx +++ b/apps/studio/src/app/w/[workId]/episodes/[id]/page.tsx @@ -12,6 +12,7 @@ import { LoadError } from "@/components/load-error"; import { TransitionBlock } from "@/components/transition-block"; import { type CutArt, + FALLBACK_ART, findEpisodeBundle, loadWork, ProjectIoError, @@ -130,7 +131,7 @@ export default async function EpisodePreviewPage({ key={key} cut={cut} bubbles={bubblesByCut.get(cut.id) ?? []} - art={artByCut.get(cut.id) ?? { src: null, width: 1000, height: 1414 }} + art={artByCut.get(cut.id) ?? FALLBACK_ART} workId={work.id} episodeId={episode.id} /> diff --git a/apps/studio/src/app/w/[workId]/episodes/[id]/read/page.tsx b/apps/studio/src/app/w/[workId]/episodes/[id]/read/page.tsx index 200dc13..b2b8386 100644 --- a/apps/studio/src/app/w/[workId]/episodes/[id]/read/page.tsx +++ b/apps/studio/src/app/w/[workId]/episodes/[id]/read/page.tsx @@ -16,6 +16,7 @@ import { LoadError } from "@/components/load-error"; import { TransitionBlock } from "@/components/transition-block"; import { type CutArt, + FALLBACK_ART, findEpisodeBundle, loadWork, ProjectIoError, @@ -103,7 +104,7 @@ export default async function EpisodeReaderPage({ key={key} cut={cut} bubbles={bubblesByCut.get(cut.id) ?? []} - art={artByCut.get(cut.id) ?? { src: null, width: 1000, height: 1414 }} + art={artByCut.get(cut.id) ?? FALLBACK_ART} workId={work.id} episodeId={episode.id} readOnly diff --git a/apps/studio/src/components/export-panel.tsx b/apps/studio/src/components/export-panel.tsx index 17aef30..a0779b6 100644 --- a/apps/studio/src/components/export-panel.tsx +++ b/apps/studio/src/components/export-panel.tsx @@ -12,6 +12,14 @@ // verbatim. No state is persisted here; the engine writes the real output on disk. import type { ExportManifest, ManifestFile } from "@toony/export"; +// Browser-safe subpath: plain constants with no Node/canvas imports (#154), so +// the client bundle single-sources them without pulling in the export engine. +import { + DEFAULT_JPEG_QUALITY, + PLATFORM_DEFAULT_WIDTH, + PLOTLINK_DEFAULT_WIDTH, + STITCHED_DEFAULT_WIDTH, +} from "@toony/export/defaults"; import { useCallback, useMemo, useState } from "react"; import { type ConstraintCheck, formatBytes } from "@/lib/export-view"; @@ -32,8 +40,8 @@ interface TargetSpec { defaultWidth: number; } -// Defaults mirror the engine's per-target defaults so the controls open at the -// values the engine would otherwise apply. +// Per-target defaults come from the export engine (#154 single-source), so the +// controls open at exactly the values the engine would otherwise apply. const TARGETS = [ { kind: "platform", @@ -41,7 +49,7 @@ const TARGETS = [ blurb: "One image per cut, in reading order.", supportsFormat: true, supportsQuality: true, - defaultWidth: 1200, + defaultWidth: PLATFORM_DEFAULT_WIDTH, }, { kind: "stitched", @@ -49,7 +57,7 @@ const TARGETS = [ blurb: "A single tall image: cuts, gutters, transitions, lettering.", supportsFormat: true, supportsQuality: true, - defaultWidth: 1200, + defaultWidth: STITCHED_DEFAULT_WIDTH, }, { kind: "plotlink", @@ -57,7 +65,7 @@ const TARGETS = [ blurb: "WebP package (≤20 images, ≤1 MB each) plus generated markdown.", supportsFormat: false, supportsQuality: true, - defaultWidth: 800, + defaultWidth: PLOTLINK_DEFAULT_WIDTH, }, ] as const satisfies readonly TargetSpec[]; @@ -83,9 +91,9 @@ export interface ExportPanelProps { export function ExportPanel({ workId, episodeId }: ExportPanelProps) { const [target, setTarget] = useState("platform"); - const [width, setWidth] = useState(1200); + const [width, setWidth] = useState(PLATFORM_DEFAULT_WIDTH); const [format, setFormat] = useState<"png" | "jpeg">("png"); - const [quality, setQuality] = useState(82); + const [quality, setQuality] = useState(DEFAULT_JPEG_QUALITY); const [busy, setBusy] = useState(false); const [error, setError] = useState<{ message: string; code?: string } | null>(null); const [result, setResult] = useState(null); diff --git a/apps/studio/src/lib/__tests__/comfyui-probe.test.ts b/apps/studio/src/lib/__tests__/comfyui-probe.test.ts new file mode 100644 index 0000000..fb7fd52 --- /dev/null +++ b/apps/studio/src/lib/__tests__/comfyui-probe.test.ts @@ -0,0 +1,29 @@ +// Shared ComfyUI probe tests (#154 item 8), in the #157 node:test harness. +// +// Both `/api/config` and the settings page call `probeComfyui`, so testing the +// shared helper covers both paths. The SSRF protocol guard + the no-endpoint / +// bad-URL branches short-circuit BEFORE any `fetch`, so they're verifiable here +// with no network; the reachable/unreachable-via-fetch path needs a live server. + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { probeComfyui } from "../comfyui-probe.js"; + +test("an unset endpoint is unconfigured", async () => { + assert.deepEqual(await probeComfyui(null), { state: "unconfigured" }); +}); + +test("a malformed endpoint is unreachable (no fetch)", async () => { + assert.deepEqual(await probeComfyui("not a url"), { state: "unreachable" }); +}); + +test("a non-http(s) endpoint is unreachable via the SSRF protocol guard (#154 item 8)", async () => { + // The guard both call paths now share: file:/ftp:/etc. never trigger a fetch. + for (const endpoint of ["file:///etc/passwd", "ftp://internal.host/x", "gopher://127.0.0.1/"]) { + assert.deepEqual( + await probeComfyui(endpoint), + { state: "unreachable" }, + `expected ${endpoint} to be unreachable`, + ); + } +}); diff --git a/apps/studio/src/lib/comfyui-probe.ts b/apps/studio/src/lib/comfyui-probe.ts new file mode 100644 index 0000000..5065cda --- /dev/null +++ b/apps/studio/src/lib/comfyui-probe.ts @@ -0,0 +1,58 @@ +// Shared ComfyUI reachability probe (#154). +// +// Both the `/api/config` route and the settings page probe the operator's +// configured ComfyUI `/system_stats` for a coarse reachable/unreachable verdict. +// This is the SINGLE implementation both call — including the SSRF protocol guard +// (http/https only) the route had and the settings page previously LACKED, which +// the settings page now adopts as a strict security improvement (#154 item 8). + +/** Coarse ComfyUI connection verdict for the status badge. */ +export interface ConnectionStatus { + state: "reachable" | "unreachable" | "unconfigured"; + detail?: string; +} + +const PING_TIMEOUT_MS = 4_000; + +/** + * Probe the configured ComfyUI endpoint's `/system_stats`. Any network/HTTP error + * is "unreachable"; an unset endpoint is "unconfigured". A non-http(s) endpoint is + * "unreachable" without a fetch (the SSRF protocol guard). + */ +export async function probeComfyui(endpoint: string | null): Promise { + if (endpoint === null) return { state: "unconfigured" }; + let base: URL; + try { + base = new URL(endpoint); + } catch { + return { state: "unreachable" }; + } + // SSRF posture (#82): a server-side fetch of the OPERATOR's own local-first + // ComfyUI endpoint (typically 127.0.0.1:8188), which they set and trust. As a + // basic guard we only probe http/https (no file:, etc.) and keep the short + // timeout below; we do NOT enumerate/block internal addresses, which would + // break the common case of pointing at a LAN ComfyUI host. + if (base.protocol !== "http:" && base.protocol !== "https:") { + return { state: "unreachable" }; + } + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), PING_TIMEOUT_MS); + try { + const response = await fetch(new URL("/system_stats", base), { signal: controller.signal }); + if (!response.ok) return { state: "unreachable" }; + // ComfyUI returns a JSON summary; surface a compact, safe detail if present. + let detail: string | undefined; + try { + const stats = (await response.json()) as { system?: { comfyui_version?: unknown } }; + const version = stats.system?.comfyui_version; + if (typeof version === "string" && version.length > 0) detail = `ComfyUI ${version}`; + } catch { + // A reachable endpoint that returns non-JSON is still reachable. + } + return { state: "reachable", detail }; + } catch { + return { state: "unreachable" }; + } finally { + clearTimeout(timer); + } +} diff --git a/apps/studio/src/lib/project.ts b/apps/studio/src/lib/project.ts index e384b1c..733450b 100644 --- a/apps/studio/src/lib/project.ts +++ b/apps/studio/src/lib/project.ts @@ -131,8 +131,9 @@ export interface CutArt { height: number; } -/** Default aspect when an asset is missing or its header cannot be read. */ -const FALLBACK_ART: CutArt = { src: null, width: 1000, height: 1414 }; +/** Default aspect when an asset is missing or its header cannot be read. The + * single source pages use for a cut whose art hasn't resolved (#154). */ +export const FALLBACK_ART: CutArt = { src: null, width: 1000, height: 1414 }; /** * Resolve a cut's art for the preview: prefer the final image, then the clean diff --git a/packages/export/package.json b/packages/export/package.json index d880c53..7a5d5c0 100644 --- a/packages/export/package.json +++ b/packages/export/package.json @@ -9,6 +9,10 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" + }, + "./defaults": { + "types": "./dist/defaults.d.ts", + "import": "./dist/defaults.js" } }, "files": [ diff --git a/packages/export/src/defaults.ts b/packages/export/src/defaults.ts new file mode 100644 index 0000000..65fb3ef --- /dev/null +++ b/packages/export/src/defaults.ts @@ -0,0 +1,15 @@ +// Plain export defaults (#154) — deliberately free of any Node/canvas import. +// +// The Node-only export engine (targets/encode) imports these, AND the studio's +// browser client (export-panel) single-sources them via "@toony/export/defaults" +// — a module with no runtime dependencies, so pulling these numbers into a client +// bundle does NOT drag in `@napi-rs/canvas` / `node:fs`. Exactly one defining site. + +/** Per-target default render width in px. */ +export const PLATFORM_DEFAULT_WIDTH = 1200; +export const STITCHED_DEFAULT_WIDTH = 1200; +export const PLOTLINK_DEFAULT_WIDTH = 800; + +/** Default lossy encode quality (0..100) for JPEG / WebP. */ +export const DEFAULT_JPEG_QUALITY = 82; +export const DEFAULT_WEBP_QUALITY = 82; diff --git a/packages/export/src/encode.ts b/packages/export/src/encode.ts index aa6344b..8a4e02d 100644 --- a/packages/export/src/encode.ts +++ b/packages/export/src/encode.ts @@ -2,12 +2,10 @@ // for the PlotLink per-image byte budget. import { type Canvas, createCanvas } from "@napi-rs/canvas"; +import { DEFAULT_JPEG_QUALITY, DEFAULT_WEBP_QUALITY } from "./defaults.js"; export type RasterFormat = "png" | "jpeg" | "webp"; -export const DEFAULT_JPEG_QUALITY = 82; -export const DEFAULT_WEBP_QUALITY = 82; - /** Clamp a lossy quality into the valid 0..100 range. */ export function clampQuality(quality: number): number { if (!Number.isFinite(quality)) return DEFAULT_JPEG_QUALITY; diff --git a/packages/export/src/fonts.ts b/packages/export/src/fonts.ts index dce53eb..665c25e 100644 --- a/packages/export/src/fonts.ts +++ b/packages/export/src/fonts.ts @@ -20,6 +20,7 @@ import { resolveFontFamily, } from "@toony/fonts"; import { fontAssetPath } from "@toony/fonts/node"; +import { matchFaceWeight } from "@toony/render"; import type { BubbleKind } from "@toony/schema"; // Canvas family name for a face FILE's weight. 400 files register under the @@ -35,11 +36,11 @@ function nameForFaceWeight(family: FontFamily, faceWeight: FontFaceWeight): stri * following CSS font-weight matching for the {400,700} set each family ships: * 600-700 pick the bold (700) face, 400-500 pick regular. This mirrors how the * browser matches the studio SVG's `font-weight`, so e.g. weight 600 draws bold - * in BOTH the export raster and the SVG preview (#85). `@toony/render` applies - * the same threshold for its measurement weight. + * in BOTH the export raster and the SVG preview (#85). Single-sourced from + * `@toony/render`'s `matchFaceWeight` (#154) so the threshold cannot drift. */ export function cssFaceWeight(weight: number): FontFaceWeight { - return weight >= 600 ? 700 : 400; + return matchFaceWeight(weight); } /** diff --git a/packages/export/src/index.ts b/packages/export/src/index.ts index 8bd77e8..749c1ce 100644 --- a/packages/export/src/index.ts +++ b/packages/export/src/index.ts @@ -2,10 +2,17 @@ // exports built on the shared renderer, plus the export manifest schema. export { composeCut, composeTransitionBand } from "./compose.js"; +// Plain constants live in the Node-free `./defaults.js` (also a browser-safe +// subpath) so consumers keep importing them from the main entry (#154). export { - clampQuality, DEFAULT_JPEG_QUALITY, DEFAULT_WEBP_QUALITY, + PLATFORM_DEFAULT_WIDTH, + PLOTLINK_DEFAULT_WIDTH, + STITCHED_DEFAULT_WIDTH, +} from "./defaults.js"; +export { + clampQuality, encodeCanvas, encodeWebpToFit, type FitResult, diff --git a/packages/export/src/targets.ts b/packages/export/src/targets.ts index a269838..2354626 100644 --- a/packages/export/src/targets.ts +++ b/packages/export/src/targets.ts @@ -12,10 +12,11 @@ import { composeCut, composeTransitionBand } from "./compose.js"; import { DEFAULT_JPEG_QUALITY, DEFAULT_WEBP_QUALITY, - encodeCanvas, - encodeWebpToFit, - type RasterFormat, -} from "./encode.js"; + PLATFORM_DEFAULT_WIDTH, + PLOTLINK_DEFAULT_WIDTH, + STITCHED_DEFAULT_WIDTH, +} from "./defaults.js"; +import { encodeCanvas, encodeWebpToFit, type RasterFormat } from "./encode.js"; import { ExportError } from "./errors.js"; import { type ExportManifest, @@ -30,10 +31,6 @@ import { } from "./manifest.js"; import { buildPlotlinkMarkdown } from "./markdown.js"; -const PLATFORM_DEFAULT_WIDTH = 1200; -const STITCHED_DEFAULT_WIDTH = 1200; -const PLOTLINK_DEFAULT_WIDTH = 800; - export interface ExportOptions { /** Render width in px. Each target has a sensible default. */ width?: number; diff --git a/packages/lint/src/craft-lint.ts b/packages/lint/src/craft-lint.ts index 4534da7..3854f5e 100644 --- a/packages/lint/src/craft-lint.ts +++ b/packages/lint/src/craft-lint.ts @@ -11,6 +11,7 @@ import { type ShotType, } from "@toony/schema"; import { type Finding, finding } from "./findings.js"; +import { REFERENCE_RENDER } from "./reference.js"; /** > this many speech/thought bubbles in a cut → density warning. */ export const CRAFT_MAX_DIALOGUE_BUBBLES = 2; @@ -44,12 +45,9 @@ export const TRANSITION_MONOTONY_RUN_MAX = 4; */ export const TRANSITION_HEIGHT_TOLERANCE_PX = 12; -/** - * Reference render size for counting wrapped lines. Wrapping depends on the box - * (normalized geometry) + font, so a FIXED reference makes the line/char checks - * deterministic and image-independent (a portrait webtoon cut). - */ -const REFERENCE = { width: 1200, height: 1600 } as const; +// Reference render size for counting wrapped lines — a FIXED, image-independent +// canvas so the line/char checks are deterministic. Single-sourced (#154). +const REFERENCE = REFERENCE_RENDER; /** Kinds that represent someone speaking/thinking and should be attributable. */ const ATTRIBUTED_KINDS = new Set(["speech", "thought", "shout", "whisper"]); diff --git a/packages/lint/src/overflow-lint.ts b/packages/lint/src/overflow-lint.ts index 78f105e..2b9a242 100644 --- a/packages/lint/src/overflow-lint.ts +++ b/packages/lint/src/overflow-lint.ts @@ -11,6 +11,7 @@ import { layoutCut } from "@toony/render"; import type { EpisodeBundle } from "@toony/schema"; import { type Finding, finding } from "./findings.js"; import { readImageDimensions } from "./image/dimensions.js"; +import { REFERENCE_RENDER } from "./reference.js"; /** * Fallback cut render size used when a cut has no image, or its image header is @@ -18,7 +19,7 @@ import { readImageDimensions } from "./image/dimensions.js"; * is derived from the render height, so a stable fallback keeps the lint * deterministic. The default approximates a typical portrait webtoon cut. */ -export const DEFAULT_OVERFLOW_FALLBACK = { width: 1200, height: 1600 } as const; +export const DEFAULT_OVERFLOW_FALLBACK = REFERENCE_RENDER; export interface OverflowLintOptions { /** Render size assumed for cuts without a readable image. */ diff --git a/packages/lint/src/reference.ts b/packages/lint/src/reference.ts new file mode 100644 index 0000000..7e78283 --- /dev/null +++ b/packages/lint/src/reference.ts @@ -0,0 +1,7 @@ +// The deterministic reference render size for a portrait webtoon cut (#154). +// +// The craft lint (rhythm/density) lays bubbles out at a fixed, image-independent +// canvas so its findings are reproducible, and the overflow lint falls back to +// the same size for a cut with no readable image. Single-sourcing the value here +// keeps the two lints assuming the identical canvas — they cannot drift apart. +export const REFERENCE_RENDER = { width: 1200, height: 1600 } as const; diff --git a/packages/providers/src/comfyui.ts b/packages/providers/src/comfyui.ts index 829bfd5..ed6e40e 100644 --- a/packages/providers/src/comfyui.ts +++ b/packages/providers/src/comfyui.ts @@ -36,6 +36,11 @@ export interface ComfyUIClientDeps { now?: () => number; } +// Non-workflow FALLBACK latent dims used when a request omits width/height. These +// intentionally stay INDEPENDENT of the bundled default-txt2img.workflow.json's +// own EmptyLatentImage defaults (#154 item 4, dropped): the JSON is the graph's +// own defaults, this is the request fallback — coupling them would mean parsing a +// JSON asset for constants, not worth it. They may match today; that's fine. const DEFAULT_WIDTH = 832; const DEFAULT_HEIGHT = 1216; diff --git a/packages/render/src/index.ts b/packages/render/src/index.ts index 6fbf541..8d0f628 100644 --- a/packages/render/src/index.ts +++ b/packages/render/src/index.ts @@ -45,7 +45,7 @@ export type { BubbleTextOptions, MeasureWidth, } from "./text.js"; -export { defaultBubbleFontRange, layoutBubbleText, wrapText } from "./text.js"; +export { defaultBubbleFontRange, layoutBubbleText, matchFaceWeight, wrapText } from "./text.js"; export type { BandBackground, diff --git a/packages/render/src/layout.ts b/packages/render/src/layout.ts index ed3a6a1..4c28813 100644 --- a/packages/render/src/layout.ts +++ b/packages/render/src/layout.ts @@ -41,6 +41,7 @@ import { defaultBubbleFontRange, layoutBubbleText, type MeasureWidth, + matchFaceWeight, } from "./text.js"; /** A positioned line of wrapped body text, in the caller's pixel space. */ @@ -267,7 +268,7 @@ export function layoutBubble( // threshold is applied in @toony/export's canvas face selection so the raster // and the SVG land on the identical face (#85). Measurement uses it too so // wrap/auto-fit reflect the weight actually drawn. - const measureWeight: 400 | 700 = fontWeight >= 600 ? 700 : 400; + const measureWeight: 400 | 700 = matchFaceWeight(fontWeight); // Placement (#98): `in_panel` uses the whole cut canvas (back-compat); `gutter` // reserves an in-bounds strip of `GUTTER_BAND_FRAC` width on `placementSide`. diff --git a/packages/render/src/text.ts b/packages/render/src/text.ts index e562b4c..055f313 100644 --- a/packages/render/src/text.ts +++ b/packages/render/src/text.ts @@ -187,3 +187,15 @@ export function defaultBubbleFontRange(renderHeight: number): { maxFontSize: Math.max(1, renderHeight * 0.05), }; } + +/** + * Map a resolved body weight (400–700) to the face weight actually used, per CSS + * font-weight matching for the {400,700} set each family ships: 600–700 pick the + * bold (700) face, 400–500 pick regular. The SINGLE source both the render + * measurement weight and `@toony/export`'s canvas face selection (`cssFaceWeight`) + * use, so the SVG preview and the export raster always land on the identical face + * (#85/#154 — do not re-derive this threshold per consumer). + */ +export function matchFaceWeight(weight: number): 400 | 700 { + return weight >= 600 ? 700 : 400; +}