plotlink-ows 1.0.33 → 1.2.95

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/README.md +4 -0
  2. package/app/lib/active-wallet.ts +260 -0
  3. package/app/lib/agent-command.ts +85 -0
  4. package/app/lib/agent-readiness.ts +133 -0
  5. package/app/lib/apply-schema.ts +55 -0
  6. package/app/lib/bubble-text.ts +160 -0
  7. package/app/lib/cartoon-coach.ts +198 -0
  8. package/app/lib/cartoon-markdown.ts +83 -0
  9. package/app/lib/cartoon-prompt.ts +122 -0
  10. package/app/lib/cartoon-readiness.ts +813 -0
  11. package/app/lib/clean-image-sync.ts +245 -0
  12. package/app/lib/codex-images.ts +152 -0
  13. package/app/lib/cut-asset-diagnostics.ts +120 -0
  14. package/app/lib/cuts.ts +302 -0
  15. package/app/lib/fonts.ts +109 -0
  16. package/app/lib/generate-claude-md.ts +8 -1
  17. package/app/lib/generate-story-instructions.ts +731 -0
  18. package/app/lib/image-asset-validate.ts +123 -0
  19. package/app/lib/lettering-status.ts +133 -0
  20. package/app/lib/overlays.ts +637 -0
  21. package/app/lib/paths.ts +10 -0
  22. package/app/lib/public-title.ts +65 -0
  23. package/app/lib/publish.ts +16 -2
  24. package/app/lib/story-progress.ts +242 -0
  25. package/app/lib/terminal-protocol.ts +16 -0
  26. package/app/lib/terminal-redact.ts +50 -0
  27. package/app/prisma/schema.sql +25 -0
  28. package/app/routes/agent.ts +42 -0
  29. package/app/routes/codex-images.ts +67 -0
  30. package/app/routes/dashboard.ts +6 -4
  31. package/app/routes/publish.ts +259 -45
  32. package/app/routes/settings.ts +92 -37
  33. package/app/routes/stories.ts +961 -5
  34. package/app/routes/terminal.ts +383 -31
  35. package/app/routes/wallet.ts +58 -30
  36. package/app/server.ts +47 -12
  37. package/app/vite.config.ts +6 -0
  38. package/app/web/components/CartoonNextAction.tsx +145 -0
  39. package/app/web/components/CartoonPreview.tsx +267 -0
  40. package/app/web/components/CartoonPublishPage.tsx +407 -0
  41. package/app/web/components/CartoonPublishPreview.tsx +121 -0
  42. package/app/web/components/CartoonStepGuide.tsx +90 -0
  43. package/app/web/components/CartoonWorkflowNav.tsx +68 -0
  44. package/app/web/components/CodexImportPicker.tsx +230 -0
  45. package/app/web/components/CutListPanel.tsx +1337 -0
  46. package/app/web/components/Dashboard.tsx +15 -6
  47. package/app/web/components/EpisodesPage.tsx +80 -0
  48. package/app/web/components/FinishEpisodePanel.tsx +151 -0
  49. package/app/web/components/Layout.tsx +7 -4
  50. package/app/web/components/LetteringEditor.tsx +1182 -0
  51. package/app/web/components/PreviewPanel.tsx +952 -78
  52. package/app/web/components/Settings.tsx +63 -0
  53. package/app/web/components/StoriesPage.tsx +745 -33
  54. package/app/web/components/StoryBrowser.tsx +22 -14
  55. package/app/web/components/StoryInfoPage.tsx +266 -0
  56. package/app/web/components/StoryProgressPanel.tsx +446 -0
  57. package/app/web/components/TerminalPanel.tsx +233 -11
  58. package/app/web/components/WalletCard.tsx +110 -8
  59. package/app/web/components/WorkflowCoach.tsx +156 -0
  60. package/app/web/components/asset-image.tsx +114 -0
  61. package/app/web/components/asset-test-utils.ts +44 -0
  62. package/app/web/components/export-cut.ts +320 -0
  63. package/app/web/dist/assets/export-cut-che5mMWc.js +1 -0
  64. package/app/web/dist/assets/index-CcfChGEK.css +32 -0
  65. package/app/web/dist/assets/index-Dc2TQ3Ij.js +143 -0
  66. package/app/web/dist/index.html +2 -2
  67. package/app/web/lib/cartoon-publish-summary.ts +43 -0
  68. package/app/web/lib/codex-import.ts +94 -0
  69. package/app/web/lib/image-compress.ts +53 -0
  70. package/app/web/lib/import-image.ts +58 -0
  71. package/app/web/lib/publish-helpers.ts +385 -0
  72. package/app/web/lib/upload-retry.ts +130 -0
  73. package/app/web/lib/verify-public-title.ts +105 -0
  74. package/app/web/styles.css +9 -0
  75. package/bin/plotlink-ows.js +53 -16
  76. package/bin/startup-plan.cjs +58 -0
  77. package/lib/genres.ts +92 -0
  78. package/package.json +60 -20
  79. package/scripts/gen-schema-sql.mjs +49 -0
  80. package/scripts/package-hygiene.mjs +116 -0
  81. package/scripts/preflight.mjs +173 -0
  82. package/scripts/start-smoke.mjs +128 -0
  83. package/app/node_modules/.prisma/local-client/client.d.ts +0 -1
  84. package/app/node_modules/.prisma/local-client/client.js +0 -5
  85. package/app/node_modules/.prisma/local-client/default.d.ts +0 -1
  86. package/app/node_modules/.prisma/local-client/default.js +0 -5
  87. package/app/node_modules/.prisma/local-client/edge.d.ts +0 -1
  88. package/app/node_modules/.prisma/local-client/edge.js +0 -184
  89. package/app/node_modules/.prisma/local-client/index-browser.js +0 -173
  90. package/app/node_modules/.prisma/local-client/index.d.ts +0 -3304
  91. package/app/node_modules/.prisma/local-client/index.js +0 -207
  92. package/app/node_modules/.prisma/local-client/libquery_engine-darwin-arm64.dylib.node +0 -0
  93. package/app/node_modules/.prisma/local-client/package.json +0 -183
  94. package/app/node_modules/.prisma/local-client/query_engine_bg.js +0 -2
  95. package/app/node_modules/.prisma/local-client/query_engine_bg.wasm +0 -0
  96. package/app/node_modules/.prisma/local-client/runtime/edge-esm.js +0 -35
  97. package/app/node_modules/.prisma/local-client/runtime/edge.js +0 -35
  98. package/app/node_modules/.prisma/local-client/runtime/index-browser.d.ts +0 -370
  99. package/app/node_modules/.prisma/local-client/runtime/index-browser.js +0 -17
  100. package/app/node_modules/.prisma/local-client/runtime/library.d.ts +0 -3982
  101. package/app/node_modules/.prisma/local-client/runtime/library.js +0 -147
  102. package/app/node_modules/.prisma/local-client/runtime/react-native.js +0 -84
  103. package/app/node_modules/.prisma/local-client/runtime/wasm-compiler-edge.js +0 -85
  104. package/app/node_modules/.prisma/local-client/runtime/wasm-engine-edge.js +0 -38
  105. package/app/node_modules/.prisma/local-client/schema.prisma +0 -21
  106. package/app/node_modules/.prisma/local-client/wasm-edge-light-loader.mjs +0 -5
  107. package/app/node_modules/.prisma/local-client/wasm-worker-loader.mjs +0 -5
  108. package/app/node_modules/.prisma/local-client/wasm.d.ts +0 -1
  109. package/app/node_modules/.prisma/local-client/wasm.js +0 -191
  110. package/app/web/dist/assets/index-B-2Ft7Yv.css +0 -32
  111. package/app/web/dist/assets/index-DxATSk7X.js +0 -134
  112. package/packages/cli/node_modules/commander/LICENSE +0 -22
  113. package/packages/cli/node_modules/commander/Readme.md +0 -1149
  114. package/packages/cli/node_modules/commander/esm.mjs +0 -16
  115. package/packages/cli/node_modules/commander/index.js +0 -24
  116. package/packages/cli/node_modules/commander/lib/argument.js +0 -149
  117. package/packages/cli/node_modules/commander/lib/command.js +0 -2662
  118. package/packages/cli/node_modules/commander/lib/error.js +0 -39
  119. package/packages/cli/node_modules/commander/lib/help.js +0 -709
  120. package/packages/cli/node_modules/commander/lib/option.js +0 -367
  121. package/packages/cli/node_modules/commander/lib/suggestSimilar.js +0 -101
  122. package/packages/cli/node_modules/commander/package-support.json +0 -16
  123. package/packages/cli/node_modules/commander/package.json +0 -82
  124. package/packages/cli/node_modules/commander/typings/esm.d.mts +0 -3
  125. package/packages/cli/node_modules/commander/typings/index.d.ts +0 -1045
  126. package/packages/cli/node_modules/resolve-from/index.d.ts +0 -31
  127. package/packages/cli/node_modules/resolve-from/index.js +0 -47
  128. package/packages/cli/node_modules/resolve-from/license +0 -9
  129. package/packages/cli/node_modules/resolve-from/package.json +0 -36
  130. package/packages/cli/node_modules/resolve-from/readme.md +0 -72
  131. package/packages/cli/node_modules/tsup/LICENSE +0 -21
  132. package/packages/cli/node_modules/tsup/README.md +0 -75
  133. package/packages/cli/node_modules/tsup/assets/cjs_shims.js +0 -13
  134. package/packages/cli/node_modules/tsup/assets/esm_shims.js +0 -9
  135. package/packages/cli/node_modules/tsup/assets/package.json +0 -3
  136. package/packages/cli/node_modules/tsup/dist/chunk-DI5BO6XE.js +0 -153
  137. package/packages/cli/node_modules/tsup/dist/chunk-JZ25TPTY.js +0 -42
  138. package/packages/cli/node_modules/tsup/dist/chunk-PEEXUWMS.js +0 -6
  139. package/packages/cli/node_modules/tsup/dist/chunk-TWFEYLU4.js +0 -352
  140. package/packages/cli/node_modules/tsup/dist/chunk-VGC3FXLU.js +0 -203
  141. package/packages/cli/node_modules/tsup/dist/cli-default.js +0 -12
  142. package/packages/cli/node_modules/tsup/dist/cli-main.js +0 -8
  143. package/packages/cli/node_modules/tsup/dist/cli-node.js +0 -14
  144. package/packages/cli/node_modules/tsup/dist/index.d.ts +0 -511
  145. package/packages/cli/node_modules/tsup/dist/index.js +0 -1711
  146. package/packages/cli/node_modules/tsup/dist/rollup.js +0 -6949
  147. package/packages/cli/node_modules/tsup/package.json +0 -99
  148. package/packages/cli/node_modules/tsup/schema.json +0 -362
  149. package/public/screenshot-1.png +0 -0
  150. package/public/screenshot-2.png +0 -0
  151. package/public/screenshot-3.png +0 -0
  152. package/scripts/e2e-verify.ts +0 -1100
