plotlink-ows 1.0.33 → 1.2.94

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