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