@@ -0,0 +1,1182 @@
1
+ import { useState, useRef, useEffect, useCallback, useMemo } from "react";
2
+ import {
3
+ getDefaultFont,
4
+ getDisplayFont,
5
+ getFontCdnUrl,
6
+ getFontFamily,
7
+ type FontEntry,
8
+ } from "@app-lib/fonts";
9
+ import {
10
+ speechTailPoints,
11
+ balloonPathD,
12
+ normalizeOverlays,
13
+ detectOverlappingOverlays,
14
+ isOverlayOutOfBounds,
15
+ createOverlay,
16
+ comfortableOverlaySize,
17
+ bubbleLayoutOptionsForOverlay,
18
+ balloonRadiusForOverlay,
19
+ type Overlay,
20
+ type OverlayType,
21
+ } from "@app-lib/overlays";
22
+ import { layoutBubbleText } from "@app-lib/bubble-text";
23
+ import { cutLetteringChecklist, cutScriptLines, isExportStale, overlaysSignature, type ScriptLine } from "@app-lib/lettering-status";
24
+ import { textPanelDimensions } from "@app-lib/cuts";
25
+ import { buildLetteringPrompt } from "@app-lib/cartoon-prompt";
26
+ import type { Cut as LibCut } from "@app-lib/cuts";
27
+ import { useAuthedAsset } from "./asset-image";
28
+
29
+ function toPixel(norm: number, size: number): number {
30
+ return norm * size;
31
+ }
32
+
33
+ function toNorm(pixel: number, size: number): number {
34
+ if (size === 0) return 0;
35
+ return pixel / size;
36
+ }
37
+
38
+ function loadFont(font: FontEntry) {
39
+ const id = `gfont-${font.googleFontsId}`;
40
+ if (document.getElementById(id)) return;
41
+ const link = document.createElement("link");
42
+ link.id = id;
43
+ link.rel = "stylesheet";
44
+ link.href = getFontCdnUrl(font);
45
+ document.head.appendChild(link);
46
+ }
47
+
48
+ interface Cut {
49
+ id: number;
50
+ cleanImagePath: string | null;
51
+ overlays: Overlay[];
52
+ narration?: string;
53
+ sfx?: string;
54
+ dialogue?: { speaker: string; text: string }[];
55
+ // Export/upload status (#336) — used by the per-cut lettering checklist so the
56
+ // writer can see how far the cut has progressed without leaving the editor.
57
+ finalImagePath?: string | null;
58
+ exportedAt?: string | null;
59
+ uploadedUrl?: string | null;
60
+ uploadedCid?: string | null;
61
+ // Text/interstitial panel (#350/#351): no clean image — the editor uses a
62
+ // styled background canvas and exports it as the final image.
63
+ kind?: "image" | "text";
64
+ background?: string;
65
+ aspectRatio?: string;
66
+ }
67
+
68
+ interface LetteringEditorProps {
69
+ storyName: string;
70
+ cut: Cut;
71
+ plotFile: string;
72
+ onSave: (overlays: Overlay[]) => void | Promise<void>;
73
+ onClose: () => void;
74
+ onExported?: () => void;
75
+ language?: string;
76
+ authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
77
+ /** Focused-editor header label supplied by the review board (#488). */
78
+ targetLabel?: string;
79
+ /** When true, the Save button returns to the review board after persisting. */
80
+ returnOnSave?: boolean;
81
+ }
82
+
83
+ const TYPE_LABEL: Record<OverlayType, string> = {
84
+ speech: "Speech",
85
+ narration: "Narration",
86
+ sfx: "SFX",
87
+ };
88
+
89
+ const TYPE_BORDER: Record<OverlayType, string> = {
90
+ speech: "border-foreground/40",
91
+ narration: "border-muted/40",
92
+ sfx: "border-accent/40",
93
+ };
94
+
95
+ // Short human label for a bubble in the overlap warning (#318): its speaker or
96
+ // a trimmed text snippet, falling back to the type name for empty bubbles.
97
+ function overlapLabel(o: Overlay): string {
98
+ const snippet = (o.speaker || o.text || "").trim().replace(/\s+/g, " ");
99
+ if (snippet) return `“${snippet.length > 18 ? `${snippet.slice(0, 18)}…` : snippet}”`;
100
+ return TYPE_LABEL[o.type];
101
+ }
102
+
103
+ const MIN_SIZE = 0.05;
104
+ const TAIL_PRESETS = [
105
+ { key: "down", label: "Down", anchor: { x: 0.5, y: 1.2 } },
106
+ { key: "up", label: "Up", anchor: { x: 0.5, y: -0.2 } },
107
+ { key: "left", label: "Left", anchor: { x: -0.2, y: 0.5 } },
108
+ { key: "right", label: "Right", anchor: { x: 1.2, y: 0.5 } },
109
+ ] as const;
110
+
111
+ function clamp(v: number, min: number, max: number): number {
112
+ return Math.min(max, Math.max(min, v));
113
+ }
114
+
115
+ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onExported, language = "English", authFetch, targetLabel, returnOnSave = false }: LetteringEditorProps) {
116
+ const bodyFont = getDefaultFont(language);
117
+ const displayFont = getDisplayFont();
118
+ const bodyFontFamily = getFontFamily(bodyFont);
119
+ const displayFontFamily = getFontFamily(displayFont);
120
+
121
+ useEffect(() => {
122
+ loadFont(bodyFont);
123
+ loadFont(displayFont);
124
+ }, [bodyFont, displayFont]);
125
+
126
+ // Wait for the same fonts export waits on, then allow the exact preview layout
127
+ // to compute/recompute with the loaded font metrics (#310, re1).
128
+ useEffect(() => {
129
+ let cancelled = false;
130
+ setFontsReady(false);
131
+ (async () => {
132
+ try {
133
+ const { ensureFontsReady } = await import("./export-cut");
134
+ await ensureFontsReady([bodyFont.family, displayFont.family]);
135
+ } catch { /* best effort — still render the preview */ }
136
+ if (!cancelled) setFontsReady(true);
137
+ })();
138
+ return () => { cancelled = true; };
139
+ }, [bodyFont.family, displayFont.family]);
140
+
141
+ // Clean image lives behind requireAuth, so a raw <img src> would 401. Load it
142
+ // via authFetch into a blob object URL and reuse that same URL for export.
143
+ const cleanAsset = useAuthedAsset(storyName, cut.cleanImagePath, authFetch);
144
+ // Repair agent-authored overlays (e.g. semantic `position` strings with no
145
+ // numeric geometry) on load so the bubbles actually render and export — and
146
+ // surface a note when some could not be auto-placed (#309).
147
+ const overlayNormalization = useMemo(() => normalizeOverlays(cut.overlays), [cut.overlays]);
148
+ const invalidOverlayCount = overlayNormalization.invalid.length;
149
+ // Overlays that could not be placed (no geometry, no recognizable position)
150
+ // are NOT exported. Exporting silently would produce a final missing that
151
+ // bubble/text, so block export until the writer explicitly discards them (#309).
152
+ const [acknowledgedInvalid, setAcknowledgedInvalid] = useState(false);
153
+ const autoPlacedOverlays =
154
+ invalidOverlayCount === 0 && overlayNormalization.changed && overlayNormalization.overlays.length > 0;
155
+ const [overlays, setOverlays] = useState<Overlay[]>(() => overlayNormalization.overlays as Overlay[]);
156
+ // Signature of the overlays that match the current export/upload (#336, re1).
157
+ // Captured (already normalized like the live `overlays`) when the cut opens so
158
+ // a load-time normalization isn't mistaken for a user edit, and advanced to the
159
+ // live overlays after a successful re-export so the stale flag clears without
160
+ // closing the editor. As state, so updating it recomputes staleExport.
161
+ const [exportBaselineSig, setExportBaselineSig] = useState(() =>
162
+ overlaysSignature(overlayNormalization.overlays as Overlay[]),
163
+ );
164
+ // Offscreen canvas to measure text exactly like the export canvas, so the
165
+ // preview wraps/sizes bubble text identically to the final image (#310).
166
+ const measureCanvasRef = useRef<HTMLCanvasElement | null>(null);
167
+ const measureWidth = useCallback((fontFamily: string) => (text: string, fontSize: number, fontWeight: 400 | 700 = 400): number => {
168
+ if (!measureCanvasRef.current && typeof document !== "undefined") {
169
+ measureCanvasRef.current = document.createElement("canvas");
170
+ }
171
+ const mctx = measureCanvasRef.current?.getContext("2d");
172
+ if (!mctx) return text.length * fontSize * 0.5; // jsdom fallback
173
+ mctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
174
+ return mctx.measureText(text).width;
175
+ }, []);
176
+ // Gate the exact (canvas-measured) preview layout on the SAME font-readiness
177
+ // signal export uses (ensureFontsReady), so the preview does not freeze line
178
+ // breaks computed from fallback-font metrics that would diverge from the
179
+ // exported image (#310, re1). Recomputes once the web fonts are loaded.
180
+ const [fontsReady, setFontsReady] = useState(false);
181
+ const [selectedId, setSelectedId] = useState<string | null>(null);
182
+ const [confirmDelete, setConfirmDelete] = useState(false);
183
+ const [exporting, setExporting] = useState(false);
184
+ const [exportError, setExportError] = useState<string | null>(null);
185
+ const [saveError, setSaveError] = useState<string | null>(null);
186
+ const [aiCopied, setAiCopied] = useState(false);
187
+ const [imageBounds, setImageBounds] = useState({ x: 0, y: 0, width: 0, height: 0 });
188
+ const containerRef = useRef<HTMLDivElement>(null);
189
+ const imgRef = useRef<HTMLImageElement>(null);
190
+ const dragRef = useRef<{ id: string; mode: "move" | "resize"; startX: number; startY: number; origX: number; origY: number; origW: number; origH: number } | null>(null);
191
+
192
+ const updateImageBounds = useCallback(() => {
193
+ const container = containerRef.current;
194
+ if (!container) return;
195
+ const cw = container.clientWidth;
196
+ const ch = container.clientHeight;
197
+ let iw: number;
198
+ let ih: number;
199
+ if (cut.kind === "text") {
200
+ // A text panel has no image — size the editor canvas from the SAME aspect
201
+ // ratio the export uses, so lettering and the exported final agree (#351).
202
+ const dims = textPanelDimensions(cut.aspectRatio) ?? { width: 800, height: 600 };
203
+ iw = dims.width;
204
+ ih = dims.height;
205
+ } else {
206
+ const img = imgRef.current;
207
+ if (!img || !img.naturalWidth) return;
208
+ iw = img.naturalWidth;
209
+ ih = img.naturalHeight;
210
+ }
211
+ if (!cw || !ch) return;
212
+ const scale = Math.min(cw / iw, ch / ih);
213
+ const rw = iw * scale;
214
+ const rh = ih * scale;
215
+ setImageBounds({ x: (cw - rw) / 2, y: (ch - rh) / 2, width: rw, height: rh });
216
+ }, [cut.kind, cut.aspectRatio]);
217
+
218
+ useEffect(() => {
219
+ const el = containerRef.current;
220
+ if (!el) return;
221
+ const observer = new ResizeObserver(() => updateImageBounds());
222
+ observer.observe(el);
223
+ return () => observer.disconnect();
224
+ }, [updateImageBounds]);
225
+
226
+ // Size an overlay so ordinary narration/dialogue lines don't overflow the box
227
+ // the instant they're added (#452). With the loaded fonts + image metrics it
228
+ // grows the height (at a comfortable on-image width) until the text fits at the
229
+ // default font; without measurement it falls back to a generous default that
230
+ // fits ordinary lines, instead of the tiny create-default. The writer can still
231
+ // resize freely afterward, and the overflow warning stays useful if they shrink it.
232
+ const fittedSize = useCallback((o: Overlay): { width: number; height: number } => {
233
+ const comfortable = comfortableOverlaySize(o.type, o.x, o.y);
234
+ const width = comfortable.width;
235
+ const maxH = Math.max(0.08, 1 - o.y);
236
+ if (!o.text || !fontsReady || imageBounds.width <= 0) {
237
+ return comfortable;
238
+ }
239
+ const fontFamily = o.type === "sfx" ? displayFontFamily : bodyFontFamily;
240
+ const wPx = toPixel(width, imageBounds.width);
241
+ let height = o.type === "sfx" ? 0.08 : 0.12;
242
+ for (let i = 0; i < 24; i++) {
243
+ const h = Math.min(height, maxH);
244
+ const hPx = toPixel(h, imageBounds.height);
245
+ const layout = layoutBubbleText(
246
+ measureWidth(fontFamily), o.text, wPx, hPx,
247
+ bubbleLayoutOptionsForOverlay({ ...o, width, height: h }, imageBounds.height || 300, wPx, hPx),
248
+ );
249
+ if (!layout.overflow || h >= maxH) return { width, height: h };
250
+ height += 0.03;
251
+ }
252
+ return { width, height: Math.min(height, maxH) };
253
+ }, [fontsReady, imageBounds, measureWidth, bodyFontFamily, displayFontFamily]);
254
+
255
+ const addOverlay = useCallback((type: OverlayType) => {
256
+ const o = createOverlay(type, 0.1 + Math.random() * 0.3, 0.1 + Math.random() * 0.3);
257
+ const sized: Overlay = { ...o, ...fittedSize(o) };
258
+ setOverlays((prev) => [...prev, sized]);
259
+ setSelectedId(sized.id);
260
+ }, [fittedSize]);
261
+
262
+ // Insert a line from the cut's cuts.json script (#336) as a prefilled overlay,
263
+ // so the writer never has to hand-copy dialogue/narration/SFX out of the JSON.
264
+ const addScriptLine = useCallback((line: ScriptLine) => {
265
+ const o = createOverlay(line.type, 0.1 + Math.random() * 0.3, 0.1 + Math.random() * 0.3);
266
+ const filled: Overlay = {
267
+ ...o,
268
+ text: line.text,
269
+ ...(line.type === "speech" && line.speaker ? { speaker: line.speaker } : {}),
270
+ };
271
+ const sized: Overlay = { ...filled, ...fittedSize(filled) };
272
+ setOverlays((prev) => [...prev, sized]);
273
+ setSelectedId(sized.id);
274
+ }, [fittedSize]);
275
+
276
+ const updateOverlay = useCallback((id: string, changes: Partial<Overlay>) => {
277
+ setOverlays((prev) => prev.map((o) => o.id === id ? { ...o, ...changes } : o));
278
+ }, []);
279
+
280
+ const enableManualTypography = useCallback((overlay: Overlay) => {
281
+ const renderHeight = imageBounds.height || 300;
282
+ const width = imageBounds.width > 0 ? toPixel(overlay.width, imageBounds.width) : 200;
283
+ const height = imageBounds.height > 0 ? toPixel(overlay.height, imageBounds.height) : 100;
284
+ const fontFamily = overlay.type === "sfx" ? displayFontFamily : bodyFontFamily;
285
+ const autoLayout = layoutBubbleText(
286
+ measureWidth(fontFamily),
287
+ overlay.text,
288
+ width,
289
+ height,
290
+ bubbleLayoutOptionsForOverlay({ ...overlay, textStyle: undefined }, renderHeight, width, height),
291
+ );
292
+ updateOverlay(overlay.id, {
293
+ textStyle: {
294
+ mode: "manual",
295
+ fontScale: autoLayout.fontSize / Math.max(1, renderHeight),
296
+ fontWeight: overlay.textStyle?.fontWeight ?? 400,
297
+ lineHeightFactor: autoLayout.fontSize > 0 ? autoLayout.lineHeight / autoLayout.fontSize : 1.2,
298
+ speakerScale: autoLayout.fontSize > 0 && autoLayout.speakerFontSize > 0 ? autoLayout.speakerFontSize / autoLayout.fontSize : 0.8,
299
+ },
300
+ });
301
+ }, [imageBounds, displayFontFamily, bodyFontFamily, measureWidth, updateOverlay]);
302
+
303
+ const deleteOverlay = useCallback((id: string) => {
304
+ setOverlays((prev) => prev.filter((o) => o.id !== id));
305
+ setSelectedId(null);
306
+ setConfirmDelete(false);
307
+ }, []);
308
+
309
+ const handleBackgroundClick = useCallback(() => {
310
+ setSelectedId(null);
311
+ setConfirmDelete(false);
312
+ }, []);
313
+
314
+ const handleOverlayClick = useCallback((e: React.MouseEvent, id: string) => {
315
+ e.stopPropagation();
316
+ setSelectedId(id);
317
+ setConfirmDelete(false);
318
+ }, []);
319
+
320
+ const handleMouseDown = useCallback((e: React.MouseEvent, id: string, mode: "move" | "resize") => {
321
+ e.stopPropagation();
322
+ e.preventDefault();
323
+ const overlay = overlays.find((o) => o.id === id);
324
+ if (!overlay) return;
325
+ setSelectedId(id);
326
+ dragRef.current = {
327
+ id,
328
+ mode,
329
+ startX: e.clientX,
330
+ startY: e.clientY,
331
+ origX: overlay.x,
332
+ origY: overlay.y,
333
+ origW: overlay.width,
334
+ origH: overlay.height,
335
+ };
336
+ }, [overlays]);
337
+
338
+ useEffect(() => {
339
+ const onMouseMove = (e: MouseEvent) => {
340
+ const drag = dragRef.current;
341
+ if (!drag || imageBounds.width === 0) return;
342
+ const dx = toNorm(e.clientX - drag.startX, imageBounds.width);
343
+ const dy = toNorm(e.clientY - drag.startY, imageBounds.height);
344
+
345
+ if (drag.mode === "move") {
346
+ const newX = clamp(drag.origX + dx, 0, 1 - drag.origW);
347
+ const newY = clamp(drag.origY + dy, 0, 1 - drag.origH);
348
+ updateOverlay(drag.id, { x: newX, y: newY });
349
+ } else {
350
+ const newW = clamp(drag.origW + dx, MIN_SIZE, 1 - drag.origX);
351
+ const newH = clamp(drag.origH + dy, MIN_SIZE, 1 - drag.origY);
352
+ updateOverlay(drag.id, { width: newW, height: newH });
353
+ }
354
+ };
355
+
356
+ const onMouseUp = () => { dragRef.current = null; };
357
+
358
+ window.addEventListener("mousemove", onMouseMove);
359
+ window.addEventListener("mouseup", onMouseUp);
360
+ return () => {
361
+ window.removeEventListener("mousemove", onMouseMove);
362
+ window.removeEventListener("mouseup", onMouseUp);
363
+ };
364
+ }, [imageBounds, updateOverlay]);
365
+
366
+ const handleSave = useCallback(async () => {
367
+ setSaveError(null);
368
+ try {
369
+ await onSave(overlays);
370
+ if (returnOnSave) onClose();
371
+ } catch (err) {
372
+ setSaveError(err instanceof Error ? err.message : "Failed to save overlays");
373
+ }
374
+ }, [overlays, onSave, onClose, returnOnSave]);
375
+
376
+ const copyAiDraftPrompt = useCallback(() => {
377
+ navigator.clipboard?.writeText(buildLetteringPrompt(cut as unknown as LibCut, plotFile));
378
+ setAiCopied(true);
379
+ setTimeout(() => setAiCopied(false), 2000);
380
+ }, [cut, plotFile]);
381
+
382
+ const handleExport = useCallback(async () => {
383
+ // Block export when the cut plan contained overlays that could not be placed
384
+ // (no numeric geometry, no recognizable position). Dropping them silently
385
+ // would export an image missing the intended bubble/text (#309, re1).
386
+ // Require an explicit discard first — do not save or export the reduced set.
387
+ if (invalidOverlayCount > 0 && !acknowledgedInvalid) {
388
+ const c = invalidOverlayCount;
389
+ setExportError(
390
+ `${c} overlay${c === 1 ? "" : "s"} from the cut plan ${c === 1 ? "has" : "have"} no usable position and cannot be exported — re-place ${c === 1 ? "it" : "them"} or discard ${c === 1 ? "it" : "them"} first.`,
391
+ );
392
+ return;
393
+ }
394
+ setExporting(true);
395
+ setExportError(null);
396
+ try {
397
+ await onSave(overlays);
398
+
399
+ const { exportCut, ensureFontsReady } = await import("./export-cut");
400
+
401
+ const usesSfx = overlays.some((o) => o.type === "sfx");
402
+ const fontsToCheck = [bodyFont.family, ...(usesSfx ? [displayFont.family] : [])];
403
+ const { ready, missing } = await ensureFontsReady(fontsToCheck);
404
+ if (!ready) {
405
+ setExportError(`Fonts not loaded: ${missing.join(", ")}. Check your connection and retry.`);
406
+ setExporting(false);
407
+ return;
408
+ }
409
+
410
+ // Export the actual loaded clean image, never a blank canvas standing in
411
+ // for an image that simply failed to load.
412
+ if (cut.cleanImagePath && !cleanAsset.url) {
413
+ setExportError(
414
+ cleanAsset.error
415
+ ? "Clean image failed to load — cannot export. Retry once it renders."
416
+ : "Clean image still loading — wait for it to render, then export.",
417
+ );
418
+ setExporting(false);
419
+ return;
420
+ }
421
+ const imgUrl = cleanAsset.url;
422
+ const blob = await exportCut(
423
+ imgUrl,
424
+ overlays,
425
+ bodyFontFamily,
426
+ displayFontFamily,
427
+ { narration: cut.narration, dialogue: cut.dialogue },
428
+ // Text panels have no clean image — render the final on a styled
429
+ // background canvas sized by the panel's aspect ratio (#351).
430
+ cut.kind === "text" ? { background: cut.background, aspectRatio: cut.aspectRatio } : undefined,
431
+ );
432
+
433
+ const fd = new FormData();
434
+ const ext = blob.type === "image/webp" ? "webp" : "jpg";
435
+ fd.append("file", blob, `cut-${cut.id}.${ext}`);
436
+
437
+ const res = await authFetch(
438
+ `/api/stories/${storyName}/cuts/${plotFile}/export-final/${cut.id}`,
439
+ { method: "POST", body: fd },
440
+ );
441
+ if (!res.ok) {
442
+ const data = await res.json();
443
+ setExportError(data.error || "Export failed");
444
+ } else {
445
+ // The just-exported overlays are now the export baseline, so the stale
446
+ // warning/checklist clear immediately without closing the editor (re1).
447
+ setExportBaselineSig(overlaysSignature(overlays));
448
+ onExported?.();
449
+ }
450
+ } catch (err) {
451
+ setExportError(err instanceof Error ? err.message : "Export failed");
452
+ } finally {
453
+ setExporting(false);
454
+ }
455
+ }, [cut, cleanAsset, overlays, storyName, plotFile, bodyFont, displayFont, bodyFontFamily, displayFontFamily, authFetch, onSave, onExported, invalidOverlayCount, acknowledgedInvalid]);
456
+
457
+ const selectedOverlay = overlays.find((o) => o.id === selectedId);
458
+
459
+ // Flag bubbles whose filled bodies overlap enough to hide each other's text so
460
+ // the writer gets a readability warning before export/publish (#318). Computed
461
+ // from the live overlay positions, so it clears as soon as bubbles are moved
462
+ // apart. Non-blocking: overlap can be intentional, so it never blocks export.
463
+ const overlapPairs = useMemo(() => detectOverlappingOverlays(overlays), [overlays]);
464
+
465
+ // Re-baseline when a different cut opens without a remount (rare — the parent
466
+ // normally unmounts the editor between cuts).
467
+ const baselineCutIdRef = useRef(cut.id);
468
+ useEffect(() => {
469
+ if (baselineCutIdRef.current !== cut.id) {
470
+ baselineCutIdRef.current = cut.id;
471
+ setExportBaselineSig(overlaysSignature(overlayNormalization.overlays as Overlay[]));
472
+ }
473
+ }, [cut.id, overlayNormalization.overlays]);
474
+
475
+ // The recorded export/upload is stale once the writer edits bubbles since it
476
+ // was produced — the final image/uploaded URL no longer match the screen, so
477
+ // export & upload must be redone before they count again (#336, re1).
478
+ const staleExport = isExportStale({
479
+ exported: !!cut.finalImagePath || !!cut.exportedAt,
480
+ uploaded: !!cut.uploadedUrl || !!cut.uploadedCid,
481
+ baselineSig: exportBaselineSig,
482
+ current: overlays,
483
+ });
484
+
485
+ // Per-cut lettering checklist + insertable script lines (#336). The checklist
486
+ // shows progress (clean image → script text → bubbles placed → exported →
487
+ // uploaded) right in the editor; the script lines power one-click insertion.
488
+ // A stale export marks the exported/uploaded steps incomplete until re-export.
489
+ const checklist = useMemo(
490
+ () => cutLetteringChecklist({ ...cut, overlays }, { staleExport }),
491
+ [cut, overlays, staleExport],
492
+ );
493
+ const scriptLines = useMemo(() => cutScriptLines(cut), [cut]);
494
+
495
+ // Likely export problems per overlay (#336): the body rect clipped by the
496
+ // image bounds, or text that overflows even at the smallest font. Out-of-bounds
497
+ // is pure geometry; overflow needs the loaded-font metrics, so it only computes
498
+ // once fonts are ready (same gate as the exact preview layout).
499
+ const overlayWarnings = useMemo(() => {
500
+ const out: Record<string, { outOfBounds: boolean; overflow: boolean }> = {};
501
+ for (const o of overlays) {
502
+ const outOfBounds = isOverlayOutOfBounds(o);
503
+ let overflow = false;
504
+ if (fontsReady && imageBounds.width > 0 && o.text) {
505
+ const fontFamily = o.type === "sfx" ? displayFontFamily : bodyFontFamily;
506
+ const w = toPixel(o.width, imageBounds.width);
507
+ const h = toPixel(o.height, imageBounds.height);
508
+ const layout = layoutBubbleText(
509
+ measureWidth(fontFamily),
510
+ o.text,
511
+ w,
512
+ h,
513
+ bubbleLayoutOptionsForOverlay(o, imageBounds.height || 300, w, h),
514
+ );
515
+ overflow = layout.overflow;
516
+ }
517
+ if (outOfBounds || overflow) out[o.id] = { outOfBounds, overflow };
518
+ }
519
+ return out;
520
+ }, [overlays, fontsReady, imageBounds, measureWidth, bodyFontFamily, displayFontFamily]);
521
+ const warningCount = Object.keys(overlayWarnings).length;
522
+
523
+ const isTextPanel = cut.kind === "text";
524
+ const isNarrationCut = !cut.cleanImagePath;
525
+
526
+ // A text/interstitial panel (#351) is editable on a styled background canvas
527
+ // even when empty, so it skips the "no clean image" guard that applies to a
528
+ // would-be image cut with nothing placed yet.
529
+ if (!isTextPanel && isNarrationCut && overlays.length === 0 && !cut.narration && !cut.dialogue?.length) {
530
+ return (
531
+ <div className="h-full flex items-center justify-center text-sm text-muted">
532
+ No clean image — upload one first, or add overlays for a narration cut.
533
+ </div>
534
+ );
535
+ }
536
+
537
+ return (
538
+ <div className="h-full flex flex-col" data-testid="focused-lettering-editor">
539
+ {/* Toolbar */}
540
+ <div className="px-4 py-3 border-b border-border bg-surface/40 flex items-center justify-between gap-3">
541
+ <div className="min-w-0">
542
+ <div className="flex items-center gap-2">
543
+ <span className="text-[10px] font-bold uppercase tracking-[0.16em] text-accent">Focused lettering editor</span>
544
+ <span className="text-xs font-mono text-muted">{targetLabel ?? `Cut #${cut.id}`}</span>
545
+ </div>
546
+ <p className="mt-0.5 text-[11px] text-muted">
547
+ Place bubbles, captions, SFX, or between-scene card text, then save back to the full cut review.
548
+ </p>
549
+ <span className="text-[10px] text-muted" data-testid="overlay-count">{overlays.length} overlays</span>
550
+ </div>
551
+ <div className="flex items-center gap-2 flex-wrap justify-end">
552
+ <div className="flex items-center gap-1 ml-2">
553
+ <button onClick={() => addOverlay("speech")} className="px-2 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5" data-testid="add-speech">Speech</button>
554
+ <button onClick={() => addOverlay("narration")} className="px-2 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5" data-testid="add-narration">Narration</button>
555
+ <button onClick={() => addOverlay("sfx")} className="px-2 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5" data-testid="add-sfx">SFX</button>
556
+ </div>
557
+ {exportError && <span className="text-[10px] text-error">{exportError}</span>}
558
+ {saveError && <span className="text-[10px] text-error">{saveError}</span>}
559
+ <button onClick={handleExport} disabled={exporting} className="px-3 py-1 text-xs border border-accent text-accent rounded hover:bg-accent/5 disabled:opacity-50" data-testid="export-btn">
560
+ {exporting ? "Exporting..." : "Export"}
561
+ </button>
562
+ <button onClick={() => { void handleSave(); }} className="px-3 py-1 text-xs bg-accent text-white rounded hover:bg-accent-dim" data-testid="save-lettering-btn">Save</button>
563
+ <button onClick={onClose} className="px-3 py-1 text-xs text-muted hover:text-foreground border border-border rounded" data-testid="cancel-lettering-btn">Cancel</button>
564
+ </div>
565
+ </div>
566
+
567
+ {invalidOverlayCount > 0 && !acknowledgedInvalid ? (
568
+ <div className="px-3 py-1 border-b border-border bg-error/10 text-[10px] text-error flex items-center gap-2 flex-wrap" data-testid="overlay-repair-note">
569
+ <span>
570
+ {invalidOverlayCount} overlay{invalidOverlayCount === 1 ? "" : "s"} from the cut plan {invalidOverlayCount === 1 ? "has" : "have"} no usable position and cannot be exported. Re-place {invalidOverlayCount === 1 ? "it" : "them"}, or
571
+ </span>
572
+ <button
573
+ onClick={() => setAcknowledgedInvalid(true)}
574
+ data-testid="discard-invalid-overlays"
575
+ className="px-1.5 py-0.5 border border-error/40 rounded hover:bg-error/10"
576
+ >
577
+ discard {invalidOverlayCount} unplaceable overlay{invalidOverlayCount === 1 ? "" : "s"}
578
+ </button>
579
+ </div>
580
+ ) : invalidOverlayCount > 0 ? (
581
+ <div className="px-3 py-1 border-b border-border bg-amber-500/10 text-[10px] text-amber-700" data-testid="overlay-repair-note">
582
+ Discarded {invalidOverlayCount} unplaceable overlay{invalidOverlayCount === 1 ? "" : "s"} — the export will not include {invalidOverlayCount === 1 ? "it" : "them"}.
583
+ </div>
584
+ ) : autoPlacedOverlays ? (
585
+ <div className="px-3 py-1 border-b border-border bg-amber-500/10 text-[10px] text-amber-700" data-testid="overlay-repair-note">
586
+ Auto-placed overlays from the cut plan — review their positions before exporting.
587
+ </div>
588
+ ) : null}
589
+
590
+ {overlapPairs.length > 0 && (
591
+ <div
592
+ className="px-3 py-1 border-b border-border bg-amber-500/10 text-[10px] text-amber-700"
593
+ data-testid="overlay-overlap-warning"
594
+ >
595
+ Cut #{cut.id}: {overlapPairs.length} bubble {overlapPairs.length === 1 ? "pair overlaps" : "pairs overlap"} and may be hard to read —{" "}
596
+ {overlapPairs
597
+ .map((p) => `#${p.indexA + 1} ${overlapLabel(overlays[p.indexA])} ↔ #${p.indexB + 1} ${overlapLabel(overlays[p.indexB])}`)
598
+ .join("; ")}
599
+ . Move them apart, or export as-is if the overlap is intended.
600
+ </div>
601
+ )}
602
+
603
+ {/* Per-cut lettering checklist (#336): shows how far this cut has come so
604
+ the writer can finish it from the editor without inspecting cuts.json. */}
605
+ <div
606
+ className="px-3 py-1 border-b border-border flex items-center gap-3 flex-wrap text-[10px] text-muted"
607
+ data-testid="lettering-checklist"
608
+ >
609
+ {([
610
+ ["clean-image", "Clean image", checklist.hasCleanImage],
611
+ ["script-text", "Script text", checklist.hasScriptText],
612
+ ["bubbles", `Bubbles placed${checklist.bubblesPlaced ? ` (${checklist.bubblesPlaced})` : ""}`, checklist.bubblesPlaced > 0],
613
+ ["exported", "Final exported", checklist.exported],
614
+ ["uploaded", "Uploaded", checklist.uploaded],
615
+ ] as [string, string, boolean][]).map(([key, label, done]) => (
616
+ <span
617
+ key={key}
618
+ data-testid={`lettering-check-${key}`}
619
+ data-done={done ? "true" : "false"}
620
+ className={`flex items-center gap-1 ${done ? "text-green-700" : "text-muted/70"}`}
621
+ >
622
+ <span aria-hidden>{done ? "✓" : "○"}</span>
623
+ {label}
624
+ </span>
625
+ ))}
626
+ </div>
627
+
628
+ {/* Stale-export warning (#336, re1): bubbles changed since the recorded
629
+ export/upload, so the final image/uploaded URL are out of date. The
630
+ checklist already marks export/upload incomplete; this says why. */}
631
+ {staleExport && (
632
+ <div
633
+ className="px-3 py-1 border-b border-border bg-amber-500/10 text-[10px] text-amber-700"
634
+ data-testid="lettering-stale-export-warning"
635
+ >
636
+ Bubbles changed since the last export — re-export this cut and upload the new final image before publishing.
637
+ </div>
638
+ )}
639
+
640
+ {/* Likely export problems (#336): clipped/out-of-bounds bubbles or text that
641
+ overflows even at the smallest font. Non-blocking guidance. */}
642
+ {warningCount > 0 && (
643
+ <div
644
+ className="px-3 py-1 border-b border-border bg-amber-500/10 text-[10px] text-amber-700"
645
+ data-testid="lettering-export-warning"
646
+ >
647
+ {warningCount} bubble{warningCount === 1 ? "" : "s"} may not export cleanly:{" "}
648
+ {Object.entries(overlayWarnings)
649
+ .map(([id, w]) => {
650
+ const idx = overlays.findIndex((o) => o.id === id);
651
+ const problems = [w.outOfBounds ? "outside image" : null, w.overflow ? "text overflow" : null]
652
+ .filter(Boolean)
653
+ .join(", ");
654
+ return `#${idx + 1} ${overlapLabel(overlays[idx])} (${problems})`;
655
+ })
656
+ .join("; ")}
657
+ . Resize or reposition before exporting.
658
+ </div>
659
+ )}
660
+
661
+ {/* Editor surface */}
662
+ <div className="flex-1 min-h-0 flex">
663
+ <div
664
+ ref={containerRef}
665
+ className="flex-1 min-w-0 relative overflow-hidden bg-[#f8f5ef]"
666
+ onClick={handleBackgroundClick}
667
+ data-testid="editor-surface"
668
+ >
669
+ {cut.cleanImagePath && cleanAsset.error ? (
670
+ <div className="w-full h-full flex items-center justify-center text-muted text-xs" data-testid="clean-image-error">
671
+ Clean image not available
672
+ </div>
673
+ ) : cut.cleanImagePath && !cleanAsset.url ? (
674
+ <div className="w-full h-full flex items-center justify-center text-muted text-xs" data-testid="clean-image-loading">
675
+ Loading clean image…
676
+ </div>
677
+ ) : cut.cleanImagePath ? (
678
+ <img
679
+ ref={imgRef}
680
+ src={cleanAsset.url!}
681
+ alt={`Cut ${cut.id} clean`}
682
+ className="w-full h-full object-contain"
683
+ draggable={false}
684
+ onLoad={updateImageBounds}
685
+ />
686
+ ) : isTextPanel ? (
687
+ // Text panel: an aspect-ratio-contained canvas (imageBounds), so the
688
+ // lettering surface matches the exported final's shape (#351, re1).
689
+ imageBounds.width > 0 && (
690
+ <div
691
+ className="absolute flex items-center justify-center text-muted text-xs"
692
+ style={{
693
+ left: imageBounds.x,
694
+ top: imageBounds.y,
695
+ width: imageBounds.width,
696
+ height: imageBounds.height,
697
+ background: cut.background || "#ffffff",
698
+ }}
699
+ data-testid="text-panel-canvas"
700
+ >
701
+ Text panel
702
+ </div>
703
+ )
704
+ ) : (
705
+ <div
706
+ className="w-full h-full bg-white flex items-center justify-center text-muted text-xs"
707
+ ref={(el) => {
708
+ if (el && imageBounds.width === 0) {
709
+ const rect = el.getBoundingClientRect();
710
+ if (rect.width > 0) {
711
+ setImageBounds({ x: 0, y: 0, width: rect.width, height: rect.height });
712
+ }
713
+ }
714
+ }}
715
+ >
716
+ Narration cut
717
+ </div>
718
+ )}
719
+
720
+ {/* Speech balloons, drawn under the overlay boxes (which carry the
721
+ text + drag/resize handles) so the box sits on top of the fill.
722
+ Body + tail are ONE integrated <path> per bubble (#327), mirroring
723
+ the export's traceBalloonPath (#317): one fill, one stroke, so the
724
+ tail reads as part of the balloon outline with no internal seam.
725
+ Tailless speech (no tailAnchor, or a tip inside the bubble) traces
726
+ a plain rounded rectangle. Tail-anchor edits update the path live. */}
727
+ {imageBounds.width > 0 && (
728
+ <svg className="absolute inset-0 w-full h-full pointer-events-none" data-testid="balloon-layer">
729
+ {overlays.map((overlay) => {
730
+ if (overlay.type !== "speech") return null;
731
+ const ox = imageBounds.x + toPixel(overlay.x, imageBounds.width);
732
+ const oy = imageBounds.y + toPixel(overlay.y, imageBounds.height);
733
+ const ow = toPixel(overlay.width, imageBounds.width);
734
+ const oh = toPixel(overlay.height, imageBounds.height);
735
+ const radius = balloonRadiusForOverlay(overlay, ow, oh);
736
+ const tail = overlay.tailAnchor ? speechTailPoints(ox, oy, ow, oh, overlay.tailAnchor, radius) : null;
737
+ // Strong, clean near-black outline scaled to the preview size so
738
+ // the bubble reads as a webtoon balloon (matching the export's
739
+ // proportional stroke), not a faint UI box (#363).
740
+ const strokeW = Math.max(1.5, imageBounds.height * 0.004);
741
+ const selected = overlay.id === selectedId;
742
+ return (
743
+ <path
744
+ key={overlay.id}
745
+ data-testid={`balloon-${overlay.id}`}
746
+ d={balloonPathD(ox, oy, ow, oh, tail, radius)}
747
+ className={`fill-white/95 ${selected ? "stroke-accent" : "stroke-[#1a1a1a]"}`}
748
+ strokeWidth={selected ? strokeW + 0.5 : strokeW}
749
+ strokeLinejoin="round"
750
+ />
751
+ );
752
+ })}
753
+ </svg>
754
+ )}
755
+
756
+ {imageBounds.width > 0 && overlays.map((overlay) => {
757
+ const left = imageBounds.x + toPixel(overlay.x, imageBounds.width);
758
+ const top = imageBounds.y + toPixel(overlay.y, imageBounds.height);
759
+ const width = toPixel(overlay.width, imageBounds.width);
760
+ const height = toPixel(overlay.height, imageBounds.height);
761
+ const isSelected = overlay.id === selectedId;
762
+ // Speech bubbles draw no body border here — the integrated balloon
763
+ // <path> in the layer below is their outline, so a box border would
764
+ // re-introduce the body/tail seam (#327). Their selection cue is the
765
+ // path's accent stroke (plus the resize handle). Narration/SFX keep
766
+ // their bordered box + selection ring as before.
767
+ const isSpeech = overlay.type === "speech";
768
+ // Narration reads as an intentional parchment caption card (rounded,
769
+ // filled), mirroring the export, instead of an empty bordered box (#363).
770
+ const isNarration = overlay.type === "narration";
771
+ const warned = !!overlayWarnings[overlay.id];
772
+
773
+ return (
774
+ <div
775
+ key={overlay.id}
776
+ data-testid={`overlay-${overlay.id}`}
777
+ data-warning={warned ? "true" : "false"}
778
+ onClick={(e) => handleOverlayClick(e, overlay.id)}
779
+ onMouseDown={(e) => handleMouseDown(e, overlay.id, "move")}
780
+ className={`absolute rounded cursor-move select-none ${
781
+ isSpeech ? "" : `border-2 ${TYPE_BORDER[overlay.type]}`
782
+ } ${isNarration ? "bg-[#f4efe6]/85 rounded-md" : ""} ${
783
+ isSelected && !isSpeech ? "ring-2 ring-accent" : ""
784
+ } ${warned ? "ring-2 ring-amber-500" : ""}`}
785
+ style={{ left, top, width, height }}
786
+ >
787
+ {(() => {
788
+ const fontFamily = overlay.type === "sfx" ? displayFontFamily : bodyFontFamily;
789
+ if (!overlay.text) {
790
+ return (
791
+ <span className="text-[9px] px-1 text-muted truncate block pointer-events-none" style={{ fontFamily }}>
792
+ {TYPE_LABEL[overlay.type]}
793
+ </span>
794
+ );
795
+ }
796
+ const hasSpeaker = overlay.type !== "sfx" && !!overlay.speaker;
797
+ if (!fontsReady) {
798
+ // Until the web font's metrics are available, don't freeze
799
+ // canvas-measured line breaks from fallback metrics (they
800
+ // would diverge from export). Show a CSS-wrapped transient;
801
+ // the exact layout computes once fonts are ready (#310, re1).
802
+ return (
803
+ <div
804
+ className="absolute inset-0 flex items-center justify-center px-1 overflow-hidden pointer-events-none text-center break-words"
805
+ style={{
806
+ fontFamily,
807
+ fontSize: Math.max(9, Math.min(height * 0.05, 16)),
808
+ fontWeight: overlay.textStyle?.fontWeight ?? 400,
809
+ }}
810
+ data-testid={`overlay-text-${overlay.id}`}
811
+ data-fonts-ready="false"
812
+ >
813
+ {hasSpeaker ? `${overlay.speaker}: ${overlay.text}` : overlay.text}
814
+ </div>
815
+ );
816
+ }
817
+ const layout = layoutBubbleText(
818
+ measureWidth(fontFamily),
819
+ overlay.text,
820
+ width,
821
+ height,
822
+ bubbleLayoutOptionsForOverlay(overlay, imageBounds.height, width, height),
823
+ );
824
+ return (
825
+ <div
826
+ className="absolute inset-0 flex flex-col items-center justify-center px-1 overflow-hidden pointer-events-none text-center"
827
+ style={{ fontFamily }}
828
+ data-testid={`overlay-text-${overlay.id}`}
829
+ data-fonts-ready="true"
830
+ >
831
+ {hasSpeaker && (
832
+ <span className="font-bold text-[#3a3a3a] block" style={{ fontSize: layout.speakerFontSize, lineHeight: 1.2 }}>
833
+ {overlay.speaker}
834
+ </span>
835
+ )}
836
+ <span
837
+ className="text-[#1a1a1a]"
838
+ style={{ fontSize: layout.fontSize, lineHeight: `${layout.lineHeight}px`, fontWeight: overlay.textStyle?.fontWeight ?? 400 }}
839
+ >
840
+ {layout.lines.map((line, i) => (
841
+ <span key={i} className="block">{line}</span>
842
+ ))}
843
+ </span>
844
+ </div>
845
+ );
846
+ })()}
847
+ {isSelected && (
848
+ <div
849
+ onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, overlay.id, "resize"); }}
850
+ className="absolute bottom-0 right-0 w-2 h-2 bg-accent cursor-se-resize"
851
+ data-testid={`resize-${overlay.id}`}
852
+ />
853
+ )}
854
+ </div>
855
+ );
856
+ })}
857
+ </div>
858
+
859
+ {/* Inspector panel */}
860
+ <div className="w-64 border-l border-border p-3 overflow-y-auto flex-shrink-0">
861
+ <div className="mb-3 rounded border border-accent/30 bg-accent/5 p-2 space-y-1.5" data-testid="ai-draft-current-target">
862
+ <p className="text-[10px] font-bold uppercase tracking-[0.14em] text-accent">AI draft assist</p>
863
+ <p className="text-[11px] text-muted">
864
+ Copy a prompt scoped to {targetLabel ?? `cut ${cut.id}`}. Review and edit any drafted bubbles here before saving.
865
+ </p>
866
+ <button
867
+ type="button"
868
+ onClick={copyAiDraftPrompt}
869
+ className="rounded border border-accent/40 px-2 py-1 text-[11px] font-medium text-accent hover:bg-accent/10"
870
+ data-testid="copy-ai-lettering-current"
871
+ >
872
+ {aiCopied ? "Copied!" : "Copy AI draft prompt"}
873
+ </button>
874
+ </div>
875
+ {/* Insert-from-script (#336): drop the cut's planned dialogue/narration/
876
+ SFX straight into a prefilled overlay — no copy/paste out of JSON. */}
877
+ {scriptLines.length > 0 && (
878
+ <div className="mb-3 space-y-1.5" data-testid="script-insert-panel">
879
+ <span className="text-[10px] font-medium text-muted">From script</span>
880
+ <div className="flex flex-col gap-1">
881
+ {scriptLines.map((line) => (
882
+ <button
883
+ key={line.key}
884
+ onClick={() => addScriptLine(line)}
885
+ data-testid={`script-insert-${line.key}`}
886
+ title={`Add ${line.type} overlay with this text`}
887
+ className="text-left px-2 py-1 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
888
+ >
889
+ <span className="font-medium text-accent">+ {TYPE_LABEL[line.type]}</span>{" "}
890
+ <span className="text-muted">
891
+ {line.speaker ? `${line.speaker}: ` : ""}
892
+ {line.text.length > 32 ? `${line.text.slice(0, 32)}…` : line.text}
893
+ </span>
894
+ </button>
895
+ ))}
896
+ </div>
897
+ </div>
898
+ )}
899
+ {selectedOverlay ? (
900
+ <div className="space-y-3">
901
+ <p className="text-xs font-medium text-foreground">{TYPE_LABEL[selectedOverlay.type]}</p>
902
+
903
+ {selectedOverlay.speaker !== undefined && (
904
+ <label className="block space-y-1">
905
+ <span className="text-[10px] font-medium text-muted">Speaker</span>
906
+ <input
907
+ value={selectedOverlay.speaker || ""}
908
+ onChange={(e) => updateOverlay(selectedOverlay.id, { speaker: e.target.value })}
909
+ className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
910
+ placeholder="Character name"
911
+ data-testid="inspector-speaker"
912
+ />
913
+ </label>
914
+ )}
915
+
916
+ <label className="block space-y-1">
917
+ <span className="text-[10px] font-medium text-muted">Text</span>
918
+ <textarea
919
+ value={selectedOverlay.text}
920
+ onChange={(e) => updateOverlay(selectedOverlay.id, { text: e.target.value })}
921
+ rows={3}
922
+ className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent resize-none focus:border-accent focus:outline-none"
923
+ placeholder="Overlay text"
924
+ data-testid="inspector-text"
925
+ />
926
+ </label>
927
+
928
+ {/* One-click resize so a long line that overflows can be fitted
929
+ without hand-dragging the box (#452). */}
930
+ <button
931
+ onClick={() => updateOverlay(selectedOverlay.id, fittedSize(selectedOverlay))}
932
+ data-testid="inspector-fit-text"
933
+ className="w-full px-2 py-1 text-[11px] border border-border rounded hover:border-accent hover:text-accent"
934
+ title="Resize this overlay so its text fits without overflowing"
935
+ >
936
+ Fit box to text
937
+ </button>
938
+
939
+ <div className="space-y-1.5 rounded border border-border/70 p-2" data-testid="inspector-typography">
940
+ <div className="flex items-center justify-between gap-2">
941
+ <span className="text-[10px] font-medium text-muted">Typography</span>
942
+ {selectedOverlay.textStyle?.mode === "manual" ? (
943
+ <button
944
+ type="button"
945
+ onClick={() => updateOverlay(selectedOverlay.id, { textStyle: undefined })}
946
+ className="px-1.5 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
947
+ data-testid="inspector-text-auto"
948
+ >
949
+ Auto-fit
950
+ </button>
951
+ ) : (
952
+ <button
953
+ type="button"
954
+ onClick={() => enableManualTypography(selectedOverlay)}
955
+ className="px-1.5 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
956
+ data-testid="inspector-text-manual"
957
+ >
958
+ Manual
959
+ </button>
960
+ )}
961
+ </div>
962
+ {selectedOverlay.textStyle?.mode === "manual" ? (
963
+ <div className="space-y-1.5">
964
+ <label className="block space-y-1">
965
+ <span className="text-[10px] text-muted">Font size (% panel height)</span>
966
+ <input
967
+ type="number"
968
+ step="0.1"
969
+ min="1.5"
970
+ max="12"
971
+ value={(((selectedOverlay.textStyle.fontScale ?? 0.032) * 100)).toFixed(1)}
972
+ onChange={(e) => updateOverlay(selectedOverlay.id, {
973
+ textStyle: {
974
+ ...selectedOverlay.textStyle,
975
+ mode: "manual",
976
+ fontScale: Math.max(0.015, Math.min(0.12, (parseFloat(e.target.value) || 3.2) / 100)),
977
+ },
978
+ })}
979
+ className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
980
+ data-testid="inspector-font-scale"
981
+ />
982
+ </label>
983
+ <label className="block space-y-1">
984
+ <span className="text-[10px] text-muted">Weight</span>
985
+ <select
986
+ value={String(selectedOverlay.textStyle.fontWeight ?? 400)}
987
+ onChange={(e) => updateOverlay(selectedOverlay.id, {
988
+ textStyle: {
989
+ ...selectedOverlay.textStyle,
990
+ mode: "manual",
991
+ fontWeight: e.target.value === "700" ? 700 : 400,
992
+ },
993
+ })}
994
+ className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
995
+ data-testid="inspector-font-weight"
996
+ >
997
+ <option value="400">Regular</option>
998
+ <option value="700">Bold</option>
999
+ </select>
1000
+ </label>
1001
+ <label className="block space-y-1">
1002
+ <span className="text-[10px] text-muted">Line height</span>
1003
+ <input
1004
+ type="number"
1005
+ step="0.05"
1006
+ min="0.9"
1007
+ max="2"
1008
+ value={(selectedOverlay.textStyle.lineHeightFactor ?? 1.2).toFixed(2)}
1009
+ onChange={(e) => updateOverlay(selectedOverlay.id, {
1010
+ textStyle: {
1011
+ ...selectedOverlay.textStyle,
1012
+ mode: "manual",
1013
+ lineHeightFactor: Math.max(0.9, Math.min(2, parseFloat(e.target.value) || 1.2)),
1014
+ },
1015
+ })}
1016
+ className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
1017
+ data-testid="inspector-line-height"
1018
+ />
1019
+ </label>
1020
+ {selectedOverlay.type !== "sfx" && (
1021
+ <label className="block space-y-1">
1022
+ <span className="text-[10px] text-muted">Speaker scale</span>
1023
+ <input
1024
+ type="number"
1025
+ step="0.05"
1026
+ min="0.5"
1027
+ max="1.5"
1028
+ value={(selectedOverlay.textStyle.speakerScale ?? 0.8).toFixed(2)}
1029
+ onChange={(e) => updateOverlay(selectedOverlay.id, {
1030
+ textStyle: {
1031
+ ...selectedOverlay.textStyle,
1032
+ mode: "manual",
1033
+ speakerScale: Math.max(0.5, Math.min(1.5, parseFloat(e.target.value) || 0.8)),
1034
+ },
1035
+ })}
1036
+ className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
1037
+ data-testid="inspector-speaker-scale"
1038
+ />
1039
+ </label>
1040
+ )}
1041
+ </div>
1042
+ ) : (
1043
+ <p className="text-[10px] text-muted">Auto-fit stays on by default and resizes text to the box.</p>
1044
+ )}
1045
+ </div>
1046
+
1047
+ {selectedOverlay.type === "speech" && (() => {
1048
+ const tail = selectedOverlay.tailAnchor || { x: 0.5, y: 1.2 };
1049
+ return (
1050
+ <div className="space-y-1">
1051
+ <span className="text-[10px] font-medium text-muted">Tail anchor</span>
1052
+ <div className="flex flex-wrap gap-1" data-testid="inspector-tail-presets">
1053
+ {TAIL_PRESETS.map((preset) => (
1054
+ <button
1055
+ key={preset.key}
1056
+ type="button"
1057
+ onClick={() => updateOverlay(selectedOverlay.id, { tailAnchor: preset.anchor })}
1058
+ className="px-1.5 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
1059
+ data-testid={`inspector-tail-${preset.key}`}
1060
+ >
1061
+ {preset.label}
1062
+ </button>
1063
+ ))}
1064
+ </div>
1065
+ <div className="flex gap-2">
1066
+ <label className="flex items-center gap-1 text-[10px] font-mono text-muted">
1067
+ x
1068
+ <input
1069
+ type="number"
1070
+ step="0.1"
1071
+ value={tail.x}
1072
+ onChange={(e) => updateOverlay(selectedOverlay.id, { tailAnchor: { ...tail, x: parseFloat(e.target.value) || 0 } })}
1073
+ className="w-14 px-1 py-0.5 text-[10px] border border-border rounded bg-transparent focus:border-accent focus:outline-none"
1074
+ data-testid="inspector-tail-x"
1075
+ />
1076
+ </label>
1077
+ <label className="flex items-center gap-1 text-[10px] font-mono text-muted">
1078
+ y
1079
+ <input
1080
+ type="number"
1081
+ step="0.1"
1082
+ value={tail.y}
1083
+ onChange={(e) => updateOverlay(selectedOverlay.id, { tailAnchor: { ...tail, y: parseFloat(e.target.value) || 0 } })}
1084
+ className="w-14 px-1 py-0.5 text-[10px] border border-border rounded bg-transparent focus:border-accent focus:outline-none"
1085
+ data-testid="inspector-tail-y"
1086
+ />
1087
+ </label>
1088
+ </div>
1089
+ </div>
1090
+ );
1091
+ })()}
1092
+
1093
+ {selectedOverlay.type !== "sfx" && (
1094
+ <div className="space-y-1.5 rounded border border-border/70 p-2" data-testid="inspector-bubble-style">
1095
+ <span className="text-[10px] font-medium text-muted">Bubble controls</span>
1096
+ <label className="block space-y-1">
1097
+ <span className="text-[10px] text-muted">Padding X (% width)</span>
1098
+ <input
1099
+ type="number"
1100
+ step="1"
1101
+ min="0"
1102
+ max="25"
1103
+ value={(((selectedOverlay.bubbleStyle?.paddingX ?? 0.06) * 100)).toFixed(0)}
1104
+ onChange={(e) => updateOverlay(selectedOverlay.id, {
1105
+ bubbleStyle: {
1106
+ ...selectedOverlay.bubbleStyle,
1107
+ paddingX: Math.max(0, Math.min(0.25, (parseFloat(e.target.value) || 6) / 100)),
1108
+ },
1109
+ })}
1110
+ className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
1111
+ data-testid="inspector-padding-x"
1112
+ />
1113
+ </label>
1114
+ <label className="block space-y-1">
1115
+ <span className="text-[10px] text-muted">Padding Y (% height)</span>
1116
+ <input
1117
+ type="number"
1118
+ step="1"
1119
+ min="0"
1120
+ max="25"
1121
+ value={(((selectedOverlay.bubbleStyle?.paddingY ?? 0.08) * 100)).toFixed(0)}
1122
+ onChange={(e) => updateOverlay(selectedOverlay.id, {
1123
+ bubbleStyle: {
1124
+ ...selectedOverlay.bubbleStyle,
1125
+ paddingY: Math.max(0, Math.min(0.25, (parseFloat(e.target.value) || 8) / 100)),
1126
+ },
1127
+ })}
1128
+ className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
1129
+ data-testid="inspector-padding-y"
1130
+ />
1131
+ </label>
1132
+ <label className="block space-y-1">
1133
+ <span className="text-[10px] text-muted">Corner roundness (% short side)</span>
1134
+ <input
1135
+ type="number"
1136
+ step="1"
1137
+ min="0"
1138
+ max="49"
1139
+ value={(((selectedOverlay.bubbleStyle?.cornerRadius ?? 0.4) * 100)).toFixed(0)}
1140
+ onChange={(e) => updateOverlay(selectedOverlay.id, {
1141
+ bubbleStyle: {
1142
+ ...selectedOverlay.bubbleStyle,
1143
+ cornerRadius: Math.max(0, Math.min(0.49, (parseFloat(e.target.value) || 40) / 100)),
1144
+ },
1145
+ })}
1146
+ className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
1147
+ data-testid="inspector-corner-radius"
1148
+ />
1149
+ </label>
1150
+ </div>
1151
+ )}
1152
+
1153
+ <div className="text-[10px] text-muted" data-testid="inspector-font">
1154
+ Font: {selectedOverlay.type === "sfx" ? displayFont.family : bodyFont.family}
1155
+ </div>
1156
+
1157
+ <div className="text-[10px] font-mono text-muted space-y-0.5">
1158
+ <p>x: {selectedOverlay.x.toFixed(3)}, y: {selectedOverlay.y.toFixed(3)}</p>
1159
+ <p>w: {selectedOverlay.width.toFixed(3)}, h: {selectedOverlay.height.toFixed(3)}</p>
1160
+ </div>
1161
+
1162
+ <button
1163
+ onClick={() => {
1164
+ if (confirmDelete) deleteOverlay(selectedOverlay.id);
1165
+ else setConfirmDelete(true);
1166
+ }}
1167
+ className="w-full px-2 py-1 text-xs text-error border border-error/30 rounded hover:bg-error/5"
1168
+ data-testid="delete-overlay"
1169
+ >
1170
+ {confirmDelete ? "Click again to delete" : "Delete"}
1171
+ </button>
1172
+ </div>
1173
+ ) : (
1174
+ <p className="text-xs text-muted" data-testid="inspector-empty">
1175
+ Select an overlay to inspect.
1176
+ </p>
1177
+ )}
1178
+ </div>
1179
+ </div>
1180
+ </div>
1181
+ );
1182
+ }