plotlink-ows 1.2.95 → 1.2.97
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.
- package/app/lib/cartoon-production-status.ts +77 -0
- package/app/lib/cuts.ts +135 -18
- package/app/lib/lettering-status.ts +64 -6
- package/app/web/components/CartoonNextAction.tsx +154 -36
- package/app/web/components/CartoonProductionStatus.tsx +142 -0
- package/app/web/components/CartoonPublishPage.tsx +34 -25
- package/app/web/components/CutListPanel.tsx +1239 -513
- package/app/web/components/CutOverlayPreview.tsx +306 -0
- package/app/web/components/EpisodesPage.tsx +24 -0
- package/app/web/components/FinishEpisodePanel.tsx +28 -104
- package/app/web/components/LetteringEditor.tsx +968 -437
- package/app/web/components/PreviewPanel.tsx +1457 -848
- package/app/web/components/StoriesPage.tsx +1022 -526
- package/app/web/components/StoryInfoPage.tsx +31 -14
- package/app/web/components/StoryProgressPanel.tsx +19 -23
- package/app/web/dist/assets/{export-cut-che5mMWc.js → export-cut-Cj-cOtan.js} +1 -1
- package/app/web/dist/assets/index-CCeEYE7p.css +32 -0
- package/app/web/dist/assets/index-CF7pE09m.js +141 -0
- package/app/web/dist/index.html +2 -2
- package/package.json +1 -1
- package/app/web/dist/assets/index-CcfChGEK.css +0 -32
- package/app/web/dist/assets/index-Dc2TQ3Ij.js +0 -143
|
@@ -20,10 +20,14 @@ import {
|
|
|
20
20
|
type OverlayType,
|
|
21
21
|
} from "@app-lib/overlays";
|
|
22
22
|
import { layoutBubbleText } from "@app-lib/bubble-text";
|
|
23
|
-
import {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
import {
|
|
24
|
+
cutLetteringChecklist,
|
|
25
|
+
cutScriptLines,
|
|
26
|
+
isExportStale,
|
|
27
|
+
overlaysSignature,
|
|
28
|
+
type ScriptLine,
|
|
29
|
+
} from "@app-lib/lettering-status";
|
|
30
|
+
import { textPanelDimensions, type CutAiDraft } from "@app-lib/cuts";
|
|
27
31
|
import { useAuthedAsset } from "./asset-image";
|
|
28
32
|
|
|
29
33
|
function toPixel(norm: number, size: number): number {
|
|
@@ -63,13 +67,17 @@ interface Cut {
|
|
|
63
67
|
kind?: "image" | "text";
|
|
64
68
|
background?: string;
|
|
65
69
|
aspectRatio?: string;
|
|
70
|
+
aiDraft?: CutAiDraft | null;
|
|
66
71
|
}
|
|
67
72
|
|
|
68
73
|
interface LetteringEditorProps {
|
|
69
74
|
storyName: string;
|
|
70
75
|
cut: Cut;
|
|
71
76
|
plotFile: string;
|
|
72
|
-
onSave: (
|
|
77
|
+
onSave: (
|
|
78
|
+
overlays: Overlay[],
|
|
79
|
+
aiDraft?: CutAiDraft | null,
|
|
80
|
+
) => void | Promise<void>;
|
|
73
81
|
onClose: () => void;
|
|
74
82
|
onExported?: () => void;
|
|
75
83
|
language?: string;
|
|
@@ -78,6 +86,10 @@ interface LetteringEditorProps {
|
|
|
78
86
|
targetLabel?: string;
|
|
79
87
|
/** When true, the Save button returns to the review board after persisting. */
|
|
80
88
|
returnOnSave?: boolean;
|
|
89
|
+
/** Whether the wider app work area / terminal is currently restored. */
|
|
90
|
+
workspaceVisible?: boolean;
|
|
91
|
+
/** Toggle the surrounding app work area while staying in the editor. */
|
|
92
|
+
onToggleWorkspaceVisible?: () => void;
|
|
81
93
|
}
|
|
82
94
|
|
|
83
95
|
const TYPE_LABEL: Record<OverlayType, string> = {
|
|
@@ -96,7 +108,8 @@ const TYPE_BORDER: Record<OverlayType, string> = {
|
|
|
96
108
|
// a trimmed text snippet, falling back to the type name for empty bubbles.
|
|
97
109
|
function overlapLabel(o: Overlay): string {
|
|
98
110
|
const snippet = (o.speaker || o.text || "").trim().replace(/\s+/g, " ");
|
|
99
|
-
if (snippet)
|
|
111
|
+
if (snippet)
|
|
112
|
+
return `“${snippet.length > 18 ? `${snippet.slice(0, 18)}…` : snippet}”`;
|
|
100
113
|
return TYPE_LABEL[o.type];
|
|
101
114
|
}
|
|
102
115
|
|
|
@@ -112,7 +125,20 @@ function clamp(v: number, min: number, max: number): number {
|
|
|
112
125
|
return Math.min(max, Math.max(min, v));
|
|
113
126
|
}
|
|
114
127
|
|
|
115
|
-
export function LetteringEditor({
|
|
128
|
+
export function LetteringEditor({
|
|
129
|
+
storyName,
|
|
130
|
+
cut,
|
|
131
|
+
plotFile,
|
|
132
|
+
onSave,
|
|
133
|
+
onClose,
|
|
134
|
+
onExported,
|
|
135
|
+
language = "English",
|
|
136
|
+
authFetch,
|
|
137
|
+
targetLabel,
|
|
138
|
+
returnOnSave = false,
|
|
139
|
+
workspaceVisible = false,
|
|
140
|
+
onToggleWorkspaceVisible,
|
|
141
|
+
}: LetteringEditorProps) {
|
|
116
142
|
const bodyFont = getDefaultFont(language);
|
|
117
143
|
const displayFont = getDisplayFont();
|
|
118
144
|
const bodyFontFamily = getFontFamily(bodyFont);
|
|
@@ -132,10 +158,14 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
132
158
|
try {
|
|
133
159
|
const { ensureFontsReady } = await import("./export-cut");
|
|
134
160
|
await ensureFontsReady([bodyFont.family, displayFont.family]);
|
|
135
|
-
} catch {
|
|
161
|
+
} catch {
|
|
162
|
+
/* best effort — still render the preview */
|
|
163
|
+
}
|
|
136
164
|
if (!cancelled) setFontsReady(true);
|
|
137
165
|
})();
|
|
138
|
-
return () => {
|
|
166
|
+
return () => {
|
|
167
|
+
cancelled = true;
|
|
168
|
+
};
|
|
139
169
|
}, [bodyFont.family, displayFont.family]);
|
|
140
170
|
|
|
141
171
|
// Clean image lives behind requireAuth, so a raw <img src> would 401. Load it
|
|
@@ -144,15 +174,22 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
144
174
|
// Repair agent-authored overlays (e.g. semantic `position` strings with no
|
|
145
175
|
// numeric geometry) on load so the bubbles actually render and export — and
|
|
146
176
|
// surface a note when some could not be auto-placed (#309).
|
|
147
|
-
const overlayNormalization = useMemo(
|
|
177
|
+
const overlayNormalization = useMemo(
|
|
178
|
+
() => normalizeOverlays(cut.overlays),
|
|
179
|
+
[cut.overlays],
|
|
180
|
+
);
|
|
148
181
|
const invalidOverlayCount = overlayNormalization.invalid.length;
|
|
149
182
|
// Overlays that could not be placed (no geometry, no recognizable position)
|
|
150
183
|
// are NOT exported. Exporting silently would produce a final missing that
|
|
151
184
|
// bubble/text, so block export until the writer explicitly discards them (#309).
|
|
152
185
|
const [acknowledgedInvalid, setAcknowledgedInvalid] = useState(false);
|
|
153
186
|
const autoPlacedOverlays =
|
|
154
|
-
invalidOverlayCount === 0 &&
|
|
155
|
-
|
|
187
|
+
invalidOverlayCount === 0 &&
|
|
188
|
+
overlayNormalization.changed &&
|
|
189
|
+
overlayNormalization.overlays.length > 0;
|
|
190
|
+
const [overlays, setOverlays] = useState<Overlay[]>(
|
|
191
|
+
() => overlayNormalization.overlays as Overlay[],
|
|
192
|
+
);
|
|
156
193
|
// Signature of the overlays that match the current export/upload (#336, re1).
|
|
157
194
|
// Captured (already normalized like the live `overlays`) when the cut opens so
|
|
158
195
|
// a load-time normalization isn't mistaken for a user edit, and advanced to the
|
|
@@ -164,15 +201,19 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
164
201
|
// Offscreen canvas to measure text exactly like the export canvas, so the
|
|
165
202
|
// preview wraps/sizes bubble text identically to the final image (#310).
|
|
166
203
|
const measureCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
167
|
-
const measureWidth = useCallback(
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
204
|
+
const measureWidth = useCallback(
|
|
205
|
+
(fontFamily: string) =>
|
|
206
|
+
(text: string, fontSize: number, fontWeight: 400 | 700 = 400): number => {
|
|
207
|
+
if (!measureCanvasRef.current && typeof document !== "undefined") {
|
|
208
|
+
measureCanvasRef.current = document.createElement("canvas");
|
|
209
|
+
}
|
|
210
|
+
const mctx = measureCanvasRef.current?.getContext("2d");
|
|
211
|
+
if (!mctx) return text.length * fontSize * 0.5; // jsdom fallback
|
|
212
|
+
mctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
|
|
213
|
+
return mctx.measureText(text).width;
|
|
214
|
+
},
|
|
215
|
+
[],
|
|
216
|
+
);
|
|
176
217
|
// Gate the exact (canvas-measured) preview layout on the SAME font-readiness
|
|
177
218
|
// signal export uses (ensureFontsReady), so the preview does not freeze line
|
|
178
219
|
// breaks computed from fallback-font metrics that would diverge from the
|
|
@@ -183,11 +224,25 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
183
224
|
const [exporting, setExporting] = useState(false);
|
|
184
225
|
const [exportError, setExportError] = useState<string | null>(null);
|
|
185
226
|
const [saveError, setSaveError] = useState<string | null>(null);
|
|
186
|
-
const [
|
|
187
|
-
const [imageBounds, setImageBounds] = useState({
|
|
227
|
+
const [showHelp, setShowHelp] = useState(false);
|
|
228
|
+
const [imageBounds, setImageBounds] = useState({
|
|
229
|
+
x: 0,
|
|
230
|
+
y: 0,
|
|
231
|
+
width: 0,
|
|
232
|
+
height: 0,
|
|
233
|
+
});
|
|
188
234
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
189
235
|
const imgRef = useRef<HTMLImageElement>(null);
|
|
190
|
-
const dragRef = useRef<{
|
|
236
|
+
const dragRef = useRef<{
|
|
237
|
+
id: string;
|
|
238
|
+
mode: "move" | "resize";
|
|
239
|
+
startX: number;
|
|
240
|
+
startY: number;
|
|
241
|
+
origX: number;
|
|
242
|
+
origY: number;
|
|
243
|
+
origW: number;
|
|
244
|
+
origH: number;
|
|
245
|
+
} | null>(null);
|
|
191
246
|
|
|
192
247
|
const updateImageBounds = useCallback(() => {
|
|
193
248
|
const container = containerRef.current;
|
|
@@ -199,7 +254,10 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
199
254
|
if (cut.kind === "text") {
|
|
200
255
|
// A text panel has no image — size the editor canvas from the SAME aspect
|
|
201
256
|
// ratio the export uses, so lettering and the exported final agree (#351).
|
|
202
|
-
const dims = textPanelDimensions(cut.aspectRatio) ?? {
|
|
257
|
+
const dims = textPanelDimensions(cut.aspectRatio) ?? {
|
|
258
|
+
width: 800,
|
|
259
|
+
height: 600,
|
|
260
|
+
};
|
|
203
261
|
iw = dims.width;
|
|
204
262
|
ih = dims.height;
|
|
205
263
|
} else {
|
|
@@ -212,7 +270,12 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
212
270
|
const scale = Math.min(cw / iw, ch / ih);
|
|
213
271
|
const rw = iw * scale;
|
|
214
272
|
const rh = ih * scale;
|
|
215
|
-
setImageBounds({
|
|
273
|
+
setImageBounds({
|
|
274
|
+
x: (cw - rw) / 2,
|
|
275
|
+
y: (ch - rh) / 2,
|
|
276
|
+
width: rw,
|
|
277
|
+
height: rh,
|
|
278
|
+
});
|
|
216
279
|
}, [cut.kind, cut.aspectRatio]);
|
|
217
280
|
|
|
218
281
|
useEffect(() => {
|
|
@@ -229,76 +292,130 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
229
292
|
// default font; without measurement it falls back to a generous default that
|
|
230
293
|
// fits ordinary lines, instead of the tiny create-default. The writer can still
|
|
231
294
|
// resize freely afterward, and the overflow warning stays useful if they shrink it.
|
|
232
|
-
const fittedSize = useCallback(
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
295
|
+
const fittedSize = useCallback(
|
|
296
|
+
(o: Overlay): { width: number; height: number } => {
|
|
297
|
+
const comfortable = comfortableOverlaySize(o.type, o.x, o.y);
|
|
298
|
+
const width = comfortable.width;
|
|
299
|
+
const maxH = Math.max(0.08, 1 - o.y);
|
|
300
|
+
if (!o.text || !fontsReady || imageBounds.width <= 0) {
|
|
301
|
+
return comfortable;
|
|
302
|
+
}
|
|
303
|
+
const fontFamily = o.type === "sfx" ? displayFontFamily : bodyFontFamily;
|
|
304
|
+
const wPx = toPixel(width, imageBounds.width);
|
|
305
|
+
let height = o.type === "sfx" ? 0.08 : 0.12;
|
|
306
|
+
for (let i = 0; i < 24; i++) {
|
|
307
|
+
const h = Math.min(height, maxH);
|
|
308
|
+
const hPx = toPixel(h, imageBounds.height);
|
|
309
|
+
const layout = layoutBubbleText(
|
|
310
|
+
measureWidth(fontFamily),
|
|
311
|
+
o.text,
|
|
312
|
+
wPx,
|
|
313
|
+
hPx,
|
|
314
|
+
bubbleLayoutOptionsForOverlay(
|
|
315
|
+
{ ...o, width, height: h },
|
|
316
|
+
imageBounds.height || 300,
|
|
317
|
+
wPx,
|
|
318
|
+
hPx,
|
|
319
|
+
),
|
|
320
|
+
);
|
|
321
|
+
if (!layout.overflow || h >= maxH) return { width, height: h };
|
|
322
|
+
height += 0.03;
|
|
323
|
+
}
|
|
324
|
+
return { width, height: Math.min(height, maxH) };
|
|
325
|
+
},
|
|
326
|
+
[fontsReady, imageBounds, measureWidth, bodyFontFamily, displayFontFamily],
|
|
327
|
+
);
|
|
254
328
|
|
|
255
|
-
const addOverlay = useCallback(
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
329
|
+
const addOverlay = useCallback(
|
|
330
|
+
(type: OverlayType) => {
|
|
331
|
+
const o = createOverlay(
|
|
332
|
+
type,
|
|
333
|
+
0.1 + Math.random() * 0.3,
|
|
334
|
+
0.1 + Math.random() * 0.3,
|
|
335
|
+
);
|
|
336
|
+
const sized: Overlay = { ...o, ...fittedSize(o) };
|
|
337
|
+
setOverlays((prev) => [...prev, sized]);
|
|
338
|
+
setSelectedId(sized.id);
|
|
339
|
+
},
|
|
340
|
+
[fittedSize],
|
|
341
|
+
);
|
|
261
342
|
|
|
262
343
|
// Insert a line from the cut's cuts.json script (#336) as a prefilled overlay,
|
|
263
344
|
// so the writer never has to hand-copy dialogue/narration/SFX out of the JSON.
|
|
264
|
-
const addScriptLine = useCallback(
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
345
|
+
const addScriptLine = useCallback(
|
|
346
|
+
(line: ScriptLine) => {
|
|
347
|
+
const o = createOverlay(
|
|
348
|
+
line.type,
|
|
349
|
+
0.1 + Math.random() * 0.3,
|
|
350
|
+
0.1 + Math.random() * 0.3,
|
|
351
|
+
);
|
|
352
|
+
const filled: Overlay = {
|
|
353
|
+
...o,
|
|
354
|
+
text: line.text,
|
|
355
|
+
...(line.type === "speech" && line.speaker
|
|
356
|
+
? { speaker: line.speaker }
|
|
357
|
+
: {}),
|
|
358
|
+
};
|
|
359
|
+
const sized: Overlay = { ...filled, ...fittedSize(filled) };
|
|
360
|
+
setOverlays((prev) => [...prev, sized]);
|
|
361
|
+
setSelectedId(sized.id);
|
|
362
|
+
},
|
|
363
|
+
[fittedSize],
|
|
364
|
+
);
|
|
275
365
|
|
|
276
366
|
const updateOverlay = useCallback((id: string, changes: Partial<Overlay>) => {
|
|
277
|
-
setOverlays((prev) =>
|
|
367
|
+
setOverlays((prev) =>
|
|
368
|
+
prev.map((o) => (o.id === id ? { ...o, ...changes } : o)),
|
|
369
|
+
);
|
|
278
370
|
}, []);
|
|
279
371
|
|
|
280
|
-
const enableManualTypography = useCallback(
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
372
|
+
const enableManualTypography = useCallback(
|
|
373
|
+
(overlay: Overlay) => {
|
|
374
|
+
const renderHeight = imageBounds.height || 300;
|
|
375
|
+
const width =
|
|
376
|
+
imageBounds.width > 0 ? toPixel(overlay.width, imageBounds.width) : 200;
|
|
377
|
+
const height =
|
|
378
|
+
imageBounds.height > 0
|
|
379
|
+
? toPixel(overlay.height, imageBounds.height)
|
|
380
|
+
: 100;
|
|
381
|
+
const fontFamily =
|
|
382
|
+
overlay.type === "sfx" ? displayFontFamily : bodyFontFamily;
|
|
383
|
+
const autoLayout = layoutBubbleText(
|
|
384
|
+
measureWidth(fontFamily),
|
|
385
|
+
overlay.text,
|
|
386
|
+
width,
|
|
387
|
+
height,
|
|
388
|
+
bubbleLayoutOptionsForOverlay(
|
|
389
|
+
{ ...overlay, textStyle: undefined },
|
|
390
|
+
renderHeight,
|
|
391
|
+
width,
|
|
392
|
+
height,
|
|
393
|
+
),
|
|
394
|
+
);
|
|
395
|
+
updateOverlay(overlay.id, {
|
|
293
396
|
textStyle: {
|
|
294
397
|
mode: "manual",
|
|
295
398
|
fontScale: autoLayout.fontSize / Math.max(1, renderHeight),
|
|
296
399
|
fontWeight: overlay.textStyle?.fontWeight ?? 400,
|
|
297
|
-
lineHeightFactor:
|
|
298
|
-
|
|
400
|
+
lineHeightFactor:
|
|
401
|
+
autoLayout.fontSize > 0
|
|
402
|
+
? autoLayout.lineHeight / autoLayout.fontSize
|
|
403
|
+
: 1.2,
|
|
404
|
+
speakerScale:
|
|
405
|
+
autoLayout.fontSize > 0 && autoLayout.speakerFontSize > 0
|
|
406
|
+
? autoLayout.speakerFontSize / autoLayout.fontSize
|
|
407
|
+
: 0.8,
|
|
299
408
|
},
|
|
300
|
-
|
|
301
|
-
|
|
409
|
+
});
|
|
410
|
+
},
|
|
411
|
+
[
|
|
412
|
+
imageBounds,
|
|
413
|
+
displayFontFamily,
|
|
414
|
+
bodyFontFamily,
|
|
415
|
+
measureWidth,
|
|
416
|
+
updateOverlay,
|
|
417
|
+
],
|
|
418
|
+
);
|
|
302
419
|
|
|
303
420
|
const deleteOverlay = useCallback((id: string) => {
|
|
304
421
|
setOverlays((prev) => prev.filter((o) => o.id !== id));
|
|
@@ -317,23 +434,26 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
317
434
|
setConfirmDelete(false);
|
|
318
435
|
}, []);
|
|
319
436
|
|
|
320
|
-
const handleMouseDown = useCallback(
|
|
321
|
-
e.
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
437
|
+
const handleMouseDown = useCallback(
|
|
438
|
+
(e: React.MouseEvent, id: string, mode: "move" | "resize") => {
|
|
439
|
+
e.stopPropagation();
|
|
440
|
+
e.preventDefault();
|
|
441
|
+
const overlay = overlays.find((o) => o.id === id);
|
|
442
|
+
if (!overlay) return;
|
|
443
|
+
setSelectedId(id);
|
|
444
|
+
dragRef.current = {
|
|
445
|
+
id,
|
|
446
|
+
mode,
|
|
447
|
+
startX: e.clientX,
|
|
448
|
+
startY: e.clientY,
|
|
449
|
+
origX: overlay.x,
|
|
450
|
+
origY: overlay.y,
|
|
451
|
+
origW: overlay.width,
|
|
452
|
+
origH: overlay.height,
|
|
453
|
+
};
|
|
454
|
+
},
|
|
455
|
+
[overlays],
|
|
456
|
+
);
|
|
337
457
|
|
|
338
458
|
useEffect(() => {
|
|
339
459
|
const onMouseMove = (e: MouseEvent) => {
|
|
@@ -353,7 +473,9 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
353
473
|
}
|
|
354
474
|
};
|
|
355
475
|
|
|
356
|
-
const onMouseUp = () => {
|
|
476
|
+
const onMouseUp = () => {
|
|
477
|
+
dragRef.current = null;
|
|
478
|
+
};
|
|
357
479
|
|
|
358
480
|
window.addEventListener("mousemove", onMouseMove);
|
|
359
481
|
window.addEventListener("mouseup", onMouseUp);
|
|
@@ -366,18 +488,24 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
366
488
|
const handleSave = useCallback(async () => {
|
|
367
489
|
setSaveError(null);
|
|
368
490
|
try {
|
|
369
|
-
|
|
491
|
+
const currentSig = overlaysSignature(overlays);
|
|
492
|
+
const nextAiDraft =
|
|
493
|
+
cut.aiDraft?.status === "generated" &&
|
|
494
|
+
currentSig !== (cut.aiDraft.baseSig ?? "")
|
|
495
|
+
? {
|
|
496
|
+
...cut.aiDraft,
|
|
497
|
+
status: "edited" as const,
|
|
498
|
+
updatedAt: new Date().toISOString(),
|
|
499
|
+
}
|
|
500
|
+
: (cut.aiDraft ?? undefined);
|
|
501
|
+
await onSave(overlays, nextAiDraft ?? null);
|
|
370
502
|
if (returnOnSave) onClose();
|
|
371
503
|
} catch (err) {
|
|
372
|
-
setSaveError(
|
|
504
|
+
setSaveError(
|
|
505
|
+
err instanceof Error ? err.message : "Failed to save overlays",
|
|
506
|
+
);
|
|
373
507
|
}
|
|
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]);
|
|
508
|
+
}, [overlays, onSave, onClose, returnOnSave, cut.aiDraft]);
|
|
381
509
|
|
|
382
510
|
const handleExport = useCallback(async () => {
|
|
383
511
|
// Block export when the cut plan contained overlays that could not be placed
|
|
@@ -399,10 +527,15 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
399
527
|
const { exportCut, ensureFontsReady } = await import("./export-cut");
|
|
400
528
|
|
|
401
529
|
const usesSfx = overlays.some((o) => o.type === "sfx");
|
|
402
|
-
const fontsToCheck = [
|
|
530
|
+
const fontsToCheck = [
|
|
531
|
+
bodyFont.family,
|
|
532
|
+
...(usesSfx ? [displayFont.family] : []),
|
|
533
|
+
];
|
|
403
534
|
const { ready, missing } = await ensureFontsReady(fontsToCheck);
|
|
404
535
|
if (!ready) {
|
|
405
|
-
setExportError(
|
|
536
|
+
setExportError(
|
|
537
|
+
`Fonts not loaded: ${missing.join(", ")}. Check your connection and retry.`,
|
|
538
|
+
);
|
|
406
539
|
setExporting(false);
|
|
407
540
|
return;
|
|
408
541
|
}
|
|
@@ -427,7 +560,9 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
427
560
|
{ narration: cut.narration, dialogue: cut.dialogue },
|
|
428
561
|
// Text panels have no clean image — render the final on a styled
|
|
429
562
|
// background canvas sized by the panel's aspect ratio (#351).
|
|
430
|
-
cut.kind === "text"
|
|
563
|
+
cut.kind === "text"
|
|
564
|
+
? { background: cut.background, aspectRatio: cut.aspectRatio }
|
|
565
|
+
: undefined,
|
|
431
566
|
);
|
|
432
567
|
|
|
433
568
|
const fd = new FormData();
|
|
@@ -452,7 +587,22 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
452
587
|
} finally {
|
|
453
588
|
setExporting(false);
|
|
454
589
|
}
|
|
455
|
-
}, [
|
|
590
|
+
}, [
|
|
591
|
+
cut,
|
|
592
|
+
cleanAsset,
|
|
593
|
+
overlays,
|
|
594
|
+
storyName,
|
|
595
|
+
plotFile,
|
|
596
|
+
bodyFont,
|
|
597
|
+
displayFont,
|
|
598
|
+
bodyFontFamily,
|
|
599
|
+
displayFontFamily,
|
|
600
|
+
authFetch,
|
|
601
|
+
onSave,
|
|
602
|
+
onExported,
|
|
603
|
+
invalidOverlayCount,
|
|
604
|
+
acknowledgedInvalid,
|
|
605
|
+
]);
|
|
456
606
|
|
|
457
607
|
const selectedOverlay = overlays.find((o) => o.id === selectedId);
|
|
458
608
|
|
|
@@ -460,7 +610,10 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
460
610
|
// the writer gets a readability warning before export/publish (#318). Computed
|
|
461
611
|
// from the live overlay positions, so it clears as soon as bubbles are moved
|
|
462
612
|
// apart. Non-blocking: overlap can be intentional, so it never blocks export.
|
|
463
|
-
const overlapPairs = useMemo(
|
|
613
|
+
const overlapPairs = useMemo(
|
|
614
|
+
() => detectOverlappingOverlays(overlays),
|
|
615
|
+
[overlays],
|
|
616
|
+
);
|
|
464
617
|
|
|
465
618
|
// Re-baseline when a different cut opens without a remount (rare — the parent
|
|
466
619
|
// normally unmounts the editor between cuts).
|
|
@@ -468,7 +621,9 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
468
621
|
useEffect(() => {
|
|
469
622
|
if (baselineCutIdRef.current !== cut.id) {
|
|
470
623
|
baselineCutIdRef.current = cut.id;
|
|
471
|
-
setExportBaselineSig(
|
|
624
|
+
setExportBaselineSig(
|
|
625
|
+
overlaysSignature(overlayNormalization.overlays as Overlay[]),
|
|
626
|
+
);
|
|
472
627
|
}
|
|
473
628
|
}, [cut.id, overlayNormalization.overlays]);
|
|
474
629
|
|
|
@@ -502,7 +657,8 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
502
657
|
const outOfBounds = isOverlayOutOfBounds(o);
|
|
503
658
|
let overflow = false;
|
|
504
659
|
if (fontsReady && imageBounds.width > 0 && o.text) {
|
|
505
|
-
const fontFamily =
|
|
660
|
+
const fontFamily =
|
|
661
|
+
o.type === "sfx" ? displayFontFamily : bodyFontFamily;
|
|
506
662
|
const w = toPixel(o.width, imageBounds.width);
|
|
507
663
|
const h = toPixel(o.height, imageBounds.height);
|
|
508
664
|
const layout = layoutBubbleText(
|
|
@@ -517,8 +673,32 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
517
673
|
if (outOfBounds || overflow) out[o.id] = { outOfBounds, overflow };
|
|
518
674
|
}
|
|
519
675
|
return out;
|
|
520
|
-
}, [
|
|
676
|
+
}, [
|
|
677
|
+
overlays,
|
|
678
|
+
fontsReady,
|
|
679
|
+
imageBounds,
|
|
680
|
+
measureWidth,
|
|
681
|
+
bodyFontFamily,
|
|
682
|
+
displayFontFamily,
|
|
683
|
+
]);
|
|
521
684
|
const warningCount = Object.keys(overlayWarnings).length;
|
|
685
|
+
const checklistChips: Array<{
|
|
686
|
+
key: string;
|
|
687
|
+
label: string;
|
|
688
|
+
done: boolean;
|
|
689
|
+
}> = [
|
|
690
|
+
{ key: "clean-image", label: "Clean", done: checklist.hasCleanImage },
|
|
691
|
+
{ key: "script-text", label: "Script", done: checklist.hasScriptText },
|
|
692
|
+
{
|
|
693
|
+
key: "bubbles",
|
|
694
|
+
label: checklist.bubblesPlaced
|
|
695
|
+
? `Bubbles ${checklist.bubblesPlaced}`
|
|
696
|
+
: "Bubbles",
|
|
697
|
+
done: checklist.bubblesPlaced > 0,
|
|
698
|
+
},
|
|
699
|
+
{ key: "exported", label: "Exported", done: checklist.exported },
|
|
700
|
+
{ key: "uploaded", label: "Uploaded", done: checklist.uploaded },
|
|
701
|
+
];
|
|
522
702
|
|
|
523
703
|
const isTextPanel = cut.kind === "text";
|
|
524
704
|
const isNarrationCut = !cut.cleanImagePath;
|
|
@@ -526,7 +706,13 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
526
706
|
// A text/interstitial panel (#351) is editable on a styled background canvas
|
|
527
707
|
// even when empty, so it skips the "no clean image" guard that applies to a
|
|
528
708
|
// would-be image cut with nothing placed yet.
|
|
529
|
-
if (
|
|
709
|
+
if (
|
|
710
|
+
!isTextPanel &&
|
|
711
|
+
isNarrationCut &&
|
|
712
|
+
overlays.length === 0 &&
|
|
713
|
+
!cut.narration &&
|
|
714
|
+
!cut.dialogue?.length
|
|
715
|
+
) {
|
|
530
716
|
return (
|
|
531
717
|
<div className="h-full flex items-center justify-center text-sm text-muted">
|
|
532
718
|
No clean image — upload one first, or add overlays for a narration cut.
|
|
@@ -535,55 +721,192 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
535
721
|
}
|
|
536
722
|
|
|
537
723
|
return (
|
|
538
|
-
<div
|
|
724
|
+
<div
|
|
725
|
+
className="h-full flex flex-col"
|
|
726
|
+
data-testid="focused-lettering-editor"
|
|
727
|
+
>
|
|
539
728
|
{/* Toolbar */}
|
|
540
|
-
<div
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
729
|
+
<div
|
|
730
|
+
className="px-3 py-2 border-b border-border bg-surface/40 flex items-center justify-between gap-2 flex-wrap"
|
|
731
|
+
data-testid="lettering-toolbar"
|
|
732
|
+
>
|
|
733
|
+
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
|
|
734
|
+
<button
|
|
735
|
+
onClick={onClose}
|
|
736
|
+
className="px-2.5 py-1 text-[11px] border border-border rounded text-muted hover:text-foreground"
|
|
737
|
+
data-testid="return-to-cut-review-btn"
|
|
738
|
+
>
|
|
739
|
+
Back to cut review
|
|
740
|
+
</button>
|
|
741
|
+
<span className="rounded-full border border-accent/30 bg-accent/10 px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.16em] text-accent">
|
|
742
|
+
Focused lettering editor
|
|
743
|
+
</span>
|
|
744
|
+
<span className="text-[11px] font-mono text-muted">
|
|
745
|
+
{targetLabel ?? `Cut #${cut.id}`}
|
|
746
|
+
</span>
|
|
747
|
+
<span
|
|
748
|
+
className="rounded-full border border-border bg-background px-2 py-0.5 text-[10px] text-muted"
|
|
749
|
+
data-testid="overlay-count"
|
|
750
|
+
>
|
|
751
|
+
{overlays.length} overlays
|
|
752
|
+
</span>
|
|
753
|
+
{checklistChips.map((chip) => (
|
|
754
|
+
<span
|
|
755
|
+
key={chip.key}
|
|
756
|
+
data-testid={`lettering-check-${chip.key}`}
|
|
757
|
+
data-done={chip.done ? "true" : "false"}
|
|
758
|
+
className={`rounded-full border px-2 py-0.5 text-[10px] ${
|
|
759
|
+
chip.done
|
|
760
|
+
? "border-green-700/30 bg-green-700/10 text-green-700"
|
|
761
|
+
: "border-border bg-background text-muted"
|
|
762
|
+
}`}
|
|
763
|
+
>
|
|
764
|
+
{chip.done ? "✓ " : "○ "}
|
|
765
|
+
{chip.label}
|
|
766
|
+
</span>
|
|
767
|
+
))}
|
|
768
|
+
{cut.aiDraft?.status === "generated" && (
|
|
769
|
+
<span
|
|
770
|
+
className="rounded-full border border-accent/30 bg-accent/10 px-2 py-0.5 text-[10px] text-accent"
|
|
771
|
+
data-testid="ai-draft-current-target"
|
|
772
|
+
>
|
|
773
|
+
AI draft ready
|
|
774
|
+
</span>
|
|
775
|
+
)}
|
|
776
|
+
{staleExport && (
|
|
777
|
+
<span
|
|
778
|
+
className="rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] text-amber-700"
|
|
779
|
+
data-testid="lettering-stale-chip"
|
|
780
|
+
>
|
|
781
|
+
Re-export needed
|
|
782
|
+
</span>
|
|
783
|
+
)}
|
|
550
784
|
</div>
|
|
551
|
-
<div className="flex items-center gap-
|
|
552
|
-
|
|
553
|
-
<button
|
|
554
|
-
|
|
555
|
-
|
|
785
|
+
<div className="flex items-center gap-1.5 flex-wrap justify-end">
|
|
786
|
+
{onToggleWorkspaceVisible && (
|
|
787
|
+
<button
|
|
788
|
+
onClick={onToggleWorkspaceVisible}
|
|
789
|
+
className="px-2.5 py-1 text-[11px] border border-border rounded text-muted hover:border-accent hover:text-accent"
|
|
790
|
+
data-testid="toggle-work-area-btn"
|
|
791
|
+
>
|
|
792
|
+
{workspaceVisible ? "Hide work area" : "Show work area"}
|
|
793
|
+
</button>
|
|
794
|
+
)}
|
|
795
|
+
<button
|
|
796
|
+
type="button"
|
|
797
|
+
onClick={() => setShowHelp((prev) => !prev)}
|
|
798
|
+
className="px-2.5 py-1 text-[11px] border border-border rounded text-muted hover:border-accent hover:text-accent"
|
|
799
|
+
data-testid="lettering-help-toggle"
|
|
800
|
+
>
|
|
801
|
+
{showHelp ? "Hide help" : "Help"}
|
|
802
|
+
</button>
|
|
803
|
+
<div className="flex items-center gap-1">
|
|
804
|
+
<button
|
|
805
|
+
onClick={() => addOverlay("speech")}
|
|
806
|
+
className="px-2 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
|
|
807
|
+
data-testid="add-speech"
|
|
808
|
+
>
|
|
809
|
+
Speech
|
|
810
|
+
</button>
|
|
811
|
+
<button
|
|
812
|
+
onClick={() => addOverlay("narration")}
|
|
813
|
+
className="px-2 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
|
|
814
|
+
data-testid="add-narration"
|
|
815
|
+
>
|
|
816
|
+
Narration
|
|
817
|
+
</button>
|
|
818
|
+
<button
|
|
819
|
+
onClick={() => addOverlay("sfx")}
|
|
820
|
+
className="px-2 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
|
|
821
|
+
data-testid="add-sfx"
|
|
822
|
+
>
|
|
823
|
+
SFX
|
|
824
|
+
</button>
|
|
556
825
|
</div>
|
|
557
|
-
{exportError &&
|
|
558
|
-
|
|
559
|
-
|
|
826
|
+
{exportError && (
|
|
827
|
+
<span className="text-[10px] text-error max-w-[18rem]">
|
|
828
|
+
{exportError}
|
|
829
|
+
</span>
|
|
830
|
+
)}
|
|
831
|
+
{saveError && (
|
|
832
|
+
<span className="text-[10px] text-error max-w-[18rem]">
|
|
833
|
+
{saveError}
|
|
834
|
+
</span>
|
|
835
|
+
)}
|
|
836
|
+
<button
|
|
837
|
+
onClick={handleExport}
|
|
838
|
+
disabled={exporting}
|
|
839
|
+
className="px-2.5 py-1 text-[11px] border border-accent text-accent rounded hover:bg-accent/5 disabled:opacity-50"
|
|
840
|
+
data-testid="export-btn"
|
|
841
|
+
>
|
|
560
842
|
{exporting ? "Exporting..." : "Export"}
|
|
561
843
|
</button>
|
|
562
|
-
<button
|
|
563
|
-
|
|
844
|
+
<button
|
|
845
|
+
onClick={() => {
|
|
846
|
+
void handleSave();
|
|
847
|
+
}}
|
|
848
|
+
className="px-2.5 py-1 text-[11px] bg-accent text-white rounded hover:bg-accent-dim"
|
|
849
|
+
data-testid="save-lettering-btn"
|
|
850
|
+
>
|
|
851
|
+
Save
|
|
852
|
+
</button>
|
|
853
|
+
<button
|
|
854
|
+
onClick={onClose}
|
|
855
|
+
className="px-2.5 py-1 text-[11px] text-muted hover:text-foreground border border-border rounded"
|
|
856
|
+
data-testid="cancel-lettering-btn"
|
|
857
|
+
>
|
|
858
|
+
Cancel
|
|
859
|
+
</button>
|
|
564
860
|
</div>
|
|
565
861
|
</div>
|
|
566
862
|
|
|
863
|
+
{showHelp && (
|
|
864
|
+
<div
|
|
865
|
+
className="px-3 py-1.5 border-b border-border bg-background text-[10px] text-muted"
|
|
866
|
+
data-testid="lettering-help-panel"
|
|
867
|
+
>
|
|
868
|
+
Add or select a bubble, edit it in the inspector, then Save to return
|
|
869
|
+
to cut review. Use Export after the overlay layout is ready. Text cards
|
|
870
|
+
use narration overlays on the canvas.
|
|
871
|
+
</div>
|
|
872
|
+
)}
|
|
873
|
+
|
|
567
874
|
{invalidOverlayCount > 0 && !acknowledgedInvalid ? (
|
|
568
|
-
<div
|
|
875
|
+
<div
|
|
876
|
+
className="px-3 py-1 border-b border-border bg-error/10 text-[10px] text-error flex items-center gap-2 flex-wrap"
|
|
877
|
+
data-testid="overlay-repair-note"
|
|
878
|
+
>
|
|
569
879
|
<span>
|
|
570
|
-
{invalidOverlayCount} overlay{invalidOverlayCount === 1 ? "" : "s"}
|
|
880
|
+
{invalidOverlayCount} overlay{invalidOverlayCount === 1 ? "" : "s"}{" "}
|
|
881
|
+
from the cut plan {invalidOverlayCount === 1 ? "has" : "have"} no
|
|
882
|
+
usable position and cannot be exported. Re-place{" "}
|
|
883
|
+
{invalidOverlayCount === 1 ? "it" : "them"}, or
|
|
571
884
|
</span>
|
|
572
885
|
<button
|
|
573
886
|
onClick={() => setAcknowledgedInvalid(true)}
|
|
574
887
|
data-testid="discard-invalid-overlays"
|
|
575
888
|
className="px-1.5 py-0.5 border border-error/40 rounded hover:bg-error/10"
|
|
576
889
|
>
|
|
577
|
-
discard {invalidOverlayCount} unplaceable overlay
|
|
890
|
+
discard {invalidOverlayCount} unplaceable overlay
|
|
891
|
+
{invalidOverlayCount === 1 ? "" : "s"}
|
|
578
892
|
</button>
|
|
579
893
|
</div>
|
|
580
894
|
) : invalidOverlayCount > 0 ? (
|
|
581
|
-
<div
|
|
582
|
-
|
|
895
|
+
<div
|
|
896
|
+
className="px-3 py-1 border-b border-border bg-amber-500/10 text-[10px] text-amber-700"
|
|
897
|
+
data-testid="overlay-repair-note"
|
|
898
|
+
>
|
|
899
|
+
Discarded {invalidOverlayCount} unplaceable overlay
|
|
900
|
+
{invalidOverlayCount === 1 ? "" : "s"} — the export will not include{" "}
|
|
901
|
+
{invalidOverlayCount === 1 ? "it" : "them"}.
|
|
583
902
|
</div>
|
|
584
903
|
) : autoPlacedOverlays ? (
|
|
585
|
-
<div
|
|
586
|
-
|
|
904
|
+
<div
|
|
905
|
+
className="px-3 py-1 border-b border-border bg-amber-500/10 text-[10px] text-amber-700"
|
|
906
|
+
data-testid="overlay-repair-note"
|
|
907
|
+
>
|
|
908
|
+
Auto-placed overlays from the cut plan — review their positions before
|
|
909
|
+
exporting.
|
|
587
910
|
</div>
|
|
588
911
|
) : null}
|
|
589
912
|
|
|
@@ -592,48 +915,29 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
592
915
|
className="px-3 py-1 border-b border-border bg-amber-500/10 text-[10px] text-amber-700"
|
|
593
916
|
data-testid="overlay-overlap-warning"
|
|
594
917
|
>
|
|
595
|
-
Cut #{cut.id}: {overlapPairs.length} bubble
|
|
918
|
+
Cut #{cut.id}: {overlapPairs.length} bubble{" "}
|
|
919
|
+
{overlapPairs.length === 1 ? "pair overlaps" : "pairs overlap"} and
|
|
920
|
+
may be hard to read —{" "}
|
|
596
921
|
{overlapPairs
|
|
597
|
-
.map(
|
|
922
|
+
.map(
|
|
923
|
+
(p) =>
|
|
924
|
+
`#${p.indexA + 1} ${overlapLabel(overlays[p.indexA])} ↔ #${p.indexB + 1} ${overlapLabel(overlays[p.indexB])}`,
|
|
925
|
+
)
|
|
598
926
|
.join("; ")}
|
|
599
927
|
. Move them apart, or export as-is if the overlap is intended.
|
|
600
928
|
</div>
|
|
601
929
|
)}
|
|
602
930
|
|
|
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
931
|
{/* Stale-export warning (#336, re1): bubbles changed since the recorded
|
|
629
932
|
export/upload, so the final image/uploaded URL are out of date. The
|
|
630
|
-
|
|
933
|
+
compact toolbar chips already mark export/upload incomplete; this says why. */}
|
|
631
934
|
{staleExport && (
|
|
632
935
|
<div
|
|
633
936
|
className="px-3 py-1 border-b border-border bg-amber-500/10 text-[10px] text-amber-700"
|
|
634
937
|
data-testid="lettering-stale-export-warning"
|
|
635
938
|
>
|
|
636
|
-
Bubbles changed since the last export — re-export this cut and upload
|
|
939
|
+
Bubbles changed since the last export — re-export this cut and upload
|
|
940
|
+
the new final image before publishing.
|
|
637
941
|
</div>
|
|
638
942
|
)}
|
|
639
943
|
|
|
@@ -644,11 +948,15 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
644
948
|
className="px-3 py-1 border-b border-border bg-amber-500/10 text-[10px] text-amber-700"
|
|
645
949
|
data-testid="lettering-export-warning"
|
|
646
950
|
>
|
|
647
|
-
{warningCount} bubble{warningCount === 1 ? "" : "s"} may not export
|
|
951
|
+
{warningCount} bubble{warningCount === 1 ? "" : "s"} may not export
|
|
952
|
+
cleanly:{" "}
|
|
648
953
|
{Object.entries(overlayWarnings)
|
|
649
954
|
.map(([id, w]) => {
|
|
650
955
|
const idx = overlays.findIndex((o) => o.id === id);
|
|
651
|
-
const problems = [
|
|
956
|
+
const problems = [
|
|
957
|
+
w.outOfBounds ? "outside image" : null,
|
|
958
|
+
w.overflow ? "text overflow" : null,
|
|
959
|
+
]
|
|
652
960
|
.filter(Boolean)
|
|
653
961
|
.join(", ");
|
|
654
962
|
return `#${idx + 1} ${overlapLabel(overlays[idx])} (${problems})`;
|
|
@@ -667,11 +975,17 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
667
975
|
data-testid="editor-surface"
|
|
668
976
|
>
|
|
669
977
|
{cut.cleanImagePath && cleanAsset.error ? (
|
|
670
|
-
<div
|
|
978
|
+
<div
|
|
979
|
+
className="w-full h-full flex items-center justify-center text-muted text-xs"
|
|
980
|
+
data-testid="clean-image-error"
|
|
981
|
+
>
|
|
671
982
|
Clean image not available
|
|
672
983
|
</div>
|
|
673
984
|
) : cut.cleanImagePath && !cleanAsset.url ? (
|
|
674
|
-
<div
|
|
985
|
+
<div
|
|
986
|
+
className="w-full h-full flex items-center justify-center text-muted text-xs"
|
|
987
|
+
data-testid="clean-image-loading"
|
|
988
|
+
>
|
|
675
989
|
Loading clean image…
|
|
676
990
|
</div>
|
|
677
991
|
) : cut.cleanImagePath ? (
|
|
@@ -708,7 +1022,12 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
708
1022
|
if (el && imageBounds.width === 0) {
|
|
709
1023
|
const rect = el.getBoundingClientRect();
|
|
710
1024
|
if (rect.width > 0) {
|
|
711
|
-
setImageBounds({
|
|
1025
|
+
setImageBounds({
|
|
1026
|
+
x: 0,
|
|
1027
|
+
y: 0,
|
|
1028
|
+
width: rect.width,
|
|
1029
|
+
height: rect.height,
|
|
1030
|
+
});
|
|
712
1031
|
}
|
|
713
1032
|
}
|
|
714
1033
|
}}
|
|
@@ -725,15 +1044,22 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
725
1044
|
Tailless speech (no tailAnchor, or a tip inside the bubble) traces
|
|
726
1045
|
a plain rounded rectangle. Tail-anchor edits update the path live. */}
|
|
727
1046
|
{imageBounds.width > 0 && (
|
|
728
|
-
<svg
|
|
1047
|
+
<svg
|
|
1048
|
+
className="absolute inset-0 w-full h-full pointer-events-none"
|
|
1049
|
+
data-testid="balloon-layer"
|
|
1050
|
+
>
|
|
729
1051
|
{overlays.map((overlay) => {
|
|
730
1052
|
if (overlay.type !== "speech") return null;
|
|
731
|
-
const ox =
|
|
732
|
-
|
|
1053
|
+
const ox =
|
|
1054
|
+
imageBounds.x + toPixel(overlay.x, imageBounds.width);
|
|
1055
|
+
const oy =
|
|
1056
|
+
imageBounds.y + toPixel(overlay.y, imageBounds.height);
|
|
733
1057
|
const ow = toPixel(overlay.width, imageBounds.width);
|
|
734
1058
|
const oh = toPixel(overlay.height, imageBounds.height);
|
|
735
1059
|
const radius = balloonRadiusForOverlay(overlay, ow, oh);
|
|
736
|
-
const tail = overlay.tailAnchor
|
|
1060
|
+
const tail = overlay.tailAnchor
|
|
1061
|
+
? speechTailPoints(ox, oy, ow, oh, overlay.tailAnchor, radius)
|
|
1062
|
+
: null;
|
|
737
1063
|
// Strong, clean near-black outline scaled to the preview size so
|
|
738
1064
|
// the bubble reads as a webtoon balloon (matching the export's
|
|
739
1065
|
// proportional stroke), not a faint UI box (#363).
|
|
@@ -753,159 +1079,166 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
753
1079
|
</svg>
|
|
754
1080
|
)}
|
|
755
1081
|
|
|
756
|
-
{imageBounds.width > 0 &&
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
1082
|
+
{imageBounds.width > 0 &&
|
|
1083
|
+
overlays.map((overlay) => {
|
|
1084
|
+
const left =
|
|
1085
|
+
imageBounds.x + toPixel(overlay.x, imageBounds.width);
|
|
1086
|
+
const top =
|
|
1087
|
+
imageBounds.y + toPixel(overlay.y, imageBounds.height);
|
|
1088
|
+
const width = toPixel(overlay.width, imageBounds.width);
|
|
1089
|
+
const height = toPixel(overlay.height, imageBounds.height);
|
|
1090
|
+
const isSelected = overlay.id === selectedId;
|
|
1091
|
+
// Speech bubbles draw no body border here — the integrated balloon
|
|
1092
|
+
// <path> in the layer below is their outline, so a box border would
|
|
1093
|
+
// re-introduce the body/tail seam (#327). Their selection cue is the
|
|
1094
|
+
// path's accent stroke (plus the resize handle). Narration/SFX keep
|
|
1095
|
+
// their bordered box + selection ring as before.
|
|
1096
|
+
const isSpeech = overlay.type === "speech";
|
|
1097
|
+
// Narration reads as an intentional parchment caption card (rounded,
|
|
1098
|
+
// filled), mirroring the export, instead of an empty bordered box (#363).
|
|
1099
|
+
const isNarration = overlay.type === "narration";
|
|
1100
|
+
const warned = !!overlayWarnings[overlay.id];
|
|
1101
|
+
|
|
1102
|
+
return (
|
|
1103
|
+
<div
|
|
1104
|
+
key={overlay.id}
|
|
1105
|
+
data-testid={`overlay-${overlay.id}`}
|
|
1106
|
+
data-warning={warned ? "true" : "false"}
|
|
1107
|
+
onClick={(e) => handleOverlayClick(e, overlay.id)}
|
|
1108
|
+
onMouseDown={(e) => handleMouseDown(e, overlay.id, "move")}
|
|
1109
|
+
className={`absolute rounded cursor-move select-none ${
|
|
1110
|
+
isSpeech ? "" : `border-2 ${TYPE_BORDER[overlay.type]}`
|
|
1111
|
+
} ${isNarration ? "bg-[#f4efe6]/85 rounded-md" : ""} ${
|
|
1112
|
+
isSelected && !isSpeech ? "ring-2 ring-accent" : ""
|
|
1113
|
+
} ${warned ? "ring-2 ring-amber-500" : ""}`}
|
|
1114
|
+
style={{ left, top, width, height }}
|
|
1115
|
+
>
|
|
1116
|
+
{(() => {
|
|
1117
|
+
const fontFamily =
|
|
1118
|
+
overlay.type === "sfx"
|
|
1119
|
+
? displayFontFamily
|
|
1120
|
+
: bodyFontFamily;
|
|
1121
|
+
if (!overlay.text) {
|
|
1122
|
+
return (
|
|
1123
|
+
<span
|
|
1124
|
+
className="text-[9px] px-1 text-muted truncate block pointer-events-none"
|
|
1125
|
+
style={{ fontFamily }}
|
|
1126
|
+
>
|
|
1127
|
+
{TYPE_LABEL[overlay.type]}
|
|
1128
|
+
</span>
|
|
1129
|
+
);
|
|
1130
|
+
}
|
|
1131
|
+
const hasSpeaker =
|
|
1132
|
+
overlay.type !== "sfx" && !!overlay.speaker;
|
|
1133
|
+
if (!fontsReady) {
|
|
1134
|
+
// Until the web font's metrics are available, don't freeze
|
|
1135
|
+
// canvas-measured line breaks from fallback metrics (they
|
|
1136
|
+
// would diverge from export). Show a CSS-wrapped transient;
|
|
1137
|
+
// the exact layout computes once fonts are ready (#310, re1).
|
|
1138
|
+
return (
|
|
1139
|
+
<div
|
|
1140
|
+
className="absolute inset-0 flex items-center justify-center px-1 overflow-hidden pointer-events-none text-center break-words"
|
|
1141
|
+
style={{
|
|
1142
|
+
fontFamily,
|
|
1143
|
+
fontSize: Math.max(9, Math.min(height * 0.05, 16)),
|
|
1144
|
+
fontWeight: overlay.textStyle?.fontWeight ?? 400,
|
|
1145
|
+
}}
|
|
1146
|
+
data-testid={`overlay-text-${overlay.id}`}
|
|
1147
|
+
data-fonts-ready="false"
|
|
1148
|
+
>
|
|
1149
|
+
{hasSpeaker
|
|
1150
|
+
? `${overlay.speaker}: ${overlay.text}`
|
|
1151
|
+
: overlay.text}
|
|
1152
|
+
</div>
|
|
1153
|
+
);
|
|
1154
|
+
}
|
|
1155
|
+
const layout = layoutBubbleText(
|
|
1156
|
+
measureWidth(fontFamily),
|
|
1157
|
+
overlay.text,
|
|
1158
|
+
width,
|
|
1159
|
+
height,
|
|
1160
|
+
bubbleLayoutOptionsForOverlay(
|
|
1161
|
+
overlay,
|
|
1162
|
+
imageBounds.height,
|
|
1163
|
+
width,
|
|
1164
|
+
height,
|
|
1165
|
+
),
|
|
794
1166
|
);
|
|
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
1167
|
return (
|
|
803
1168
|
<div
|
|
804
|
-
className="absolute inset-0 flex items-center justify-center px-1 overflow-hidden pointer-events-none text-center
|
|
805
|
-
style={{
|
|
806
|
-
fontFamily,
|
|
807
|
-
fontSize: Math.max(9, Math.min(height * 0.05, 16)),
|
|
808
|
-
fontWeight: overlay.textStyle?.fontWeight ?? 400,
|
|
809
|
-
}}
|
|
1169
|
+
className="absolute inset-0 flex flex-col items-center justify-center px-1 overflow-hidden pointer-events-none text-center"
|
|
1170
|
+
style={{ fontFamily }}
|
|
810
1171
|
data-testid={`overlay-text-${overlay.id}`}
|
|
811
|
-
data-fonts-ready="
|
|
1172
|
+
data-fonts-ready="true"
|
|
812
1173
|
>
|
|
813
|
-
{hasSpeaker
|
|
1174
|
+
{hasSpeaker && (
|
|
1175
|
+
<span
|
|
1176
|
+
className="font-bold text-[#3a3a3a] block"
|
|
1177
|
+
style={{
|
|
1178
|
+
fontSize: layout.speakerFontSize,
|
|
1179
|
+
lineHeight: 1.2,
|
|
1180
|
+
}}
|
|
1181
|
+
>
|
|
1182
|
+
{overlay.speaker}
|
|
1183
|
+
</span>
|
|
1184
|
+
)}
|
|
1185
|
+
<span
|
|
1186
|
+
className="text-[#1a1a1a]"
|
|
1187
|
+
style={{
|
|
1188
|
+
fontSize: layout.fontSize,
|
|
1189
|
+
lineHeight: `${layout.lineHeight}px`,
|
|
1190
|
+
fontWeight: overlay.textStyle?.fontWeight ?? 400,
|
|
1191
|
+
}}
|
|
1192
|
+
>
|
|
1193
|
+
{layout.lines.map((line, i) => (
|
|
1194
|
+
<span key={i} className="block">
|
|
1195
|
+
{line}
|
|
1196
|
+
</span>
|
|
1197
|
+
))}
|
|
1198
|
+
</span>
|
|
814
1199
|
</div>
|
|
815
1200
|
);
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
measureWidth(fontFamily),
|
|
819
|
-
overlay.text,
|
|
820
|
-
width,
|
|
821
|
-
height,
|
|
822
|
-
bubbleLayoutOptionsForOverlay(overlay, imageBounds.height, width, height),
|
|
823
|
-
);
|
|
824
|
-
return (
|
|
1201
|
+
})()}
|
|
1202
|
+
{isSelected && (
|
|
825
1203
|
<div
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
{
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
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
|
-
})}
|
|
1204
|
+
onMouseDown={(e) => {
|
|
1205
|
+
e.stopPropagation();
|
|
1206
|
+
handleMouseDown(e, overlay.id, "resize");
|
|
1207
|
+
}}
|
|
1208
|
+
className="absolute bottom-0 right-0 w-2 h-2 bg-accent cursor-se-resize"
|
|
1209
|
+
data-testid={`resize-${overlay.id}`}
|
|
1210
|
+
/>
|
|
1211
|
+
)}
|
|
1212
|
+
</div>
|
|
1213
|
+
);
|
|
1214
|
+
})}
|
|
857
1215
|
</div>
|
|
858
1216
|
|
|
859
1217
|
{/* Inspector panel */}
|
|
860
1218
|
<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
1219
|
{selectedOverlay ? (
|
|
900
1220
|
<div className="space-y-3">
|
|
901
|
-
<
|
|
1221
|
+
<div className="flex items-center justify-between gap-2">
|
|
1222
|
+
<p className="text-xs font-medium text-foreground">
|
|
1223
|
+
{TYPE_LABEL[selectedOverlay.type]}
|
|
1224
|
+
</p>
|
|
1225
|
+
<span className="text-[10px] text-muted">
|
|
1226
|
+
#{overlays.findIndex((o) => o.id === selectedOverlay.id) + 1}
|
|
1227
|
+
</span>
|
|
1228
|
+
</div>
|
|
902
1229
|
|
|
903
1230
|
{selectedOverlay.speaker !== undefined && (
|
|
904
1231
|
<label className="block space-y-1">
|
|
905
|
-
<span className="text-[10px] font-medium text-muted">
|
|
1232
|
+
<span className="text-[10px] font-medium text-muted">
|
|
1233
|
+
Speaker
|
|
1234
|
+
</span>
|
|
906
1235
|
<input
|
|
907
1236
|
value={selectedOverlay.speaker || ""}
|
|
908
|
-
onChange={(e) =>
|
|
1237
|
+
onChange={(e) =>
|
|
1238
|
+
updateOverlay(selectedOverlay.id, {
|
|
1239
|
+
speaker: e.target.value,
|
|
1240
|
+
})
|
|
1241
|
+
}
|
|
909
1242
|
className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
910
1243
|
placeholder="Character name"
|
|
911
1244
|
data-testid="inspector-speaker"
|
|
@@ -917,7 +1250,9 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
917
1250
|
<span className="text-[10px] font-medium text-muted">Text</span>
|
|
918
1251
|
<textarea
|
|
919
1252
|
value={selectedOverlay.text}
|
|
920
|
-
onChange={(e) =>
|
|
1253
|
+
onChange={(e) =>
|
|
1254
|
+
updateOverlay(selectedOverlay.id, { text: e.target.value })
|
|
1255
|
+
}
|
|
921
1256
|
rows={3}
|
|
922
1257
|
className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent resize-none focus:border-accent focus:outline-none"
|
|
923
1258
|
placeholder="Overlay text"
|
|
@@ -928,7 +1263,9 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
928
1263
|
{/* One-click resize so a long line that overflows can be fitted
|
|
929
1264
|
without hand-dragging the box (#452). */}
|
|
930
1265
|
<button
|
|
931
|
-
onClick={() =>
|
|
1266
|
+
onClick={() =>
|
|
1267
|
+
updateOverlay(selectedOverlay.id, fittedSize(selectedOverlay))
|
|
1268
|
+
}
|
|
932
1269
|
data-testid="inspector-fit-text"
|
|
933
1270
|
className="w-full px-2 py-1 text-[11px] border border-border rounded hover:border-accent hover:text-accent"
|
|
934
1271
|
title="Resize this overlay so its text fits without overflowing"
|
|
@@ -936,13 +1273,22 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
936
1273
|
Fit box to text
|
|
937
1274
|
</button>
|
|
938
1275
|
|
|
939
|
-
<div
|
|
1276
|
+
<div
|
|
1277
|
+
className="space-y-1.5 rounded border border-border/70 p-2"
|
|
1278
|
+
data-testid="inspector-typography"
|
|
1279
|
+
>
|
|
940
1280
|
<div className="flex items-center justify-between gap-2">
|
|
941
|
-
<span className="text-[10px] font-medium text-muted">
|
|
1281
|
+
<span className="text-[10px] font-medium text-muted">
|
|
1282
|
+
Typography
|
|
1283
|
+
</span>
|
|
942
1284
|
{selectedOverlay.textStyle?.mode === "manual" ? (
|
|
943
1285
|
<button
|
|
944
1286
|
type="button"
|
|
945
|
-
onClick={() =>
|
|
1287
|
+
onClick={() =>
|
|
1288
|
+
updateOverlay(selectedOverlay.id, {
|
|
1289
|
+
textStyle: undefined,
|
|
1290
|
+
})
|
|
1291
|
+
}
|
|
946
1292
|
className="px-1.5 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
|
|
947
1293
|
data-testid="inspector-text-auto"
|
|
948
1294
|
>
|
|
@@ -962,20 +1308,32 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
962
1308
|
{selectedOverlay.textStyle?.mode === "manual" ? (
|
|
963
1309
|
<div className="space-y-1.5">
|
|
964
1310
|
<label className="block space-y-1">
|
|
965
|
-
<span className="text-[10px] text-muted">
|
|
1311
|
+
<span className="text-[10px] text-muted">
|
|
1312
|
+
Font size (% panel height)
|
|
1313
|
+
</span>
|
|
966
1314
|
<input
|
|
967
1315
|
type="number"
|
|
968
1316
|
step="0.1"
|
|
969
1317
|
min="1.5"
|
|
970
1318
|
max="12"
|
|
971
|
-
value={(
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
1319
|
+
value={(
|
|
1320
|
+
(selectedOverlay.textStyle.fontScale ?? 0.032) * 100
|
|
1321
|
+
).toFixed(1)}
|
|
1322
|
+
onChange={(e) =>
|
|
1323
|
+
updateOverlay(selectedOverlay.id, {
|
|
1324
|
+
textStyle: {
|
|
1325
|
+
...selectedOverlay.textStyle,
|
|
1326
|
+
mode: "manual",
|
|
1327
|
+
fontScale: Math.max(
|
|
1328
|
+
0.015,
|
|
1329
|
+
Math.min(
|
|
1330
|
+
0.12,
|
|
1331
|
+
(parseFloat(e.target.value) || 3.2) / 100,
|
|
1332
|
+
),
|
|
1333
|
+
),
|
|
1334
|
+
},
|
|
1335
|
+
})
|
|
1336
|
+
}
|
|
979
1337
|
className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
980
1338
|
data-testid="inspector-font-scale"
|
|
981
1339
|
/>
|
|
@@ -983,14 +1341,18 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
983
1341
|
<label className="block space-y-1">
|
|
984
1342
|
<span className="text-[10px] text-muted">Weight</span>
|
|
985
1343
|
<select
|
|
986
|
-
value={String(
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
1344
|
+
value={String(
|
|
1345
|
+
selectedOverlay.textStyle.fontWeight ?? 400,
|
|
1346
|
+
)}
|
|
1347
|
+
onChange={(e) =>
|
|
1348
|
+
updateOverlay(selectedOverlay.id, {
|
|
1349
|
+
textStyle: {
|
|
1350
|
+
...selectedOverlay.textStyle,
|
|
1351
|
+
mode: "manual",
|
|
1352
|
+
fontWeight: e.target.value === "700" ? 700 : 400,
|
|
1353
|
+
},
|
|
1354
|
+
})
|
|
1355
|
+
}
|
|
994
1356
|
className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
995
1357
|
data-testid="inspector-font-weight"
|
|
996
1358
|
>
|
|
@@ -999,40 +1361,61 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
999
1361
|
</select>
|
|
1000
1362
|
</label>
|
|
1001
1363
|
<label className="block space-y-1">
|
|
1002
|
-
<span className="text-[10px] text-muted">
|
|
1364
|
+
<span className="text-[10px] text-muted">
|
|
1365
|
+
Line height
|
|
1366
|
+
</span>
|
|
1003
1367
|
<input
|
|
1004
1368
|
type="number"
|
|
1005
1369
|
step="0.05"
|
|
1006
1370
|
min="0.9"
|
|
1007
1371
|
max="2"
|
|
1008
|
-
value={(
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1372
|
+
value={(
|
|
1373
|
+
selectedOverlay.textStyle.lineHeightFactor ?? 1.2
|
|
1374
|
+
).toFixed(2)}
|
|
1375
|
+
onChange={(e) =>
|
|
1376
|
+
updateOverlay(selectedOverlay.id, {
|
|
1377
|
+
textStyle: {
|
|
1378
|
+
...selectedOverlay.textStyle,
|
|
1379
|
+
mode: "manual",
|
|
1380
|
+
lineHeightFactor: Math.max(
|
|
1381
|
+
0.9,
|
|
1382
|
+
Math.min(2, parseFloat(e.target.value) || 1.2),
|
|
1383
|
+
),
|
|
1384
|
+
},
|
|
1385
|
+
})
|
|
1386
|
+
}
|
|
1016
1387
|
className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
1017
1388
|
data-testid="inspector-line-height"
|
|
1018
1389
|
/>
|
|
1019
1390
|
</label>
|
|
1020
1391
|
{selectedOverlay.type !== "sfx" && (
|
|
1021
1392
|
<label className="block space-y-1">
|
|
1022
|
-
<span className="text-[10px] text-muted">
|
|
1393
|
+
<span className="text-[10px] text-muted">
|
|
1394
|
+
Speaker scale
|
|
1395
|
+
</span>
|
|
1023
1396
|
<input
|
|
1024
1397
|
type="number"
|
|
1025
1398
|
step="0.05"
|
|
1026
1399
|
min="0.5"
|
|
1027
1400
|
max="1.5"
|
|
1028
|
-
value={(
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1401
|
+
value={(
|
|
1402
|
+
selectedOverlay.textStyle.speakerScale ?? 0.8
|
|
1403
|
+
).toFixed(2)}
|
|
1404
|
+
onChange={(e) =>
|
|
1405
|
+
updateOverlay(selectedOverlay.id, {
|
|
1406
|
+
textStyle: {
|
|
1407
|
+
...selectedOverlay.textStyle,
|
|
1408
|
+
mode: "manual",
|
|
1409
|
+
speakerScale: Math.max(
|
|
1410
|
+
0.5,
|
|
1411
|
+
Math.min(
|
|
1412
|
+
1.5,
|
|
1413
|
+
parseFloat(e.target.value) || 0.8,
|
|
1414
|
+
),
|
|
1415
|
+
),
|
|
1416
|
+
},
|
|
1417
|
+
})
|
|
1418
|
+
}
|
|
1036
1419
|
className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
1037
1420
|
data-testid="inspector-speaker-scale"
|
|
1038
1421
|
/>
|
|
@@ -1040,109 +1423,176 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
1040
1423
|
)}
|
|
1041
1424
|
</div>
|
|
1042
1425
|
) : (
|
|
1043
|
-
<p className="text-[10px] text-muted">
|
|
1426
|
+
<p className="text-[10px] text-muted">
|
|
1427
|
+
Auto-fit stays on by default and resizes text to the box.
|
|
1428
|
+
</p>
|
|
1044
1429
|
)}
|
|
1045
1430
|
</div>
|
|
1046
1431
|
|
|
1047
|
-
{selectedOverlay.type === "speech" &&
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
<
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1432
|
+
{selectedOverlay.type === "speech" &&
|
|
1433
|
+
(() => {
|
|
1434
|
+
const tail = selectedOverlay.tailAnchor || { x: 0.5, y: 1.2 };
|
|
1435
|
+
return (
|
|
1436
|
+
<div className="space-y-1">
|
|
1437
|
+
<span className="text-[10px] font-medium text-muted">
|
|
1438
|
+
Tail anchor
|
|
1439
|
+
</span>
|
|
1440
|
+
<div
|
|
1441
|
+
className="flex flex-wrap gap-1"
|
|
1442
|
+
data-testid="inspector-tail-presets"
|
|
1443
|
+
>
|
|
1444
|
+
{TAIL_PRESETS.map((preset) => (
|
|
1445
|
+
<button
|
|
1446
|
+
key={preset.key}
|
|
1447
|
+
type="button"
|
|
1448
|
+
onClick={() =>
|
|
1449
|
+
updateOverlay(selectedOverlay.id, {
|
|
1450
|
+
tailAnchor: preset.anchor,
|
|
1451
|
+
})
|
|
1452
|
+
}
|
|
1453
|
+
className="px-1.5 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
|
|
1454
|
+
data-testid={`inspector-tail-${preset.key}`}
|
|
1455
|
+
>
|
|
1456
|
+
{preset.label}
|
|
1457
|
+
</button>
|
|
1458
|
+
))}
|
|
1459
|
+
</div>
|
|
1460
|
+
<div className="flex gap-2">
|
|
1461
|
+
<label className="flex items-center gap-1 text-[10px] font-mono text-muted">
|
|
1462
|
+
x
|
|
1463
|
+
<input
|
|
1464
|
+
type="number"
|
|
1465
|
+
step="0.1"
|
|
1466
|
+
value={tail.x}
|
|
1467
|
+
onChange={(e) =>
|
|
1468
|
+
updateOverlay(selectedOverlay.id, {
|
|
1469
|
+
tailAnchor: {
|
|
1470
|
+
...tail,
|
|
1471
|
+
x: parseFloat(e.target.value) || 0,
|
|
1472
|
+
},
|
|
1473
|
+
})
|
|
1474
|
+
}
|
|
1475
|
+
className="w-14 px-1 py-0.5 text-[10px] border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
1476
|
+
data-testid="inspector-tail-x"
|
|
1477
|
+
/>
|
|
1478
|
+
</label>
|
|
1479
|
+
<label className="flex items-center gap-1 text-[10px] font-mono text-muted">
|
|
1480
|
+
y
|
|
1481
|
+
<input
|
|
1482
|
+
type="number"
|
|
1483
|
+
step="0.1"
|
|
1484
|
+
value={tail.y}
|
|
1485
|
+
onChange={(e) =>
|
|
1486
|
+
updateOverlay(selectedOverlay.id, {
|
|
1487
|
+
tailAnchor: {
|
|
1488
|
+
...tail,
|
|
1489
|
+
y: parseFloat(e.target.value) || 0,
|
|
1490
|
+
},
|
|
1491
|
+
})
|
|
1492
|
+
}
|
|
1493
|
+
className="w-14 px-1 py-0.5 text-[10px] border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
1494
|
+
data-testid="inspector-tail-y"
|
|
1495
|
+
/>
|
|
1496
|
+
</label>
|
|
1497
|
+
</div>
|
|
1088
1498
|
</div>
|
|
1089
|
-
|
|
1090
|
-
)
|
|
1091
|
-
})()}
|
|
1499
|
+
);
|
|
1500
|
+
})()}
|
|
1092
1501
|
|
|
1093
1502
|
{selectedOverlay.type !== "sfx" && (
|
|
1094
|
-
<div
|
|
1095
|
-
|
|
1503
|
+
<div
|
|
1504
|
+
className="space-y-1.5 rounded border border-border/70 p-2"
|
|
1505
|
+
data-testid="inspector-bubble-style"
|
|
1506
|
+
>
|
|
1507
|
+
<span className="text-[10px] font-medium text-muted">
|
|
1508
|
+
Bubble controls
|
|
1509
|
+
</span>
|
|
1096
1510
|
<label className="block space-y-1">
|
|
1097
|
-
<span className="text-[10px] text-muted">
|
|
1511
|
+
<span className="text-[10px] text-muted">
|
|
1512
|
+
Padding X (% width)
|
|
1513
|
+
</span>
|
|
1098
1514
|
<input
|
|
1099
1515
|
type="number"
|
|
1100
1516
|
step="1"
|
|
1101
1517
|
min="0"
|
|
1102
1518
|
max="25"
|
|
1103
|
-
value={(
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1519
|
+
value={(
|
|
1520
|
+
(selectedOverlay.bubbleStyle?.paddingX ?? 0.06) * 100
|
|
1521
|
+
).toFixed(0)}
|
|
1522
|
+
onChange={(e) =>
|
|
1523
|
+
updateOverlay(selectedOverlay.id, {
|
|
1524
|
+
bubbleStyle: {
|
|
1525
|
+
...selectedOverlay.bubbleStyle,
|
|
1526
|
+
paddingX: Math.max(
|
|
1527
|
+
0,
|
|
1528
|
+
Math.min(
|
|
1529
|
+
0.25,
|
|
1530
|
+
(parseFloat(e.target.value) || 6) / 100,
|
|
1531
|
+
),
|
|
1532
|
+
),
|
|
1533
|
+
},
|
|
1534
|
+
})
|
|
1535
|
+
}
|
|
1110
1536
|
className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
1111
1537
|
data-testid="inspector-padding-x"
|
|
1112
1538
|
/>
|
|
1113
1539
|
</label>
|
|
1114
1540
|
<label className="block space-y-1">
|
|
1115
|
-
<span className="text-[10px] text-muted">
|
|
1541
|
+
<span className="text-[10px] text-muted">
|
|
1542
|
+
Padding Y (% height)
|
|
1543
|
+
</span>
|
|
1116
1544
|
<input
|
|
1117
1545
|
type="number"
|
|
1118
1546
|
step="1"
|
|
1119
1547
|
min="0"
|
|
1120
1548
|
max="25"
|
|
1121
|
-
value={(
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1549
|
+
value={(
|
|
1550
|
+
(selectedOverlay.bubbleStyle?.paddingY ?? 0.08) * 100
|
|
1551
|
+
).toFixed(0)}
|
|
1552
|
+
onChange={(e) =>
|
|
1553
|
+
updateOverlay(selectedOverlay.id, {
|
|
1554
|
+
bubbleStyle: {
|
|
1555
|
+
...selectedOverlay.bubbleStyle,
|
|
1556
|
+
paddingY: Math.max(
|
|
1557
|
+
0,
|
|
1558
|
+
Math.min(
|
|
1559
|
+
0.25,
|
|
1560
|
+
(parseFloat(e.target.value) || 8) / 100,
|
|
1561
|
+
),
|
|
1562
|
+
),
|
|
1563
|
+
},
|
|
1564
|
+
})
|
|
1565
|
+
}
|
|
1128
1566
|
className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
1129
1567
|
data-testid="inspector-padding-y"
|
|
1130
1568
|
/>
|
|
1131
1569
|
</label>
|
|
1132
1570
|
<label className="block space-y-1">
|
|
1133
|
-
<span className="text-[10px] text-muted">
|
|
1571
|
+
<span className="text-[10px] text-muted">
|
|
1572
|
+
Corner roundness (% short side)
|
|
1573
|
+
</span>
|
|
1134
1574
|
<input
|
|
1135
1575
|
type="number"
|
|
1136
1576
|
step="1"
|
|
1137
1577
|
min="0"
|
|
1138
1578
|
max="49"
|
|
1139
|
-
value={(
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1579
|
+
value={(
|
|
1580
|
+
(selectedOverlay.bubbleStyle?.cornerRadius ?? 0.4) * 100
|
|
1581
|
+
).toFixed(0)}
|
|
1582
|
+
onChange={(e) =>
|
|
1583
|
+
updateOverlay(selectedOverlay.id, {
|
|
1584
|
+
bubbleStyle: {
|
|
1585
|
+
...selectedOverlay.bubbleStyle,
|
|
1586
|
+
cornerRadius: Math.max(
|
|
1587
|
+
0,
|
|
1588
|
+
Math.min(
|
|
1589
|
+
0.49,
|
|
1590
|
+
(parseFloat(e.target.value) || 40) / 100,
|
|
1591
|
+
),
|
|
1592
|
+
),
|
|
1593
|
+
},
|
|
1594
|
+
})
|
|
1595
|
+
}
|
|
1146
1596
|
className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
1147
1597
|
data-testid="inspector-corner-radius"
|
|
1148
1598
|
/>
|
|
@@ -1150,13 +1600,25 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
1150
1600
|
</div>
|
|
1151
1601
|
)}
|
|
1152
1602
|
|
|
1153
|
-
<div
|
|
1154
|
-
|
|
1603
|
+
<div
|
|
1604
|
+
className="text-[10px] text-muted"
|
|
1605
|
+
data-testid="inspector-font"
|
|
1606
|
+
>
|
|
1607
|
+
Font:{" "}
|
|
1608
|
+
{selectedOverlay.type === "sfx"
|
|
1609
|
+
? displayFont.family
|
|
1610
|
+
: bodyFont.family}
|
|
1155
1611
|
</div>
|
|
1156
1612
|
|
|
1157
1613
|
<div className="text-[10px] font-mono text-muted space-y-0.5">
|
|
1158
|
-
<p>
|
|
1159
|
-
|
|
1614
|
+
<p>
|
|
1615
|
+
x: {selectedOverlay.x.toFixed(3)}, y:{" "}
|
|
1616
|
+
{selectedOverlay.y.toFixed(3)}
|
|
1617
|
+
</p>
|
|
1618
|
+
<p>
|
|
1619
|
+
w: {selectedOverlay.width.toFixed(3)}, h:{" "}
|
|
1620
|
+
{selectedOverlay.height.toFixed(3)}
|
|
1621
|
+
</p>
|
|
1160
1622
|
</div>
|
|
1161
1623
|
|
|
1162
1624
|
<button
|
|
@@ -1169,11 +1631,80 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
1169
1631
|
>
|
|
1170
1632
|
{confirmDelete ? "Click again to delete" : "Delete"}
|
|
1171
1633
|
</button>
|
|
1634
|
+
|
|
1635
|
+
{scriptLines.length > 0 && (
|
|
1636
|
+
<div
|
|
1637
|
+
className="space-y-1.5 border-t border-border pt-3"
|
|
1638
|
+
data-testid="script-insert-panel"
|
|
1639
|
+
>
|
|
1640
|
+
<span className="text-[10px] font-medium text-muted">
|
|
1641
|
+
Add from script
|
|
1642
|
+
</span>
|
|
1643
|
+
<div className="flex flex-col gap-1">
|
|
1644
|
+
{scriptLines.map((line) => (
|
|
1645
|
+
<button
|
|
1646
|
+
key={line.key}
|
|
1647
|
+
onClick={() => addScriptLine(line)}
|
|
1648
|
+
data-testid={`script-insert-${line.key}`}
|
|
1649
|
+
title={`Add ${line.type} overlay with this text`}
|
|
1650
|
+
className="text-left px-2 py-1 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
|
|
1651
|
+
>
|
|
1652
|
+
<span className="font-medium text-accent">
|
|
1653
|
+
+ {TYPE_LABEL[line.type]}
|
|
1654
|
+
</span>{" "}
|
|
1655
|
+
<span className="text-muted">
|
|
1656
|
+
{line.speaker ? `${line.speaker}: ` : ""}
|
|
1657
|
+
{line.text.length > 32
|
|
1658
|
+
? `${line.text.slice(0, 32)}…`
|
|
1659
|
+
: line.text}
|
|
1660
|
+
</span>
|
|
1661
|
+
</button>
|
|
1662
|
+
))}
|
|
1663
|
+
</div>
|
|
1664
|
+
</div>
|
|
1665
|
+
)}
|
|
1172
1666
|
</div>
|
|
1173
1667
|
) : (
|
|
1174
|
-
<
|
|
1175
|
-
|
|
1176
|
-
|
|
1668
|
+
<div className="space-y-3">
|
|
1669
|
+
<p className="text-xs text-muted" data-testid="inspector-empty">
|
|
1670
|
+
Select or add an overlay to inspect it.
|
|
1671
|
+
</p>
|
|
1672
|
+
{cut.aiDraft?.status === "generated" && (
|
|
1673
|
+
<div className="rounded border border-accent/30 bg-accent/5 p-2 text-[10px] text-muted">
|
|
1674
|
+
AI drafted overlays are editable here before export.
|
|
1675
|
+
</div>
|
|
1676
|
+
)}
|
|
1677
|
+
{/* Insert-from-script (#336): drop the cut's planned dialogue/narration/
|
|
1678
|
+
SFX straight into a prefilled overlay — no copy/paste out of JSON. */}
|
|
1679
|
+
{scriptLines.length > 0 && (
|
|
1680
|
+
<div className="space-y-1.5" data-testid="script-insert-panel">
|
|
1681
|
+
<span className="text-[10px] font-medium text-muted">
|
|
1682
|
+
Add from script
|
|
1683
|
+
</span>
|
|
1684
|
+
<div className="flex flex-col gap-1">
|
|
1685
|
+
{scriptLines.map((line) => (
|
|
1686
|
+
<button
|
|
1687
|
+
key={line.key}
|
|
1688
|
+
onClick={() => addScriptLine(line)}
|
|
1689
|
+
data-testid={`script-insert-${line.key}`}
|
|
1690
|
+
title={`Add ${line.type} overlay with this text`}
|
|
1691
|
+
className="text-left px-2 py-1 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
|
|
1692
|
+
>
|
|
1693
|
+
<span className="font-medium text-accent">
|
|
1694
|
+
+ {TYPE_LABEL[line.type]}
|
|
1695
|
+
</span>{" "}
|
|
1696
|
+
<span className="text-muted">
|
|
1697
|
+
{line.speaker ? `${line.speaker}: ` : ""}
|
|
1698
|
+
{line.text.length > 32
|
|
1699
|
+
? `${line.text.slice(0, 32)}…`
|
|
1700
|
+
: line.text}
|
|
1701
|
+
</span>
|
|
1702
|
+
</button>
|
|
1703
|
+
))}
|
|
1704
|
+
</div>
|
|
1705
|
+
</div>
|
|
1706
|
+
)}
|
|
1707
|
+
</div>
|
|
1177
1708
|
)}
|
|
1178
1709
|
</div>
|
|
1179
1710
|
</div>
|