plotlink-ows 1.2.94 → 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.
@@ -1,15 +1,34 @@
1
- import { useState, useEffect, useCallback, useRef } from "react";
1
+ import { Fragment, useState, useEffect, useCallback, useRef } from "react";
2
2
  import { LetteringEditor } from "./LetteringEditor";
3
3
  import { AssetImage, assetUrl } from "./asset-image";
4
- import { buildCodexTaskPrompt, buildLetteringPrompt } from "@app-lib/cartoon-prompt";
4
+ import { buildCodexTaskPrompt } from "@app-lib/cartoon-prompt";
5
5
  import type { Cut as LibCut } from "@app-lib/cuts";
6
6
  import { isTextPanel, isStaleTailedExport } from "@app-lib/cuts";
7
- import { withRateLimitRetry, createUploadThrottle, type RetryDeps } from "../lib/upload-retry";
8
- import { importImageToCompliantBlob, isCompliantImage } from "../lib/import-image";
7
+ import {
8
+ withRateLimitRetry,
9
+ createUploadThrottle,
10
+ type RetryDeps,
11
+ } from "../lib/upload-retry";
12
+ import {
13
+ importImageToCompliantBlob,
14
+ isCompliantImage,
15
+ } from "../lib/import-image";
9
16
  import { CodexImportPicker } from "./CodexImportPicker";
10
17
  import { FinishEpisodePanel } from "./FinishEpisodePanel";
11
- import { cartoonChecklist, checkMarkdownReadiness } from "@app-lib/cartoon-readiness";
12
- import { summarizeAssetDiagnostics, type CutAssetDiagnostic } from "@app-lib/cut-asset-diagnostics";
18
+ import {
19
+ cartoonChecklist,
20
+ checkMarkdownReadiness,
21
+ } from "@app-lib/cartoon-readiness";
22
+ import {
23
+ summarizeAssetDiagnostics,
24
+ type CutAssetDiagnostic,
25
+ } from "@app-lib/cut-asset-diagnostics";
26
+ import {
27
+ buildDraftOverlays,
28
+ overlaysSignature,
29
+ cutScriptLines,
30
+ } from "@app-lib/lettering-status";
31
+ import type { CutAiDraft } from "@app-lib/cuts";
13
32
 
14
33
  interface Overlay {
15
34
  id: string;
@@ -54,6 +73,7 @@ interface Cut {
54
73
  uploadedUrl: string | null;
55
74
  overlays: Overlay[];
56
75
  kind?: "image" | "text";
76
+ aiDraft?: CutAiDraft | null;
57
77
  background?: string;
58
78
  aspectRatio?: string;
59
79
  finalRendererVersion?: number;
@@ -83,10 +103,32 @@ interface CutListPanelProps {
83
103
  // is called once applied so the parent can clear the request.
84
104
  focusRequest?: { cutId: number; openEditor: boolean; seq: number } | null;
85
105
  onFocusHandled?: () => void;
106
+ /** Enter/leave the wider app's focused lettering mode (#493). */
107
+ onFocusedLetteringModeChange?: (active: boolean) => void;
108
+ /** Whether the surrounding work area is currently restored while editing. */
109
+ workspaceVisible?: boolean;
110
+ /** Restore/fold the wider app work area while staying in the editor. */
111
+ onWorkspaceVisibleChange?: (visible: boolean) => void;
86
112
  }
87
113
 
88
114
  type CutStatus = "missing" | "clean" | "lettered" | "uploaded" | "text";
89
115
 
116
+ function canDraftLettering(cut: Cut): boolean {
117
+ return (
118
+ (!!cut.cleanImagePath || isTextPanel(cut)) && cutScriptLines(cut).length > 0
119
+ );
120
+ }
121
+
122
+ function buildAiDraftState(overlays: Overlay[]): CutAiDraft {
123
+ const now = new Date().toISOString();
124
+ return {
125
+ status: "generated",
126
+ baseSig: overlaysSignature(overlays),
127
+ generatedAt: now,
128
+ updatedAt: now,
129
+ };
130
+ }
131
+
90
132
  function getCutStatus(cut: Cut): CutStatus {
91
133
  if (cut.uploadedCid) return "uploaded";
92
134
  if (cut.finalImagePath || cut.exportedAt) return "lettered";
@@ -101,14 +143,31 @@ function getCutStatus(cut: Cut): CutStatus {
101
143
  // per cut + the single primary human action, instead of internal field names.
102
144
  type BoardTone = "muted" | "amber" | "green" | "accent";
103
145
  const BOARD_TONE_TEXT: Record<BoardTone, string> = {
104
- muted: "text-muted", amber: "text-amber-700", green: "text-green-700", accent: "text-accent",
146
+ muted: "text-muted",
147
+ amber: "text-amber-700",
148
+ green: "text-green-700",
149
+ accent: "text-accent",
105
150
  };
106
151
  const BOARD_TONE_DOT: Record<BoardTone, string> = {
107
- muted: "bg-muted/40", amber: "bg-amber-500", green: "bg-green-600", accent: "bg-accent",
152
+ muted: "bg-muted/40",
153
+ amber: "bg-amber-500",
154
+ green: "bg-green-600",
155
+ accent: "bg-accent",
108
156
  };
109
157
 
110
- type BoardStatusKey = "uploaded" | "exported" | "convert" | "text" | "review" | "letter" | "needs-image";
111
- interface BoardStatus { key: BoardStatusKey; label: string; tone: BoardTone }
158
+ type BoardStatusKey =
159
+ | "uploaded"
160
+ | "exported"
161
+ | "convert"
162
+ | "text"
163
+ | "review"
164
+ | "letter"
165
+ | "needs-image";
166
+ interface BoardStatus {
167
+ key: BoardStatusKey;
168
+ label: string;
169
+ tone: BoardTone;
170
+ }
112
171
 
113
172
  /**
114
173
  * Map a cut's real state to one creator-facing board status (#440). `.png` clean
@@ -117,11 +176,17 @@ interface BoardStatus { key: BoardStatusKey; label: string; tone: BoardTone }
117
176
  * Details. Precedence follows the pipeline: uploaded → exported → convert →
118
177
  * letter/review → needs image.
119
178
  */
120
- function boardStatus(cut: Cut, needsConversion: boolean, hasStale: boolean): BoardStatus {
179
+ function boardStatus(
180
+ cut: Cut,
181
+ needsConversion: boolean,
182
+ hasStale: boolean,
183
+ ): BoardStatus {
121
184
  // Uploaded content lives on IPFS, so a missing LOCAL file is not a defect.
122
- if (cut.uploadedCid || cut.uploadedUrl) return { key: "uploaded", label: "Uploaded", tone: "green" };
185
+ if (cut.uploadedCid || cut.uploadedUrl)
186
+ return { key: "uploaded", label: "Uploaded", tone: "green" };
123
187
  // PNG clean art is an actionable conversion step (#441).
124
- if (needsConversion) return { key: "convert", label: "Needs conversion", tone: "amber" };
188
+ if (needsConversion)
189
+ return { key: "convert", label: "Needs conversion", tone: "amber" };
125
190
  // A recorded asset path that's broken on disk (#302) must NOT read as a
126
191
  // finished "Exported"/clean cut (#440 RE1): a recorded final needs
127
192
  // re-review/re-export; otherwise the clean art is gone → needs image. The
@@ -131,8 +196,10 @@ function boardStatus(cut: Cut, needsConversion: boolean, hasStale: boolean): Boa
131
196
  ? { key: "review", label: "Needs review", tone: "amber" }
132
197
  : { key: "needs-image", label: "Needs image", tone: "muted" };
133
198
  }
134
- if (cut.finalImagePath) return { key: "exported", label: "Exported", tone: "green" };
135
- if (isTextPanel(cut)) return { key: "text", label: "Ready for captions", tone: "accent" };
199
+ if (cut.finalImagePath)
200
+ return { key: "exported", label: "Exported", tone: "green" };
201
+ if (isTextPanel(cut))
202
+ return { key: "text", label: "Ready for captions", tone: "accent" };
136
203
  if (cut.cleanImagePath) {
137
204
  return (cut.overlays?.length ?? 0) > 0
138
205
  ? { key: "review", label: "Needs review", tone: "amber" }
@@ -141,6 +208,59 @@ function boardStatus(cut: Cut, needsConversion: boolean, hasStale: boolean): Boa
141
208
  return { key: "needs-image", label: "Needs image", tone: "muted" };
142
209
  }
143
210
 
211
+ function letteringReviewState(cut: Cut): {
212
+ label: string;
213
+ detail: string;
214
+ tone: BoardTone;
215
+ } {
216
+ if (cut.uploadedCid || cut.uploadedUrl) {
217
+ return { label: "Complete", detail: "Final image uploaded", tone: "green" };
218
+ }
219
+ if (cut.finalImagePath || cut.exportedAt) {
220
+ return { label: "Exported", detail: "Ready to upload", tone: "green" };
221
+ }
222
+ if ((cut.overlays?.length ?? 0) > 0) {
223
+ if (cut.aiDraft?.status === "generated") {
224
+ return {
225
+ label: "Draft ready",
226
+ detail: `${cut.overlays.length} AI-drafted overlay${cut.overlays.length === 1 ? "" : "s"} ready to review`,
227
+ tone: "amber",
228
+ };
229
+ }
230
+ return {
231
+ label: "User-edited",
232
+ detail: `${cut.overlays.length} overlay${cut.overlays.length === 1 ? "" : "s"} adjusted and ready to review`,
233
+ tone: "amber",
234
+ };
235
+ }
236
+ if (canDraftLettering(cut)) {
237
+ return {
238
+ label: "No draft",
239
+ detail: "Draft with AI or place bubbles manually",
240
+ tone: "muted",
241
+ };
242
+ }
243
+ if (isTextPanel(cut)) {
244
+ return {
245
+ label: "Between-scene card",
246
+ detail: "Open to add narration or title text",
247
+ tone: "accent",
248
+ };
249
+ }
250
+ if (cut.cleanImagePath) {
251
+ return {
252
+ label: "Unlettered",
253
+ detail: "Clean art ready for bubble placement",
254
+ tone: "muted",
255
+ };
256
+ }
257
+ return {
258
+ label: "Needs artwork",
259
+ detail: "Add or sync clean art first",
260
+ tone: "muted",
261
+ };
262
+ }
263
+
144
264
  function CutRow({
145
265
  cut,
146
266
  storyName,
@@ -153,6 +273,8 @@ function CutRow({
153
273
  detectedLocalClean,
154
274
  onSyncClean,
155
275
  syncing,
276
+ onAiDraft,
277
+ aiDrafting,
156
278
  staleMessages,
157
279
  onRepairStale,
158
280
  repairing,
@@ -172,6 +294,8 @@ function CutRow({
172
294
  detectedLocalClean: boolean;
173
295
  onSyncClean: () => void;
174
296
  syncing: boolean;
297
+ onAiDraft: () => void;
298
+ aiDrafting: boolean;
175
299
  staleMessages: string[];
176
300
  onRepairStale: () => void;
177
301
  repairing: boolean;
@@ -190,10 +314,6 @@ function CutRow({
190
314
  // generated PNG into this cut without hunting through a hidden folder.
191
315
  const [showCodexPicker, setShowCodexPicker] = useState(false);
192
316
  const [convertingThis, setConvertingThis] = useState(false);
193
- // Lettering is a first-class step (#442): an intentional Manual vs AI-draft
194
- // choice per cut, surfaced on the card (not hidden under Edit).
195
- const [letteringMode, setLetteringMode] = useState<"manual" | "ai">("manual");
196
- const [letteringCopied, setLetteringCopied] = useState(false);
197
317
  const status = getCutStatus(cut);
198
318
  // A recorded cleanImagePath/finalImagePath whose file is missing/invalid (#302):
199
319
  // surface it precisely rather than letting the field-based status claim the cut
@@ -213,46 +333,54 @@ function CutRow({
213
333
 
214
334
  // Returns true on a successful upload so callers (e.g. the Codex import picker)
215
335
  // can close themselves only when the clean image was actually recorded.
216
- const handleUpload = useCallback(async (file: File): Promise<boolean> => {
217
- setUploading(true);
218
- setUploadError(null);
219
- try {
220
- // Accept Codex-generated images (e.g. large PNG) by converting/compressing
221
- // them to a compliant WebP/JPEG <=1MB in the browser first (#301). An
222
- // already-compliant WebP/JPEG is passed through untouched, so the manual
223
- // upload behavior is unchanged. A source that cannot be decoded or
224
- // compressed under 1MB surfaces a clear error instead of saving anything.
225
- let upload: Blob = file;
226
- if (!isCompliantImage(file)) {
227
- try {
228
- upload = await importImageToCompliantBlob(file);
229
- } catch (err) {
230
- setUploadError(err instanceof Error ? err.message : "Could not import image");
231
- return false;
336
+ const handleUpload = useCallback(
337
+ async (file: File): Promise<boolean> => {
338
+ setUploading(true);
339
+ setUploadError(null);
340
+ try {
341
+ // Accept Codex-generated images (e.g. large PNG) by converting/compressing
342
+ // them to a compliant WebP/JPEG <=1MB in the browser first (#301). An
343
+ // already-compliant WebP/JPEG is passed through untouched, so the manual
344
+ // upload behavior is unchanged. A source that cannot be decoded or
345
+ // compressed under 1MB surfaces a clear error instead of saving anything.
346
+ let upload: Blob = file;
347
+ if (!isCompliantImage(file)) {
348
+ try {
349
+ upload = await importImageToCompliantBlob(file);
350
+ } catch (err) {
351
+ setUploadError(
352
+ err instanceof Error ? err.message : "Could not import image",
353
+ );
354
+ return false;
355
+ }
232
356
  }
233
- }
234
357
 
235
- const ext = upload.type === "image/jpeg" ? "jpg" : "webp";
236
- const formData = new FormData();
237
- formData.append("file", new File([upload], `clean.${ext}`, { type: upload.type }));
238
- const res = await authFetch(
239
- `/api/stories/${storyName}/cuts/${plotFile}/upload-clean/${cut.id}`,
240
- { method: "POST", body: formData },
241
- );
242
- if (!res.ok) {
243
- const data = await res.json();
244
- setUploadError(data.error || "Upload failed");
358
+ const ext = upload.type === "image/jpeg" ? "jpg" : "webp";
359
+ const formData = new FormData();
360
+ formData.append(
361
+ "file",
362
+ new File([upload], `clean.${ext}`, { type: upload.type }),
363
+ );
364
+ const res = await authFetch(
365
+ `/api/stories/${storyName}/cuts/${plotFile}/upload-clean/${cut.id}`,
366
+ { method: "POST", body: formData },
367
+ );
368
+ if (!res.ok) {
369
+ const data = await res.json();
370
+ setUploadError(data.error || "Upload failed");
371
+ return false;
372
+ }
373
+ onUpdated();
374
+ return true;
375
+ } catch {
376
+ setUploadError("Upload failed");
245
377
  return false;
378
+ } finally {
379
+ setUploading(false);
246
380
  }
247
- onUpdated();
248
- return true;
249
- } catch {
250
- setUploadError("Upload failed");
251
- return false;
252
- } finally {
253
- setUploading(false);
254
- }
255
- }, [authFetch, storyName, plotFile, cut.id, onUpdated]);
381
+ },
382
+ [authFetch, storyName, plotFile, cut.id, onUpdated],
383
+ );
256
384
 
257
385
  // Creator-facing board status + the single primary action for this cut (#440).
258
386
  const board = boardStatus(cut, needsConversion, hasStale);
@@ -264,21 +392,50 @@ function CutRow({
264
392
  // first-class Manual/AI-draft lettering choice instead of a single button.
265
393
  const bubblesPlaced = cut.overlays?.length ?? 0;
266
394
  const atLetteringStage =
267
- !isTextPanel(cut) && !!cut.cleanImagePath && !cut.finalImagePath &&
268
- !cut.uploadedCid && !cut.uploadedUrl && !hasStale && !needsConversion;
269
-
270
- const copyLetteringPrompt = useCallback(() => {
271
- navigator.clipboard?.writeText(buildLetteringPrompt(cut as unknown as LibCut, plotFile));
272
- setLetteringCopied(true);
273
- setTimeout(() => setLetteringCopied(false), 2000);
274
- }, [cut, plotFile]);
395
+ !isTextPanel(cut) &&
396
+ !!cut.cleanImagePath &&
397
+ !cut.finalImagePath &&
398
+ !cut.uploadedCid &&
399
+ !cut.uploadedUrl &&
400
+ !hasStale &&
401
+ !needsConversion;
402
+ const canAiDraft =
403
+ !hasStale &&
404
+ !needsConversion &&
405
+ !cut.finalImagePath &&
406
+ !cut.uploadedCid &&
407
+ !cut.uploadedUrl &&
408
+ canDraftLettering(cut);
409
+ const aiDraftLabel =
410
+ (cut.overlays?.length ?? 0) > 0 ? "Re-draft with AI" : "AI draft lettering";
275
411
 
276
412
  const primary: { label: string; onClick: () => void; testid: string } | null =
277
- board.key === "convert" ? { label: convertingThis ? "Converting…" : "Convert image", onClick: handleConvertThis, testid: `card-convert-${cut.id}` }
278
- : board.key === "review" ? { label: "Review cut", onClick: onOpenEditor, testid: `card-review-${cut.id}` }
279
- : board.key === "text" ? { label: "Add captions", onClick: onOpenEditor, testid: `card-letter-${cut.id}` }
280
- : board.key === "needs-image" ? { label: "Add artwork", onClick: onToggle, testid: `card-addart-${cut.id}` }
281
- : null; // exported / uploaded — the next action is the episode-level upload/publish
413
+ board.key === "convert"
414
+ ? {
415
+ label: convertingThis ? "Converting…" : "Convert image",
416
+ onClick: handleConvertThis,
417
+ testid: `card-convert-${cut.id}`,
418
+ }
419
+ : board.key === "review"
420
+ ? {
421
+ label: "Review cut",
422
+ onClick: onOpenEditor,
423
+ testid: `card-review-${cut.id}`,
424
+ }
425
+ : board.key === "text"
426
+ ? {
427
+ label: "Add captions",
428
+ onClick: onOpenEditor,
429
+ testid: `card-letter-${cut.id}`,
430
+ }
431
+ : board.key === "needs-image"
432
+ ? {
433
+ label: "Add artwork",
434
+ onClick: onToggle,
435
+ testid: `card-addart-${cut.id}`,
436
+ }
437
+ : null; // exported / uploaded — the next action is the episode-level upload/publish
438
+ const reviewState = letteringReviewState(cut);
282
439
 
283
440
  return (
284
441
  <div
@@ -290,10 +447,19 @@ function CutRow({
290
447
  action, plus an "Open details" toggle for the technical controls (#440). */}
291
448
  <div className="px-3 py-2 space-y-2" data-testid={`cut-card-${cut.id}`}>
292
449
  <div className="flex items-center gap-2 text-sm">
293
- <span className={`w-2 h-2 rounded-full flex-shrink-0 ${BOARD_TONE_DOT[board.tone]}`} />
294
- <span className="font-medium text-xs text-foreground">Cut {String(cut.id).padStart(2, "0")}</span>
295
- <span className="font-mono text-[10px] text-muted">· {cut.shotType}</span>
296
- <span className={`ml-auto text-[10px] font-medium flex-shrink-0 ${BOARD_TONE_TEXT[board.tone]}`} data-testid={`cut-card-status-${cut.id}`}>
450
+ <span
451
+ className={`w-2 h-2 rounded-full flex-shrink-0 ${BOARD_TONE_DOT[board.tone]}`}
452
+ />
453
+ <span className="font-medium text-xs text-foreground">
454
+ Cut {String(cut.id).padStart(2, "0")}
455
+ </span>
456
+ <span className="font-mono text-[10px] text-muted">
457
+ · {cut.shotType}
458
+ </span>
459
+ <span
460
+ className={`ml-auto text-[10px] font-medium flex-shrink-0 ${BOARD_TONE_TEXT[board.tone]}`}
461
+ data-testid={`cut-card-status-${cut.id}`}
462
+ >
297
463
  {board.label}
298
464
  </span>
299
465
  </div>
@@ -303,13 +469,25 @@ function CutRow({
303
469
  assetPath={thumbPath}
304
470
  authFetch={authFetch}
305
471
  alt={`Cut ${cut.id} artwork`}
306
- className="w-full max-h-44 object-contain rounded border border-border bg-white"
472
+ className="w-full max-h-[32rem] object-contain rounded border border-border bg-white"
307
473
  />
308
474
  ) : (
309
- <div className="w-full h-20 rounded border border-dashed border-border bg-surface/40 flex items-center justify-center text-[10px] text-muted" data-testid={`cut-card-noart-${cut.id}`}>
310
- {isTextPanel(cut) ? "Text panel no artwork needed" : "No artwork yet"}
475
+ <div
476
+ className="w-full min-h-28 rounded border border-dashed border-border bg-surface/40 flex items-center justify-center text-[10px] text-muted"
477
+ data-testid={`cut-card-noart-${cut.id}`}
478
+ >
479
+ {isTextPanel(cut)
480
+ ? "Text panel — no artwork needed"
481
+ : "No artwork yet"}
311
482
  </div>
312
483
  )}
484
+ <div
485
+ className={`rounded border border-border/70 bg-surface/50 px-2 py-1.5 text-[11px] ${BOARD_TONE_TEXT[reviewState.tone]}`}
486
+ data-testid={`lettering-review-state-${cut.id}`}
487
+ >
488
+ <span className="font-semibold">{reviewState.label}</span>
489
+ <span className="text-muted"> · {reviewState.detail}</span>
490
+ </div>
313
491
  <button
314
492
  onClick={onToggle}
315
493
  data-testid={`cut-desc-${cut.id}`}
@@ -317,61 +495,49 @@ function CutRow({
317
495
  >
318
496
  {cut.description || "No description"}
319
497
  </button>
320
- {/* Lettering is a first-class, visible step (#442): an intentional
321
- Manual vs AI-draft choice on the card, then the matching CTA. */}
322
- {atLetteringStage && (
323
- <div className="space-y-1" data-testid={`lettering-${cut.id}`}>
324
- <div className="text-[10px] font-medium text-muted uppercase tracking-wider">Lettering</div>
325
- <label className="flex items-center gap-1.5 text-[11px] text-foreground">
326
- <input
327
- type="radio" name={`lettering-mode-${cut.id}`} checked={letteringMode === "manual"}
328
- onChange={() => setLetteringMode("manual")} data-testid={`lettering-mode-manual-${cut.id}`}
329
- />
330
- Manual — I place bubbles myself
331
- </label>
332
- <label className="flex items-center gap-1.5 text-[11px] text-foreground">
333
- <input
334
- type="radio" name={`lettering-mode-${cut.id}`} checked={letteringMode === "ai"}
335
- onChange={() => setLetteringMode("ai")} data-testid={`lettering-mode-ai-${cut.id}`}
336
- />
337
- AI draft — ask the agent to place initial bubbles
338
- </label>
339
- {letteringMode === "ai" && (
340
- <p className="text-[10px] text-muted">
341
- Paste it to your agent, then review the draft bubbles here and export the final cut.
342
- </p>
343
- )}
344
- </div>
345
- )}
346
498
  <div className="flex items-center gap-2 flex-wrap">
347
499
  {atLetteringStage ? (
348
- letteringMode === "manual" ? (
500
+ <>
349
501
  <button
350
502
  onClick={onOpenEditor}
351
503
  data-testid={`add-bubbles-${cut.id}`}
352
504
  className="px-2.5 py-1 text-[11px] font-medium rounded bg-accent text-white hover:bg-accent-dim"
353
505
  >
354
- {bubblesPlaced > 0 ? "Review lettering" : "Add speech bubbles"}
506
+ {bubblesPlaced > 0 ? "Review lettering" : "Open focused editor"}
355
507
  </button>
356
- ) : (
357
- <button
358
- onClick={copyLetteringPrompt}
359
- data-testid={`copy-lettering-${cut.id}`}
360
- className="px-2.5 py-1 text-[11px] font-medium rounded border border-accent/40 text-accent hover:bg-accent/5"
361
- >
362
- {letteringCopied ? "Copied!" : "Copy AI lettering prompt"}
363
- </button>
364
- )
508
+ {canAiDraft && (
509
+ <button
510
+ onClick={onAiDraft}
511
+ disabled={aiDrafting}
512
+ data-testid={`ai-draft-${cut.id}`}
513
+ className="px-2.5 py-1 text-[11px] rounded border border-accent/40 text-accent hover:bg-accent/5 disabled:opacity-50"
514
+ >
515
+ {aiDrafting ? "Drafting…" : aiDraftLabel}
516
+ </button>
517
+ )}
518
+ </>
365
519
  ) : primary ? (
366
520
  <button
367
521
  onClick={primary.onClick}
368
- disabled={board.key === "convert" && (convertingThis || converting)}
522
+ disabled={
523
+ board.key === "convert" && (convertingThis || converting)
524
+ }
369
525
  data-testid={primary.testid}
370
526
  className="px-2.5 py-1 text-[11px] font-medium rounded bg-accent text-white hover:bg-accent-dim disabled:opacity-50"
371
527
  >
372
528
  {primary.label}
373
529
  </button>
374
530
  ) : null}
531
+ {!atLetteringStage && !primary && canAiDraft && (
532
+ <button
533
+ onClick={onAiDraft}
534
+ disabled={aiDrafting}
535
+ data-testid={`ai-draft-${cut.id}`}
536
+ className="px-2.5 py-1 text-[11px] rounded border border-accent/40 text-accent hover:bg-accent/5 disabled:opacity-50"
537
+ >
538
+ {aiDrafting ? "Drafting…" : aiDraftLabel}
539
+ </button>
540
+ )}
375
541
  <button
376
542
  onClick={onToggle}
377
543
  data-testid={`cut-details-${cut.id}`}
@@ -393,9 +559,13 @@ function CutRow({
393
559
  draft art). The raw unsupported-extension reason stays hidden in the
394
560
  Convert artwork banner's technical details. */}
395
561
  {needsConversion && (
396
- <div className="mt-2 rounded border border-amber-500/40 bg-amber-500/10 p-2 space-y-1" data-testid={`needs-conversion-${cut.id}`}>
562
+ <div
563
+ className="mt-2 rounded border border-amber-500/40 bg-amber-500/10 p-2 space-y-1"
564
+ data-testid={`needs-conversion-${cut.id}`}
565
+ >
397
566
  <p className="text-[11px] text-amber-800">
398
- This cut’s artwork is a PNG. Convert it to WebP so it can be lettered and published.
567
+ This cut’s artwork is a PNG. Convert it to WebP so it can be
568
+ lettered and published.
399
569
  </p>
400
570
  <button
401
571
  onClick={handleConvertThis}
@@ -408,9 +578,14 @@ function CutRow({
408
578
  </div>
409
579
  )}
410
580
  {hasStale && !needsConversion && (
411
- <div className="mt-2 rounded border border-error/40 bg-error/5 p-2 space-y-1" data-testid={`stale-asset-${cut.id}`}>
581
+ <div
582
+ className="mt-2 rounded border border-error/40 bg-error/5 p-2 space-y-1"
583
+ data-testid={`stale-asset-${cut.id}`}
584
+ >
412
585
  {staleMessages.map((m, i) => (
413
- <p key={i} className="text-[11px] text-error">{m}</p>
586
+ <p key={i} className="text-[11px] text-error">
587
+ {m}
588
+ </p>
414
589
  ))}
415
590
  <button
416
591
  onClick={onRepairStale}
@@ -430,111 +605,137 @@ function CutRow({
430
605
  Text/interstitial panels need no clean image (#351), so this whole
431
606
  generation/upload handoff is image-cut only. */}
432
607
  {!isTextPanel(cut) && (
433
- <div className="mt-2 space-y-2">
434
- <button
435
- onClick={() => {
436
- navigator.clipboard.writeText(buildCodexTaskPrompt(cut as unknown as LibCut, plotFile));
437
- setCopied(true);
438
- setTimeout(() => setCopied(false), 2000);
439
- }}
440
- data-testid={`copy-prompt-${cut.id}`}
441
- className="px-3 py-1.5 text-xs border border-border rounded hover:border-accent hover:bg-accent/5"
442
- >
443
- {copied ? "Copied!" : "Copy Codex task"}
444
- </button>
445
-
446
- <input
447
- ref={fileInputRef}
448
- type="file"
449
- accept="image/webp,image/jpeg,image/png"
450
- className="hidden"
451
- onChange={(e) => {
452
- const file = e.target.files?.[0];
453
- if (file) handleUpload(file);
454
- e.target.value = "";
455
- }}
456
- />
457
- <div className="flex items-center gap-2 flex-wrap">
608
+ <div className="mt-2 space-y-2">
458
609
  <button
459
- onClick={() => fileInputRef.current?.click()}
460
- disabled={uploading}
461
- className="px-3 py-1.5 text-xs border border-border rounded hover:border-accent hover:bg-accent/5 disabled:opacity-50"
462
- >
463
- {uploading ? "Uploading..." : cut.cleanImagePath ? "Replace clean image" : "Upload clean image"}
464
- </button>
465
- {/* #403: import a Codex-generated PNG straight from its cache, so a
466
- writer never has to hunt through ~/.codex/generated_images in an
467
- OS file dialog. Same in-browser PNG→WebP conversion + upload. */}
468
- <button
469
- onClick={() => setShowCodexPicker((v) => !v)}
470
- disabled={uploading}
471
- data-testid={`import-codex-${cut.id}`}
472
- className="px-3 py-1.5 text-xs border border-border rounded hover:border-accent hover:bg-accent/5 disabled:opacity-50"
610
+ onClick={() => {
611
+ navigator.clipboard.writeText(
612
+ buildCodexTaskPrompt(cut as unknown as LibCut, plotFile),
613
+ );
614
+ setCopied(true);
615
+ setTimeout(() => setCopied(false), 2000);
616
+ }}
617
+ data-testid={`copy-prompt-${cut.id}`}
618
+ className="px-3 py-1.5 text-xs border border-border rounded hover:border-accent hover:bg-accent/5"
473
619
  >
474
- {showCodexPicker ? "Hide Codex images" : "Import from Codex"}
620
+ {copied ? "Copied!" : "Copy Codex task"}
475
621
  </button>
476
- </div>
477
- {showCodexPicker && (
478
- <CodexImportPicker
479
- authFetch={authFetch}
480
- cutId={cut.id}
481
- onImport={async (file) => {
482
- const ok = await handleUpload(file);
483
- if (ok) setShowCodexPicker(false);
622
+
623
+ <input
624
+ ref={fileInputRef}
625
+ type="file"
626
+ accept="image/webp,image/jpeg,image/png"
627
+ className="hidden"
628
+ onChange={(e) => {
629
+ const file = e.target.files?.[0];
630
+ if (file) handleUpload(file);
631
+ e.target.value = "";
484
632
  }}
485
- onClose={() => setShowCodexPicker(false)}
486
633
  />
487
- )}
488
- {!cut.cleanImagePath && (
489
- <p className="text-xs text-muted" data-testid={`clean-image-handoff-${cut.id}`}>
490
- Generate this cut in Codex, then import the cached PNG with &ldquo;Import from Codex&rdquo; — or
491
- upload an image manually. Letter it next.
492
- </p>
493
- )}
494
- {status === "missing" && (
495
- <div
496
- className="rounded border border-border bg-surface/60 p-2 space-y-1"
497
- data-testid={`ask-codex-${cut.id}`}
498
- >
499
- <p className="text-[11px] font-medium text-foreground">Generate this cut in Codex</p>
500
- <p className="text-[10px] text-muted">
501
- Copy the task below and paste it into Codex. Codex usually saves a PNG to its
502
- image cache — bring it into this cut with &ldquo;Import from Codex&rdquo; above (the PNG
503
- becomes a WebP automatically). If Codex instead writes a WebP/JPEG at{" "}
504
- <span className="font-mono">assets/{plotFile}/cut-{String(cut.id).padStart(2, "0")}-clean.webp</span>,
505
- it&rsquo;s picked up by &ldquo;Sync clean images&rdquo;.
506
- </p>
634
+ <div className="flex items-center gap-2 flex-wrap">
507
635
  <button
508
- onClick={() => {
509
- navigator.clipboard.writeText(buildCodexTaskPrompt(cut as unknown as LibCut, plotFile));
510
- setAskCopied(true);
511
- setTimeout(() => setAskCopied(false), 2000);
512
- }}
513
- data-testid={`ask-codex-copy-${cut.id}`}
514
- className="px-2 py-1 text-[11px] border border-border rounded hover:border-accent hover:bg-accent/5"
636
+ onClick={() => fileInputRef.current?.click()}
637
+ disabled={uploading}
638
+ className="px-3 py-1.5 text-xs border border-border rounded hover:border-accent hover:bg-accent/5 disabled:opacity-50"
639
+ >
640
+ {uploading
641
+ ? "Uploading..."
642
+ : cut.cleanImagePath
643
+ ? "Replace clean image"
644
+ : "Upload clean image"}
645
+ </button>
646
+ {/* #403: import a Codex-generated PNG straight from its cache, so a
647
+ writer never has to hunt through ~/.codex/generated_images in an
648
+ OS file dialog. Same in-browser PNG→WebP conversion + upload. */}
649
+ <button
650
+ onClick={() => setShowCodexPicker((v) => !v)}
651
+ disabled={uploading}
652
+ data-testid={`import-codex-${cut.id}`}
653
+ className="px-3 py-1.5 text-xs border border-border rounded hover:border-accent hover:bg-accent/5 disabled:opacity-50"
515
654
  >
516
- {askCopied ? "Copied!" : "Copy Codex task"}
655
+ {showCodexPicker ? "Hide Codex images" : "Import from Codex"}
517
656
  </button>
518
657
  </div>
519
- )}
520
- {status === "missing" && detectedLocalClean && (
521
- <button
522
- onClick={onSyncClean}
523
- disabled={syncing}
524
- data-testid={`found-local-clean-${cut.id}`}
525
- className="px-3 py-1.5 text-xs border border-green-700/40 text-green-700 rounded hover:bg-green-700/5 disabled:opacity-50"
526
- >
527
- {syncing ? "Syncing..." : "Found local clean image — sync to cut plan"}
528
- </button>
529
- )}
530
- {uploadError && (
531
- <p className="text-xs text-error mt-1">{uploadError}</p>
532
- )}
533
- </div>
658
+ {showCodexPicker && (
659
+ <CodexImportPicker
660
+ authFetch={authFetch}
661
+ cutId={cut.id}
662
+ onImport={async (file) => {
663
+ const ok = await handleUpload(file);
664
+ if (ok) setShowCodexPicker(false);
665
+ }}
666
+ onClose={() => setShowCodexPicker(false)}
667
+ />
668
+ )}
669
+ {!cut.cleanImagePath && (
670
+ <p
671
+ className="text-xs text-muted"
672
+ data-testid={`clean-image-handoff-${cut.id}`}
673
+ >
674
+ Generate this cut in Codex, then import the cached PNG with
675
+ &ldquo;Import from Codex&rdquo; — or upload an image manually.
676
+ Letter it next.
677
+ </p>
678
+ )}
679
+ {status === "missing" && (
680
+ <div
681
+ className="rounded border border-border bg-surface/60 p-2 space-y-1"
682
+ data-testid={`ask-codex-${cut.id}`}
683
+ >
684
+ <p className="text-[11px] font-medium text-foreground">
685
+ Generate this cut in Codex
686
+ </p>
687
+ <p className="text-[10px] text-muted">
688
+ Copy the task below and paste it into Codex. Codex usually
689
+ saves a PNG to its image cache — bring it into this cut with
690
+ &ldquo;Import from Codex&rdquo; above (the PNG becomes a
691
+ WebP automatically). If Codex instead writes a WebP/JPEG at{" "}
692
+ <span className="font-mono">
693
+ assets/{plotFile}/cut-{String(cut.id).padStart(2, "0")}
694
+ -clean.webp
695
+ </span>
696
+ , it&rsquo;s picked up by &ldquo;Sync clean images&rdquo;.
697
+ </p>
698
+ <button
699
+ onClick={() => {
700
+ navigator.clipboard.writeText(
701
+ buildCodexTaskPrompt(
702
+ cut as unknown as LibCut,
703
+ plotFile,
704
+ ),
705
+ );
706
+ setAskCopied(true);
707
+ setTimeout(() => setAskCopied(false), 2000);
708
+ }}
709
+ data-testid={`ask-codex-copy-${cut.id}`}
710
+ className="px-2 py-1 text-[11px] border border-border rounded hover:border-accent hover:bg-accent/5"
711
+ >
712
+ {askCopied ? "Copied!" : "Copy Codex task"}
713
+ </button>
714
+ </div>
715
+ )}
716
+ {status === "missing" && detectedLocalClean && (
717
+ <button
718
+ onClick={onSyncClean}
719
+ disabled={syncing}
720
+ data-testid={`found-local-clean-${cut.id}`}
721
+ className="px-3 py-1.5 text-xs border border-green-700/40 text-green-700 rounded hover:bg-green-700/5 disabled:opacity-50"
722
+ >
723
+ {syncing
724
+ ? "Syncing..."
725
+ : "Found local clean image — sync to cut plan"}
726
+ </button>
727
+ )}
728
+ {uploadError && (
729
+ <p className="text-xs text-error mt-1">{uploadError}</p>
730
+ )}
731
+ </div>
534
732
  )}
535
733
 
536
734
  {/* Open editor — image cuts, narration cuts, and text panels (#351) */}
537
- {(cut.cleanImagePath || cut.narration || cut.dialogue.length > 0 || isTextPanel(cut)) && (
735
+ {(cut.cleanImagePath ||
736
+ cut.narration ||
737
+ cut.dialogue.length > 0 ||
738
+ isTextPanel(cut)) && (
538
739
  <button
539
740
  onClick={onOpenEditor}
540
741
  data-testid={`open-editor-${cut.id}`}
@@ -546,12 +747,16 @@ function CutRow({
546
747
 
547
748
  {/* Cut metadata */}
548
749
  {cut.characters.length > 0 && (
549
- <p className="text-xs text-muted">Characters: {cut.characters.join(", ")}</p>
750
+ <p className="text-xs text-muted">
751
+ Characters: {cut.characters.join(", ")}
752
+ </p>
550
753
  )}
551
754
  {cut.dialogue.length > 0 && (
552
755
  <div className="text-xs text-muted">
553
756
  {cut.dialogue.map((d, i) => (
554
- <p key={i}><span className="font-medium">{d.speaker}:</span> {d.text}</p>
757
+ <p key={i}>
758
+ <span className="font-medium">{d.speaker}:</span> {d.text}
759
+ </p>
555
760
  ))}
556
761
  </div>
557
762
  )}
@@ -564,7 +769,19 @@ function CutRow({
564
769
  );
565
770
  }
566
771
 
567
- export function CutListPanel({ storyName, fileName, authFetch, language, uploadRetry, onCutsChanged, focusRequest, onFocusHandled }: CutListPanelProps) {
772
+ export function CutListPanel({
773
+ storyName,
774
+ fileName,
775
+ authFetch,
776
+ language,
777
+ uploadRetry,
778
+ onCutsChanged,
779
+ focusRequest,
780
+ onFocusHandled,
781
+ onFocusedLetteringModeChange,
782
+ workspaceVisible = false,
783
+ onWorkspaceVisibleChange,
784
+ }: CutListPanelProps) {
568
785
  const [cutsFile, setCutsFile] = useState<CutsFile | null>(null);
569
786
  // Latest onCutsChanged in a ref so loadCuts can notify the parent without
570
787
  // taking the callback as a dependency (which would churn loadCuts/effects).
@@ -583,7 +800,10 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
583
800
  // Episode publish markdown + on-chain state, for the guided Finish panel (#414):
584
801
  // distinguishes uploaded-but-not-prepared from a prepared/ready-to-publish or
585
802
  // already-published episode (which cuts.json alone cannot tell apart).
586
- const [episodeState, setEpisodeState] = useState<{ markdownReady: boolean; published: boolean }>({
803
+ const [episodeState, setEpisodeState] = useState<{
804
+ markdownReady: boolean;
805
+ published: boolean;
806
+ }>({
587
807
  markdownReady: false,
588
808
  published: false,
589
809
  });
@@ -592,9 +812,13 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
592
812
  const [converting, setConverting] = useState(false);
593
813
  const [convertResult, setConvertResult] = useState<string | null>(null);
594
814
  const [syncResult, setSyncResult] = useState<string | null>(null);
815
+ const [aiDraftingCutId, setAiDraftingCutId] = useState<number | null>(null);
816
+ const [aiDraftingAll, setAiDraftingAll] = useState(false);
595
817
  const [detected, setDetected] = useState<Set<number>>(new Set());
596
818
  // cutId → precise stale-path messages (#302), from detect-clean-images.
597
- const [staleByCut, setStaleByCut] = useState<Map<number, string[]>>(new Map());
819
+ const [staleByCut, setStaleByCut] = useState<Map<number, string[]>>(
820
+ new Map(),
821
+ );
598
822
  // True only after /detect-clean-images has SUCCESSFULLY verified the recorded
599
823
  // paths against disk (#311). Gates the "clean-assets-ready" banner so it never
600
824
  // claims completion from unverified cut-plan fields while detection is pending
@@ -602,11 +826,15 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
602
826
  const [detectConfirmed, setDetectConfirmed] = useState(false);
603
827
  // Read-only per-cut asset diagnostics validated against disk (#427): the real
604
828
  // state (planned/missing/clean-ready/final-ready/uploaded) + precise issues.
605
- const [assetDiagnostics, setAssetDiagnostics] = useState<CutAssetDiagnostic[] | null>(null);
829
+ const [assetDiagnostics, setAssetDiagnostics] = useState<
830
+ CutAssetDiagnostic[] | null
831
+ >(null);
606
832
  const [rescanning, setRescanning] = useState(false);
607
833
  // #371: cut whose row should be scrolled into view after a Preview→Edit deep
608
834
  // link. Applied once its row has rendered (see the scroll effect below).
609
- const [scrollTargetCutId, setScrollTargetCutId] = useState<number | null>(null);
835
+ const [scrollTargetCutId, setScrollTargetCutId] = useState<number | null>(
836
+ null,
837
+ );
610
838
  // Live DOM refs for cut rows, keyed by cut id, used to scroll a focused cut
611
839
  // into view. A ref map avoids re-rendering on registration.
612
840
  const rowRefs = useRef<Map<number, HTMLDivElement>>(new Map());
@@ -632,6 +860,11 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
632
860
  onFocusHandledRef.current?.();
633
861
  }, [focusRequest]);
634
862
 
863
+ useEffect(() => {
864
+ onFocusedLetteringModeChange?.(editingCutId !== null);
865
+ return () => onFocusedLetteringModeChange?.(false);
866
+ }, [editingCutId, onFocusedLetteringModeChange]);
867
+
635
868
  // Scroll a deep-linked, expanded cut into view once its row is on screen. Runs
636
869
  // when the target is set and again after the cut plan loads (rows mount). Best
637
870
  // effort: `scrollIntoView` is a no-op/undefined under jsdom.
@@ -663,13 +896,19 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
663
896
  // distinctly, not just upload progress (#414). Best-effort — a missing file
664
897
  // or error simply leaves those steps as not-yet-prepared.
665
898
  try {
666
- const fileRes = await authFetch(`/api/stories/${storyName}/${fileName}`);
899
+ const fileRes = await authFetch(
900
+ `/api/stories/${storyName}/${fileName}`,
901
+ );
667
902
  if (fileRes.ok) {
668
903
  const fd = await fileRes.json();
669
- const content: string = typeof fd?.content === "string" ? fd.content : "";
904
+ const content: string =
905
+ typeof fd?.content === "string" ? fd.content : "";
670
906
  const cuts = Array.isArray(parsed?.cuts) ? parsed.cuts : [];
671
- const markdownReady = content.length > 0 && checkMarkdownReadiness(content, cuts).ready;
672
- const published = fd?.status === "published" || fd?.status === "published-not-indexed";
907
+ const markdownReady =
908
+ content.length > 0 && checkMarkdownReadiness(content, cuts).ready;
909
+ const published =
910
+ fd?.status === "published" ||
911
+ fd?.status === "published-not-indexed";
673
912
  setEpisodeState({ markdownReady, published });
674
913
  } else {
675
914
  setEpisodeState({ markdownReady: false, published: false });
@@ -687,6 +926,22 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
687
926
  }
688
927
  }, [authFetch, storyName, plotFile, fileName]);
689
928
 
929
+ const saveCutsFile = useCallback(
930
+ async (updated: CutsFile): Promise<boolean> => {
931
+ const res = await authFetch(
932
+ `/api/stories/${storyName}/cuts/${plotFile}`,
933
+ {
934
+ method: "PUT",
935
+ headers: { "Content-Type": "application/json" },
936
+ body: JSON.stringify(updated),
937
+ },
938
+ );
939
+ if (!res.ok) return false;
940
+ return true;
941
+ },
942
+ [authFetch, storyName, plotFile],
943
+ );
944
+
690
945
  // Server-confirmed detection of local clean files for cuts whose cleanImagePath
691
946
  // is still null. Best-effort: failures leave the detected set unchanged.
692
947
  const loadDetect = useCallback(async () => {
@@ -694,15 +949,20 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
694
949
  // unverified — don't let the done banner claim completion (#311).
695
950
  setDetectConfirmed(false);
696
951
  try {
697
- const res = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}/detect-clean-images`);
952
+ const res = await authFetch(
953
+ `/api/stories/${storyName}/cuts/${plotFile}/detect-clean-images`,
954
+ );
698
955
  if (!res.ok) return;
699
956
  const data = await res.json();
700
- setDetected(new Set<number>(Array.isArray(data.detected) ? data.detected : []));
957
+ setDetected(
958
+ new Set<number>(Array.isArray(data.detected) ? data.detected : []),
959
+ );
701
960
  const staleMap = new Map<number, string[]>();
702
961
  const staleList: unknown = data.stale;
703
962
  if (Array.isArray(staleList)) {
704
963
  for (const s of staleList) {
705
- if (typeof s?.cutId !== "number" || typeof s?.message !== "string") continue;
964
+ if (typeof s?.cutId !== "number" || typeof s?.message !== "string")
965
+ continue;
706
966
  const arr = staleMap.get(s.cutId) ?? [];
707
967
  arr.push(s.message);
708
968
  staleMap.set(s.cutId, arr);
@@ -722,11 +982,17 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
722
982
  const loadDiagnostics = useCallback(async () => {
723
983
  setAssetDiagnostics(null);
724
984
  try {
725
- const res = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}/asset-diagnostics`);
985
+ const res = await authFetch(
986
+ `/api/stories/${storyName}/cuts/${plotFile}/asset-diagnostics`,
987
+ );
726
988
  if (!res.ok) return; // stays cleared
727
989
  const data = await res.json();
728
- setAssetDiagnostics(Array.isArray(data.diagnostics) ? data.diagnostics : null);
729
- } catch { /* stays cleared — diagnostics are optional */ }
990
+ setAssetDiagnostics(
991
+ Array.isArray(data.diagnostics) ? data.diagnostics : null,
992
+ );
993
+ } catch {
994
+ /* stays cleared — diagnostics are optional */
995
+ }
730
996
  }, [authFetch, storyName, plotFile]);
731
997
 
732
998
  // "Refresh assets / Check generated images" (#427): a read-only rescan that
@@ -742,26 +1008,125 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
742
1008
  }
743
1009
  }, [loadCuts, loadDetect, loadDiagnostics]);
744
1010
 
1011
+ const draftCutWithAi = useCallback(
1012
+ async (cutId: number, opts: { openEditor?: boolean } = {}) => {
1013
+ if (!cutsFile) return false;
1014
+ const cut = cutsFile.cuts.find((c) => c.id === cutId);
1015
+ if (!cut || !canDraftLettering(cut)) return false;
1016
+ if ((cut.overlays?.length ?? 0) > 0) {
1017
+ const ok = window.confirm(
1018
+ "This cut already has overlays. Re-drafting with AI will replace the current draft overlays for this cut. Continue?",
1019
+ );
1020
+ if (!ok) return false;
1021
+ }
1022
+ const overlays = buildDraftOverlays(cut);
1023
+ if (overlays.length === 0) {
1024
+ setSyncResult(`Cut ${cut.id}: no script lines available to draft.`);
1025
+ return false;
1026
+ }
1027
+ setAiDraftingCutId(cutId);
1028
+ setSyncResult(null);
1029
+ try {
1030
+ const updated: CutsFile = {
1031
+ ...cutsFile,
1032
+ cuts: cutsFile.cuts.map((c) =>
1033
+ c.id === cutId
1034
+ ? { ...c, overlays, aiDraft: buildAiDraftState(overlays) }
1035
+ : c,
1036
+ ),
1037
+ };
1038
+ const ok = await saveCutsFile(updated);
1039
+ if (!ok) {
1040
+ setSyncResult(`Cut ${cut.id}: AI draft failed`);
1041
+ return false;
1042
+ }
1043
+ setSyncResult(`Cut ${cut.id}: AI draft ready`);
1044
+ await loadCuts();
1045
+ if (opts.openEditor) setEditingCutId(cutId);
1046
+ return true;
1047
+ } finally {
1048
+ setAiDraftingCutId(null);
1049
+ }
1050
+ },
1051
+ [cutsFile, saveCutsFile, loadCuts],
1052
+ );
1053
+
1054
+ const draftAllUnletteredCuts = useCallback(async () => {
1055
+ if (!cutsFile) return;
1056
+ const eligible = cutsFile.cuts.filter(
1057
+ (cut) =>
1058
+ canDraftLettering(cut) &&
1059
+ (cut.overlays?.length ?? 0) === 0 &&
1060
+ !cut.finalImagePath &&
1061
+ !cut.uploadedCid &&
1062
+ !cut.uploadedUrl,
1063
+ );
1064
+ if (eligible.length === 0) {
1065
+ setSyncResult("No unlettered cuts need an AI draft");
1066
+ return;
1067
+ }
1068
+ setAiDraftingAll(true);
1069
+ setSyncResult(null);
1070
+ try {
1071
+ const updated: CutsFile = {
1072
+ ...cutsFile,
1073
+ cuts: cutsFile.cuts.map((cut) => {
1074
+ if (!eligible.some((e) => e.id === cut.id)) return cut;
1075
+ const overlays = buildDraftOverlays(cut);
1076
+ return overlays.length > 0
1077
+ ? { ...cut, overlays, aiDraft: buildAiDraftState(overlays) }
1078
+ : cut;
1079
+ }),
1080
+ };
1081
+ const ok = await saveCutsFile(updated);
1082
+ if (!ok) {
1083
+ setSyncResult("AI draft failed");
1084
+ return;
1085
+ }
1086
+ setSyncResult(
1087
+ `AI draft ready for ${eligible.length} cut${eligible.length === 1 ? "" : "s"}`,
1088
+ );
1089
+ await loadCuts();
1090
+ } finally {
1091
+ setAiDraftingAll(false);
1092
+ }
1093
+ }, [cutsFile, saveCutsFile, loadCuts]);
1094
+
745
1095
  const syncCleanImages = useCallback(async () => {
746
1096
  setSyncing(true);
747
1097
  setSyncResult(null);
748
1098
  setGenWarnings([]);
749
1099
  try {
750
- const res = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}/sync-clean-images`, { method: "POST" });
1100
+ const res = await authFetch(
1101
+ `/api/stories/${storyName}/cuts/${plotFile}/sync-clean-images`,
1102
+ { method: "POST" },
1103
+ );
751
1104
  const data = await res.json().catch(() => ({}));
752
1105
  if (!res.ok) {
753
1106
  setSyncResult(data.error || "Sync failed");
754
1107
  } else {
755
1108
  const syncedCount = Array.isArray(data.synced) ? data.synced.length : 0;
756
- const clearedCount = Array.isArray(data.cleared) ? data.cleared.length : 0;
1109
+ const clearedCount = Array.isArray(data.cleared)
1110
+ ? data.cleared.length
1111
+ : 0;
757
1112
  const rejected = Array.isArray(data.rejected) ? data.rejected : [];
758
1113
  if (rejected.length > 0) {
759
- setGenWarnings(rejected.map((r: { cutId: number; reason: string }) => `Cut ${r.cutId}: ${r.reason}`));
1114
+ setGenWarnings(
1115
+ rejected.map(
1116
+ (r: { cutId: number; reason: string }) =>
1117
+ `Cut ${r.cutId}: ${r.reason}`,
1118
+ ),
1119
+ );
760
1120
  }
761
1121
  const parts: string[] = [];
762
1122
  if (syncedCount > 0) parts.push(`Synced ${syncedCount}`);
763
- if (clearedCount > 0) parts.push(`Cleared ${clearedCount} stale path${clearedCount === 1 ? "" : "s"}`);
764
- setSyncResult(parts.length > 0 ? parts.join(", ") : "No new clean images");
1123
+ if (clearedCount > 0)
1124
+ parts.push(
1125
+ `Cleared ${clearedCount} stale path${clearedCount === 1 ? "" : "s"}`,
1126
+ );
1127
+ setSyncResult(
1128
+ parts.length > 0 ? parts.join(", ") : "No new clean images",
1129
+ );
765
1130
  await loadCuts();
766
1131
  await loadDetect();
767
1132
  await loadDiagnostics();
@@ -777,42 +1142,57 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
777
1142
  // upload), and persist via the existing upload-clean route, which records the
778
1143
  // new cleanImagePath. Returns true on success. Publish stays strict — the route
779
1144
  // only accepts WebP/JPEG ≤1MB, so conversion is the safe bridge from a draft PNG.
780
- const convertCut = useCallback(async (cutId: number, pngPath: string): Promise<boolean> => {
781
- try {
782
- const res = await authFetch(assetUrl(storyName, pngPath));
783
- if (!res.ok) return false;
784
- const blob = await res.blob();
785
- const compliant = await importImageToCompliantBlob(new File([blob], "clean.png", { type: blob.type || "image/png" }));
786
- const ext = compliant.type === "image/jpeg" ? "jpg" : "webp";
787
- const formData = new FormData();
788
- formData.append("file", new File([compliant], `clean.${ext}`, { type: compliant.type }));
789
- const up = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}/upload-clean/${cutId}`, { method: "POST", body: formData });
790
- return up.ok;
791
- } catch {
792
- return false;
793
- }
794
- }, [authFetch, storyName, plotFile]);
1145
+ const convertCut = useCallback(
1146
+ async (cutId: number, pngPath: string): Promise<boolean> => {
1147
+ try {
1148
+ const res = await authFetch(assetUrl(storyName, pngPath));
1149
+ if (!res.ok) return false;
1150
+ const blob = await res.blob();
1151
+ const compliant = await importImageToCompliantBlob(
1152
+ new File([blob], "clean.png", { type: blob.type || "image/png" }),
1153
+ );
1154
+ const ext = compliant.type === "image/jpeg" ? "jpg" : "webp";
1155
+ const formData = new FormData();
1156
+ formData.append(
1157
+ "file",
1158
+ new File([compliant], `clean.${ext}`, { type: compliant.type }),
1159
+ );
1160
+ const up = await authFetch(
1161
+ `/api/stories/${storyName}/cuts/${plotFile}/upload-clean/${cutId}`,
1162
+ { method: "POST", body: formData },
1163
+ );
1164
+ return up.ok;
1165
+ } catch {
1166
+ return false;
1167
+ }
1168
+ },
1169
+ [authFetch, storyName, plotFile],
1170
+ );
795
1171
 
796
1172
  // "Convert all artwork" (#441): batch-convert every cut flagged needs-conversion.
797
- const convertAll = useCallback(async (jobs: { cutId: number; pngPath: string }[]) => {
798
- setConverting(true);
799
- setConvertResult(null);
800
- let done = 0;
801
- const failed: number[] = [];
802
- for (const job of jobs) {
803
- // Sequential on purpose: avoid hammering browser canvas conversion + the
804
- // upload-clean write all at once for a 10-cut episode.
805
- const ok = await convertCut(job.cutId, job.pngPath);
806
- if (ok) done++; else failed.push(job.cutId);
807
- }
808
- await refreshAssets();
809
- setConverting(false);
810
- setConvertResult(
811
- failed.length === 0
812
- ? `Converted ${done} image${done === 1 ? "" : "s"} to WebP`
813
- : `Converted ${done}; ${failed.length} failed (Cut ${failed.join(", ")}) — try Convert image on each`,
814
- );
815
- }, [convertCut, refreshAssets]);
1173
+ const convertAll = useCallback(
1174
+ async (jobs: { cutId: number; pngPath: string }[]) => {
1175
+ setConverting(true);
1176
+ setConvertResult(null);
1177
+ let done = 0;
1178
+ const failed: number[] = [];
1179
+ for (const job of jobs) {
1180
+ // Sequential on purpose: avoid hammering browser canvas conversion + the
1181
+ // upload-clean write all at once for a 10-cut episode.
1182
+ const ok = await convertCut(job.cutId, job.pngPath);
1183
+ if (ok) done++;
1184
+ else failed.push(job.cutId);
1185
+ }
1186
+ await refreshAssets();
1187
+ setConverting(false);
1188
+ setConvertResult(
1189
+ failed.length === 0
1190
+ ? `Converted ${done} image${done === 1 ? "" : "s"} to WebP`
1191
+ : `Converted ${done}; ${failed.length} failed (Cut ${failed.join(", ")}) — try Convert image on each`,
1192
+ );
1193
+ },
1194
+ [convertCut, refreshAssets],
1195
+ );
816
1196
 
817
1197
  // Guided "Finish episode" orchestration (#414): upload every exported final
818
1198
  // image (paced under the rate limit, #413/#288), then prepare the publish
@@ -824,7 +1204,9 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
824
1204
  setUploading(true);
825
1205
  setUploadProgress("");
826
1206
  setGenWarnings([]);
827
- const toUpload = cutsFile.cuts.filter((ct) => ct.finalImagePath && !ct.uploadedCid);
1207
+ const toUpload = cutsFile.cuts.filter(
1208
+ (ct) => ct.finalImagePath && !ct.uploadedCid,
1209
+ );
828
1210
  const errors: string[] = [];
829
1211
  // Proactively pace uploads under PlotLink's 5/min limit so a 7–10 cut episode
830
1212
  // completes without manual waiting, instead of firing all at once and thrashing
@@ -839,14 +1221,27 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
839
1221
  });
840
1222
  for (let i = 0; i < toUpload.length; i++) {
841
1223
  const ct = toUpload[i];
842
- setUploadProgress(`Uploading cut ${ct.id} (${i + 1}/${toUpload.length})...`);
1224
+ setUploadProgress(
1225
+ `Uploading cut ${ct.id} (${i + 1}/${toUpload.length})...`,
1226
+ );
843
1227
  try {
844
- const assetRel = ct.finalImagePath!.startsWith("assets/") ? ct.finalImagePath!.slice(7) : ct.finalImagePath!;
845
- const imgRes = await authFetch(`/api/stories/${storyName}/asset/${assetRel}`);
846
- if (!imgRes.ok) { errors.push(`Cut ${ct.id}: failed to fetch asset`); continue; }
1228
+ const assetRel = ct.finalImagePath!.startsWith("assets/")
1229
+ ? ct.finalImagePath!.slice(7)
1230
+ : ct.finalImagePath!;
1231
+ const imgRes = await authFetch(
1232
+ `/api/stories/${storyName}/asset/${assetRel}`,
1233
+ );
1234
+ if (!imgRes.ok) {
1235
+ errors.push(`Cut ${ct.id}: failed to fetch asset`);
1236
+ continue;
1237
+ }
847
1238
  const blob = await imgRes.blob();
848
1239
  const fd = new FormData();
849
- fd.append("file", blob, `cut-${ct.id}.${blob.type === "image/webp" ? "webp" : "jpg"}`);
1240
+ fd.append(
1241
+ "file",
1242
+ blob,
1243
+ `cut-${ct.id}.${blob.type === "image/webp" ? "webp" : "jpg"}`,
1244
+ );
850
1245
  // Proactively wait if we've already used the 5/min budget (#413), then retry
851
1246
  // with backoff while the PlotLink endpoint rate-limits us anyway (5
852
1247
  // uploads/min), instead of failing the whole batch (#288). Already-uploaded
@@ -855,13 +1250,20 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
855
1250
  await throttle();
856
1251
  const upload = await withRateLimitRetry(
857
1252
  async () => {
858
- const res = await authFetch("/api/publish/upload-plot-image", { method: "POST", body: fd });
1253
+ const res = await authFetch("/api/publish/upload-plot-image", {
1254
+ method: "POST",
1255
+ body: fd,
1256
+ });
859
1257
  if (res.ok) {
860
1258
  const { cid, url } = await res.json();
861
1259
  return { ok: true as const, status: res.status, cid, url };
862
1260
  }
863
1261
  const e = await res.json().catch(() => ({}));
864
- return { ok: false as const, status: res.status, errorMessage: (e as { error?: string }).error };
1262
+ return {
1263
+ ok: false as const,
1264
+ status: res.status,
1265
+ errorMessage: (e as { error?: string }).error,
1266
+ };
865
1267
  },
866
1268
  {
867
1269
  ...uploadRetry,
@@ -871,16 +1273,28 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
871
1273
  ),
872
1274
  },
873
1275
  );
874
- if (!upload.ok) { errors.push(`Cut ${ct.id}: upload failed — ${upload.errorMessage || "unknown"}`); continue; }
1276
+ if (!upload.ok) {
1277
+ errors.push(
1278
+ `Cut ${ct.id}: upload failed — ${upload.errorMessage || "unknown"}`,
1279
+ );
1280
+ continue;
1281
+ }
875
1282
  const { cid, url } = upload;
876
- const setRes = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}/set-uploaded/${ct.id}`, {
877
- method: "POST",
878
- headers: { "Content-Type": "application/json" },
879
- body: JSON.stringify({ cid, url }),
880
- });
881
- if (!setRes.ok) { errors.push(`Cut ${ct.id}: failed to record upload`); }
1283
+ const setRes = await authFetch(
1284
+ `/api/stories/${storyName}/cuts/${plotFile}/set-uploaded/${ct.id}`,
1285
+ {
1286
+ method: "POST",
1287
+ headers: { "Content-Type": "application/json" },
1288
+ body: JSON.stringify({ cid, url }),
1289
+ },
1290
+ );
1291
+ if (!setRes.ok) {
1292
+ errors.push(`Cut ${ct.id}: failed to record upload`);
1293
+ }
882
1294
  } catch (err) {
883
- errors.push(`Cut ${ct.id}: ${err instanceof Error ? err.message : "failed"}`);
1295
+ errors.push(
1296
+ `Cut ${ct.id}: ${err instanceof Error ? err.message : "failed"}`,
1297
+ );
884
1298
  }
885
1299
  }
886
1300
  if (errors.length > 0) {
@@ -891,7 +1305,10 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
891
1305
  return;
892
1306
  }
893
1307
  setUploadProgress("Preparing episode for publishing…");
894
- const mdRes = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}/generate-markdown`, { method: "POST" });
1308
+ const mdRes = await authFetch(
1309
+ `/api/stories/${storyName}/cuts/${plotFile}/generate-markdown`,
1310
+ { method: "POST" },
1311
+ );
895
1312
  if (mdRes.ok) {
896
1313
  const data = await mdRes.json();
897
1314
  if (data.warnings?.length > 0) setGenWarnings(data.warnings);
@@ -908,13 +1325,22 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
908
1325
  setRepairing(true);
909
1326
  setSyncResult(null);
910
1327
  try {
911
- const res = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}/repair-asset-paths`, { method: "POST" });
1328
+ const res = await authFetch(
1329
+ `/api/stories/${storyName}/cuts/${plotFile}/repair-asset-paths`,
1330
+ { method: "POST" },
1331
+ );
912
1332
  const data = await res.json().catch(() => ({}));
913
1333
  if (!res.ok) {
914
1334
  setSyncResult(data.error || "Repair failed");
915
1335
  } else {
916
- const clearedCount = Array.isArray(data.cleared) ? data.cleared.length : 0;
917
- setSyncResult(clearedCount > 0 ? `Cleared ${clearedCount} stale path${clearedCount === 1 ? "" : "s"}` : "No stale paths to clear");
1336
+ const clearedCount = Array.isArray(data.cleared)
1337
+ ? data.cleared.length
1338
+ : 0;
1339
+ setSyncResult(
1340
+ clearedCount > 0
1341
+ ? `Cleared ${clearedCount} stale path${clearedCount === 1 ? "" : "s"}`
1342
+ : "No stale paths to clear",
1343
+ );
918
1344
  await loadCuts();
919
1345
  await loadDetect();
920
1346
  }
@@ -924,39 +1350,67 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
924
1350
  setRepairing(false);
925
1351
  }, [authFetch, storyName, plotFile, loadCuts, loadDetect]);
926
1352
 
927
- // Append a text/interstitial panel to the cut plan (#352) — a one-click way to
928
- // add a narration/title card between image cuts without hand-editing cuts.json.
1353
+ // Insert a text/interstitial panel to the cut plan (#352/#488) — a one-click way
1354
+ // to add a narration/title card between image cuts without hand-editing cuts.json.
929
1355
  const [addingPanel, setAddingPanel] = useState(false);
930
- const addTextPanel = useCallback(async () => {
931
- if (!cutsFile) return;
932
- setAddingPanel(true);
933
- try {
934
- const nextId = cutsFile.cuts.reduce((m, c) => Math.max(m, c.id), 0) + 1;
935
- const panel = {
936
- id: nextId, shotType: "wide", description: "Text panel", characters: [],
937
- dialogue: [], narration: "", sfx: "",
938
- cleanImagePath: null, finalImagePath: null, exportedAt: null,
939
- uploadedCid: null, uploadedUrl: null, overlays: [],
940
- kind: "text" as const, background: "#101820", aspectRatio: "4:5",
941
- };
942
- const updated = { ...cutsFile, cuts: [...cutsFile.cuts, panel] };
943
- const res = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}`, {
944
- method: "PUT",
945
- headers: { "Content-Type": "application/json" },
946
- body: JSON.stringify(updated),
947
- });
948
- if (res.ok) {
949
- setExpandedCut(nextId);
950
- await loadCuts();
951
- } else {
952
- const data = await res.json().catch(() => ({}));
953
- setSyncResult(data.error || "Could not add text panel");
1356
+ const addTextPanelAt = useCallback(
1357
+ async (insertIndex: number, openEditor = true) => {
1358
+ if (!cutsFile) return;
1359
+ setAddingPanel(true);
1360
+ try {
1361
+ const nextId = cutsFile.cuts.reduce((m, c) => Math.max(m, c.id), 0) + 1;
1362
+ const panel = {
1363
+ id: nextId,
1364
+ shotType: "wide",
1365
+ description: "Text panel",
1366
+ characters: [],
1367
+ dialogue: [],
1368
+ narration: "",
1369
+ sfx: "",
1370
+ cleanImagePath: null,
1371
+ finalImagePath: null,
1372
+ exportedAt: null,
1373
+ uploadedCid: null,
1374
+ uploadedUrl: null,
1375
+ overlays: [],
1376
+ kind: "text" as const,
1377
+ background: "#101820",
1378
+ aspectRatio: "4:5",
1379
+ };
1380
+ const nextCuts = [...cutsFile.cuts];
1381
+ nextCuts.splice(
1382
+ Math.max(0, Math.min(insertIndex, nextCuts.length)),
1383
+ 0,
1384
+ panel,
1385
+ );
1386
+ const updated = { ...cutsFile, cuts: nextCuts };
1387
+ const res = await authFetch(
1388
+ `/api/stories/${storyName}/cuts/${plotFile}`,
1389
+ {
1390
+ method: "PUT",
1391
+ headers: { "Content-Type": "application/json" },
1392
+ body: JSON.stringify(updated),
1393
+ },
1394
+ );
1395
+ if (res.ok) {
1396
+ if (openEditor) setEditingCutId(nextId);
1397
+ else setExpandedCut(nextId);
1398
+ await loadCuts();
1399
+ } else {
1400
+ const data = await res.json().catch(() => ({}));
1401
+ setSyncResult(data.error || "Could not add text panel");
1402
+ }
1403
+ } catch {
1404
+ setSyncResult("Could not add text panel");
954
1405
  }
955
- } catch {
956
- setSyncResult("Could not add text panel");
957
- }
958
- setAddingPanel(false);
959
- }, [cutsFile, authFetch, storyName, plotFile, loadCuts]);
1406
+ setAddingPanel(false);
1407
+ },
1408
+ [cutsFile, authFetch, storyName, plotFile, loadCuts],
1409
+ );
1410
+ const addTextPanel = useCallback(
1411
+ () => addTextPanelAt(cutsFile?.cuts.length ?? 0, true),
1412
+ [addTextPanelAt, cutsFile],
1413
+ );
960
1414
 
961
1415
  useEffect(() => {
962
1416
  loadCuts();
@@ -974,9 +1428,16 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
974
1428
  <p className="text-sm text-error font-medium">Invalid cuts file</p>
975
1429
  <p className="text-xs text-error">{error}</p>
976
1430
  <p className="text-xs text-muted">
977
- {plotFile}.cuts.json must follow the OWS v1 schema. Ask Claude to regenerate it using the v1 cuts schema from the cartoon writing instructions.
1431
+ {plotFile}.cuts.json must follow the OWS v1 schema. Ask Claude to
1432
+ regenerate it using the v1 cuts schema from the cartoon writing
1433
+ instructions.
978
1434
  </p>
979
- <button onClick={loadCuts} className="text-xs text-accent hover:text-accent-dim">Retry</button>
1435
+ <button
1436
+ onClick={loadCuts}
1437
+ className="text-xs text-accent hover:text-accent-dim"
1438
+ >
1439
+ Retry
1440
+ </button>
980
1441
  </div>
981
1442
  );
982
1443
  }
@@ -985,12 +1446,17 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
985
1446
  return (
986
1447
  <div className="p-4 text-center space-y-1">
987
1448
  <p className="text-sm text-muted">No cuts yet</p>
988
- <p className="text-xs text-muted">Ask Claude to create a cut plan for this episode.</p>
1449
+ <p className="text-xs text-muted">
1450
+ Ask Claude to create a cut plan for this episode.
1451
+ </p>
989
1452
  </div>
990
1453
  );
991
1454
  }
992
1455
 
993
- const editingCut = editingCutId !== null ? cutsFile.cuts.find((c) => c.id === editingCutId) : null;
1456
+ const editingCut =
1457
+ editingCutId !== null
1458
+ ? cutsFile.cuts.find((c) => c.id === editingCutId)
1459
+ : null;
994
1460
 
995
1461
  if (editingCut) {
996
1462
  return (
@@ -1000,20 +1466,37 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
1000
1466
  plotFile={plotFile}
1001
1467
  language={language}
1002
1468
  authFetch={authFetch}
1003
- onSave={async (overlays: Overlay[]) => {
1004
- const updated = { ...cutsFile, cuts: cutsFile.cuts.map((c) => c.id === editingCutId ? { ...c, overlays } : c) };
1005
- const res = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}`, {
1006
- method: "PUT",
1007
- headers: { "Content-Type": "application/json" },
1008
- body: JSON.stringify(updated),
1009
- });
1010
- if (!res.ok) {
1011
- const data = await res.json().catch(() => ({}));
1012
- throw new Error(data.error || "Failed to save overlays");
1469
+ targetLabel={
1470
+ isTextPanel(editingCut)
1471
+ ? `Between-scene card ${editingCut.id}`
1472
+ : `Cut ${String(editingCut.id).padStart(2, "0")}`
1473
+ }
1474
+ returnOnSave
1475
+ workspaceVisible={workspaceVisible}
1476
+ onToggleWorkspaceVisible={
1477
+ onWorkspaceVisibleChange
1478
+ ? () => onWorkspaceVisibleChange(!workspaceVisible)
1479
+ : undefined
1480
+ }
1481
+ onSave={async (overlays: Overlay[], aiDraft?: CutAiDraft | null) => {
1482
+ const updated = {
1483
+ ...cutsFile,
1484
+ cuts: cutsFile.cuts.map((c) =>
1485
+ c.id === editingCutId
1486
+ ? { ...c, overlays, aiDraft: aiDraft ?? c.aiDraft ?? null }
1487
+ : c,
1488
+ ),
1489
+ };
1490
+ const ok = await saveCutsFile(updated);
1491
+ if (!ok) {
1492
+ throw new Error("Failed to save overlays");
1013
1493
  }
1014
1494
  }}
1015
1495
  onExported={() => loadCuts()}
1016
- onClose={() => { setEditingCutId(null); loadCuts(); }}
1496
+ onClose={() => {
1497
+ setEditingCutId(null);
1498
+ loadCuts();
1499
+ }}
1017
1500
  />
1018
1501
  );
1019
1502
  }
@@ -1024,21 +1507,30 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
1024
1507
  acc[s]++;
1025
1508
  return acc;
1026
1509
  },
1027
- { missing: 0, clean: 0, lettered: 0, uploaded: 0, text: 0 } as Record<CutStatus, number>,
1510
+ { missing: 0, clean: 0, lettered: 0, uploaded: 0, text: 0 } as Record<
1511
+ CutStatus,
1512
+ number
1513
+ >,
1028
1514
  );
1029
1515
  // Text/interstitial panels need no clean image (#351), so the clean-assets
1030
1516
  // banner/claims reason about IMAGE cuts only — never the total cut count.
1031
1517
  const imageCutCount = cutsFile.cuts.filter((c) => !isTextPanel(c)).length;
1032
1518
  // #381: final images lettered by an older bubble renderer (separate-tail seam)
1033
1519
  // must be re-exported before publish. Only tailed speech bubbles are affected.
1034
- const staleTailIds = cutsFile.cuts.filter((c) => isStaleTailedExport(c)).map((c) => c.id);
1520
+ const staleTailIds = cutsFile.cuts
1521
+ .filter((c) => isStaleTailedExport(c))
1522
+ .map((c) => c.id);
1035
1523
 
1036
1524
  // Guided "Finish episode" state (#414). The checklist's publish step reflects the
1037
1525
  // real on-chain status; markdownReady distinguishes uploaded-but-not-prepared from
1038
1526
  // a prepared/ready-to-publish episode. canFinish = something the Finish action can
1039
1527
  // still do: a final to upload, or uploads done but the sequence not yet prepared.
1040
- const finishChecklist = cartoonChecklist({ cuts: cutsFile.cuts, published: episodeState.published });
1041
- const uploadStepDone = finishChecklist.steps.find((s) => s.key === "upload")?.status === "done";
1528
+ const finishChecklist = cartoonChecklist({
1529
+ cuts: cutsFile.cuts,
1530
+ published: episodeState.published,
1531
+ });
1532
+ const uploadStepDone =
1533
+ finishChecklist.steps.find((s) => s.key === "upload")?.status === "done";
1042
1534
  const canFinish =
1043
1535
  cutsFile.cuts.some((ct) => ct.finalImagePath && !ct.uploadedCid) ||
1044
1536
  (uploadStepDone && !episodeState.markdownReady);
@@ -1048,7 +1540,9 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
1048
1540
  const conversionJobs = (assetDiagnostics ?? [])
1049
1541
  .filter((d) => d.state === "needs-conversion" && d.convertiblePng)
1050
1542
  .map((d) => ({ cutId: d.cutId, pngPath: d.convertiblePng as string }));
1051
- const conversionByCut = new Map(conversionJobs.map((j) => [j.cutId, j.pngPath]));
1543
+ const conversionByCut = new Map(
1544
+ conversionJobs.map((j) => [j.cutId, j.pngPath]),
1545
+ );
1052
1546
  const conversionIssues = (assetDiagnostics ?? [])
1053
1547
  .filter((d) => d.state === "needs-conversion" && d.issue)
1054
1548
  .map((d) => d.issue as string);
@@ -1057,119 +1551,211 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
1057
1551
  // milestones, not internal fields: artwork found (any clean image incl. a draft
1058
1552
  // PNG), converted (publishable WebP/JPEG), lettered (bubbles placed/exported),
1059
1553
  // uploaded. PNG-only cuts read as "artwork found" but not yet "converted".
1060
- const episodeLabel = fileName === "genesis.md"
1061
- ? "Genesis / Episode 1"
1062
- : `Episode ${parseInt(plotFile.match(/\d+/)?.[0] ?? "0", 10) + 1}`;
1063
- const episodeTitle = typeof (cutsFile as { title?: unknown }).title === "string" ? (cutsFile as { title?: string }).title : null;
1554
+ const episodeLabel =
1555
+ fileName === "genesis.md"
1556
+ ? "Genesis / Episode 1"
1557
+ : `Episode ${parseInt(plotFile.match(/\d+/)?.[0] ?? "0", 10) + 1}`;
1558
+ const episodeTitle =
1559
+ typeof (cutsFile as { title?: unknown }).title === "string"
1560
+ ? (cutsFile as { title?: string }).title
1561
+ : null;
1064
1562
  const imageCuts = cutsFile.cuts.filter((c) => !isTextPanel(c));
1065
1563
  const boardSummary = {
1066
1564
  cuts: cutsFile.cuts.length,
1067
- artwork: imageCuts.filter((c) => c.cleanImagePath || conversionByCut.has(c.id)).length,
1068
- converted: imageCuts.filter((c) => c.cleanImagePath && /\.(webp|jpe?g)$/i.test(c.cleanImagePath)).length,
1069
- lettered: cutsFile.cuts.filter((c) => (c.overlays?.length ?? 0) > 0 || !!c.finalImagePath).length,
1070
- uploaded: cutsFile.cuts.filter((c) => c.uploadedCid || c.uploadedUrl).length,
1565
+ artwork: imageCuts.filter(
1566
+ (c) => c.cleanImagePath || conversionByCut.has(c.id),
1567
+ ).length,
1568
+ converted: imageCuts.filter(
1569
+ (c) => c.cleanImagePath && /\.(webp|jpe?g)$/i.test(c.cleanImagePath),
1570
+ ).length,
1571
+ lettered: cutsFile.cuts.filter(
1572
+ (c) => (c.overlays?.length ?? 0) > 0 || !!c.finalImagePath,
1573
+ ).length,
1574
+ uploaded: cutsFile.cuts.filter((c) => c.uploadedCid || c.uploadedUrl)
1575
+ .length,
1071
1576
  };
1577
+ const aiDraftEligibleCount = cutsFile.cuts.filter(
1578
+ (cut) =>
1579
+ canDraftLettering(cut) &&
1580
+ (cut.overlays?.length ?? 0) === 0 &&
1581
+ !cut.finalImagePath &&
1582
+ !cut.uploadedCid &&
1583
+ !cut.uploadedUrl,
1584
+ ).length;
1072
1585
 
1073
1586
  return (
1074
- <div className="h-full min-h-[22rem] flex flex-col overflow-hidden" data-testid="cut-list-panel">
1587
+ <div
1588
+ className="h-full min-h-[22rem] flex flex-col overflow-hidden"
1589
+ data-testid="cut-list-panel"
1590
+ >
1075
1591
  {/* Episode header + creator-facing progress summary (#440). */}
1076
- <div className="px-3 py-2 border-b border-border flex-shrink-0" data-testid="cut-board-header">
1077
- <div className="flex items-center gap-2 text-xs">
1078
- <span className="font-serif text-foreground truncate">{episodeLabel}</span>
1079
- {episodeTitle && <span className="text-muted truncate">· {episodeTitle}</span>}
1080
- </div>
1081
- <div className="mt-0.5 text-[10px] text-muted" data-testid="cut-board-summary">
1082
- {boardSummary.cuts} cuts · {boardSummary.artwork} artwork found · {boardSummary.converted} converted · {boardSummary.lettered} lettered · {boardSummary.uploaded} uploaded
1592
+ <div
1593
+ className="px-3 py-2 border-b border-border flex-shrink-0"
1594
+ data-testid="cut-board-header"
1595
+ >
1596
+ <div className="flex items-start gap-3 justify-between">
1597
+ <div className="min-w-0">
1598
+ <div className="flex items-center gap-2 text-xs">
1599
+ <span className="font-serif text-foreground truncate">
1600
+ {episodeLabel}
1601
+ </span>
1602
+ {episodeTitle && (
1603
+ <span className="text-muted truncate">· {episodeTitle}</span>
1604
+ )}
1605
+ </div>
1606
+ <div
1607
+ className="mt-0.5 text-[10px] text-muted"
1608
+ data-testid="cut-board-summary"
1609
+ >
1610
+ {boardSummary.cuts} cuts · {boardSummary.artwork} artwork found ·{" "}
1611
+ {boardSummary.converted} converted · {boardSummary.lettered}{" "}
1612
+ lettered · {boardSummary.uploaded} uploaded
1613
+ </div>
1614
+ </div>
1615
+ {aiDraftEligibleCount > 0 && (
1616
+ <button
1617
+ onClick={draftAllUnletteredCuts}
1618
+ disabled={aiDraftingAll}
1619
+ data-testid="ai-draft-all-btn"
1620
+ className="px-2.5 py-1 text-[11px] rounded border border-accent/40 text-accent hover:bg-accent/5 disabled:opacity-50"
1621
+ >
1622
+ {aiDraftingAll
1623
+ ? "Drafting…"
1624
+ : `AI draft all unlettered (${aiDraftEligibleCount})`}
1625
+ </button>
1626
+ )}
1083
1627
  </div>
1084
1628
  </div>
1085
1629
  {/* Lower-level / manual controls, collapsed by default so the board stays
1086
1630
  focused on per-cut actions (#440). The guided Finish flow + per-cut
1087
1631
  primary actions are the main path; these stay for power users. */}
1088
- <details className="border-b border-border flex-shrink-0" data-testid="cut-advanced">
1089
- <summary className="px-3 py-1.5 text-[10px] text-muted cursor-pointer hover:text-foreground">Technical details</summary>
1090
- <div className="px-3 py-2 flex flex-wrap items-center gap-2 text-[10px]">
1091
- <span className="font-mono text-muted">{cutsFile.cuts.length} cuts</span>
1092
- {stats.missing > 0 && <span className="text-muted">{stats.missing} missing</span>}
1093
- {stats.clean > 0 && <span className="text-green-700">{stats.clean} clean</span>}
1094
- {stats.lettered > 0 && <span className="text-amber-700">{stats.lettered} lettered</span>}
1095
- {stats.uploaded > 0 && <span className="text-green-700">{stats.uploaded} uploaded</span>}
1096
- {stats.text > 0 && <span className="text-accent">{stats.text} text {stats.text === 1 ? "panel" : "panels"}</span>}
1097
- <button
1098
- onClick={async () => {
1099
- setGenerating(true);
1100
- setGenWarnings([]);
1101
- try {
1102
- const res = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}/generate-markdown`, { method: "POST" });
1103
- if (res.ok) {
1104
- const data = await res.json();
1105
- setGenWarnings(data.warnings || []);
1632
+ <details
1633
+ className="border-b border-border flex-shrink-0"
1634
+ data-testid="cut-advanced"
1635
+ >
1636
+ <summary className="px-3 py-1.5 text-[10px] text-muted cursor-pointer hover:text-foreground">
1637
+ Technical details
1638
+ </summary>
1639
+ <div className="px-3 py-2 flex flex-wrap items-center gap-2 text-[10px]">
1640
+ <span className="font-mono text-muted">
1641
+ {cutsFile.cuts.length} cuts
1642
+ </span>
1643
+ {stats.missing > 0 && (
1644
+ <span className="text-muted">{stats.missing} missing</span>
1645
+ )}
1646
+ {stats.clean > 0 && (
1647
+ <span className="text-green-700">{stats.clean} clean</span>
1648
+ )}
1649
+ {stats.lettered > 0 && (
1650
+ <span className="text-amber-700">{stats.lettered} lettered</span>
1651
+ )}
1652
+ {stats.uploaded > 0 && (
1653
+ <span className="text-green-700">{stats.uploaded} uploaded</span>
1654
+ )}
1655
+ {stats.text > 0 && (
1656
+ <span className="text-accent">
1657
+ {stats.text} text {stats.text === 1 ? "panel" : "panels"}
1658
+ </span>
1659
+ )}
1660
+ <button
1661
+ onClick={async () => {
1662
+ setGenerating(true);
1663
+ setGenWarnings([]);
1664
+ try {
1665
+ const res = await authFetch(
1666
+ `/api/stories/${storyName}/cuts/${plotFile}/generate-markdown`,
1667
+ { method: "POST" },
1668
+ );
1669
+ if (res.ok) {
1670
+ const data = await res.json();
1671
+ setGenWarnings(data.warnings || []);
1672
+ }
1673
+ } catch {
1674
+ /* ignore */
1106
1675
  }
1107
- } catch { /* ignore */ }
1108
- setGenerating(false);
1109
- }}
1110
- disabled={generating}
1111
- className="ml-auto px-2 py-0.5 border border-accent/30 text-accent rounded hover:bg-accent/5 disabled:opacity-50"
1112
- data-testid="generate-markdown-btn"
1113
- title="Build the publish-ready episode from the uploaded cut images"
1114
- >
1115
- {generating ? "Preparing…" : "Prepare episode for publish"}
1116
- </button>
1117
- <button
1118
- onClick={addTextPanel}
1119
- disabled={addingPanel}
1120
- className="px-2 py-0.5 border border-accent/30 text-accent rounded hover:bg-accent/5 disabled:opacity-50"
1121
- data-testid="add-text-panel-btn"
1122
- title="Insert a narration/title card between art panels — a solid card exported as a final image panel, no drawing needed"
1123
- >
1124
- {addingPanel ? "Adding…" : "Add narration/text panel"}
1125
- </button>
1126
- <button
1127
- onClick={refreshAssets}
1128
- disabled={rescanning}
1129
- className="px-2 py-0.5 border border-border text-muted rounded hover:border-accent hover:text-accent disabled:opacity-50"
1130
- data-testid="refresh-assets-btn"
1131
- title="Re-check the story folder for agent-generated images and report each cut's asset state — read only, nothing is uploaded or published"
1132
- >
1133
- {rescanning ? "Checking…" : "Refresh assets"}
1134
- </button>
1135
- <button
1136
- onClick={syncCleanImages}
1137
- disabled={syncing}
1138
- className="px-2 py-0.5 border border-accent/30 text-accent rounded hover:bg-accent/5 disabled:opacity-50"
1139
- data-testid="sync-clean-btn"
1140
- >
1141
- {syncing ? "Syncing..." : "Sync clean images"}
1142
- </button>
1143
- <button
1144
- onClick={finishEpisode}
1145
- disabled={uploading || !cutsFile?.cuts.some((ct) => ct.finalImagePath && !ct.uploadedCid)}
1146
- className="px-2 py-0.5 border border-accent/30 text-accent rounded hover:bg-accent/5 disabled:opacity-50"
1147
- data-testid="upload-generate-btn"
1148
- title="Upload each cut's final lettered image, then prepare the episode for publishing"
1149
- >
1150
- {uploadProgress || "Upload & Prepare for Publish"}
1151
- </button>
1152
- </div>
1676
+ setGenerating(false);
1677
+ }}
1678
+ disabled={generating}
1679
+ className="ml-auto px-2 py-0.5 border border-accent/30 text-accent rounded hover:bg-accent/5 disabled:opacity-50"
1680
+ data-testid="generate-markdown-btn"
1681
+ title="Build the publish-ready episode from the uploaded cut images"
1682
+ >
1683
+ {generating ? "Preparing…" : "Prepare episode for publish"}
1684
+ </button>
1685
+ <button
1686
+ onClick={addTextPanel}
1687
+ disabled={addingPanel}
1688
+ className="px-2 py-0.5 border border-accent/30 text-accent rounded hover:bg-accent/5 disabled:opacity-50"
1689
+ data-testid="add-text-panel-btn"
1690
+ title="Insert a narration/title card between art panels — a solid card exported as a final image panel, no drawing needed"
1691
+ >
1692
+ {addingPanel ? "Adding…" : "Add narration/text panel"}
1693
+ </button>
1694
+ <button
1695
+ onClick={refreshAssets}
1696
+ disabled={rescanning}
1697
+ className="px-2 py-0.5 border border-border text-muted rounded hover:border-accent hover:text-accent disabled:opacity-50"
1698
+ data-testid="refresh-assets-btn"
1699
+ title="Re-check the story folder for agent-generated images and report each cut's asset state — read only, nothing is uploaded or published"
1700
+ >
1701
+ {rescanning ? "Checking…" : "Refresh assets"}
1702
+ </button>
1703
+ <button
1704
+ onClick={syncCleanImages}
1705
+ disabled={syncing}
1706
+ className="px-2 py-0.5 border border-accent/30 text-accent rounded hover:bg-accent/5 disabled:opacity-50"
1707
+ data-testid="sync-clean-btn"
1708
+ >
1709
+ {syncing ? "Syncing..." : "Sync clean images"}
1710
+ </button>
1711
+ <button
1712
+ onClick={finishEpisode}
1713
+ disabled={
1714
+ uploading ||
1715
+ !cutsFile?.cuts.some((ct) => ct.finalImagePath && !ct.uploadedCid)
1716
+ }
1717
+ className="px-2 py-0.5 border border-accent/30 text-accent rounded hover:bg-accent/5 disabled:opacity-50"
1718
+ data-testid="upload-generate-btn"
1719
+ title="Upload each cut's final lettered image, then prepare the episode for publishing"
1720
+ >
1721
+ {uploadProgress || "Upload & Prepare for Publish"}
1722
+ </button>
1723
+ </div>
1153
1724
  </details>
1154
1725
  {/* Plain-language workflow + text-panel explainer (#360) so a non-technical
1155
1726
  writer understands the order of operations and what a text panel is. */}
1156
- <div
1157
- className="px-3 py-2 border-b border-border bg-surface/40 flex-shrink-0"
1727
+ <details
1728
+ className="px-3 py-1.5 border-b border-border bg-surface/40 flex-shrink-0"
1158
1729
  data-testid="cartoon-workflow-help"
1159
1730
  >
1160
- <div className="flex flex-wrap items-center gap-1.5 text-[10px] text-muted">
1161
- <span className="rounded-full border border-border bg-background px-2 py-0.5 text-foreground">1. Letter</span>
1162
- <span aria-hidden>→</span>
1163
- <span className="rounded-full border border-border bg-background px-2 py-0.5 text-foreground">2. Export</span>
1164
- <span aria-hidden>→</span>
1165
- <span className="rounded-full border border-border bg-background px-2 py-0.5 text-foreground">3. Upload</span>
1166
- <span aria-hidden>→</span>
1167
- <span className="rounded-full border border-border bg-background px-2 py-0.5 text-foreground">4. Prepare episode for publish</span>
1168
- </div>
1169
- <div className="mt-1 text-[10px] text-muted">
1170
- Use <span className="text-accent">Add narration/text panel</span> for a narration or title card. It becomes a solid card exported as a final image.
1731
+ <summary className="cursor-pointer select-none text-[10px] text-muted hover:text-foreground">
1732
+ Cut workflow help
1733
+ </summary>
1734
+ <div className="mt-1.5">
1735
+ <div className="flex flex-wrap items-center gap-1.5 text-[10px] text-muted">
1736
+ <span className="rounded-full border border-border bg-background px-2 py-0.5 text-foreground">
1737
+ 1. Letter
1738
+ </span>
1739
+ <span aria-hidden>→</span>
1740
+ <span className="rounded-full border border-border bg-background px-2 py-0.5 text-foreground">
1741
+ 2. Export
1742
+ </span>
1743
+ <span aria-hidden>→</span>
1744
+ <span className="rounded-full border border-border bg-background px-2 py-0.5 text-foreground">
1745
+ 3. Upload
1746
+ </span>
1747
+ <span aria-hidden>→</span>
1748
+ <span className="rounded-full border border-border bg-background px-2 py-0.5 text-foreground">
1749
+ 4. Prepare episode for publish
1750
+ </span>
1751
+ </div>
1752
+ <div className="mt-1 text-[10px] text-muted">
1753
+ Use <span className="text-accent">Add narration/text panel</span>{" "}
1754
+ for a narration or title card. It becomes a solid card exported as a
1755
+ final image.
1756
+ </div>
1171
1757
  </div>
1172
- </div>
1758
+ </details>
1173
1759
  {/* Stale bubble-renderer warning (#381): a final image lettered before the
1174
1760
  current seamless-tail renderer may show the old separate-tail seam.
1175
1761
  Mark those cuts so the writer re-exports (open lettering → Export) and
@@ -1179,23 +1765,38 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
1179
1765
  className="px-3 py-1.5 border-b border-amber-500/40 bg-amber-500/10 text-[10px] text-amber-700 flex-shrink-0"
1180
1766
  data-testid="stale-bubble-export-warning"
1181
1767
  >
1182
- {staleTailIds.length === 1 ? "Cut" : "Cuts"} {staleTailIds.join(", ")} {staleTailIds.length === 1 ? "was" : "were"} lettered with an older speech-bubble style whose tail can show a visible seam. Re-export {staleTailIds.length === 1 ? "it" : "them"} (open lettering → Export) and re-upload before publishing so the bubble tails are seamless.
1768
+ {staleTailIds.length === 1 ? "Cut" : "Cuts"} {staleTailIds.join(", ")}{" "}
1769
+ {staleTailIds.length === 1 ? "was" : "were"} lettered with an older
1770
+ speech-bubble style whose tail can show a visible seam. Re-export{" "}
1771
+ {staleTailIds.length === 1 ? "it" : "them"} (open lettering → Export)
1772
+ and re-upload before publishing so the bubble tails are seamless.
1183
1773
  </div>
1184
1774
  )}
1185
1775
  {/* Clean-asset generation done-state (#311): when every cut has a present,
1186
1776
  valid clean image, surface a clear "done" signal so the operator knows
1187
1777
  Codex generation is complete even if the terminal session is still
1188
1778
  connected — no more guessing whether it is still Working. */}
1189
- {detectConfirmed && imageCutCount > 0 && stats.missing === 0 && staleByCut.size === 0 && (
1190
- <div className="px-3 py-1 border-b border-border bg-green-600/10 text-[10px] text-green-700 flex items-center gap-1 flex-shrink-0" data-testid="clean-assets-ready">
1191
- <span aria-hidden>✓</span>
1192
- <span>
1193
- All {imageCutCount} clean image{imageCutCount === 1 ? "" : "s"} present — clean-asset generation is complete. Ready for lettering in OWS.
1194
- </span>
1195
- </div>
1196
- )}
1779
+ {detectConfirmed &&
1780
+ imageCutCount > 0 &&
1781
+ stats.missing === 0 &&
1782
+ staleByCut.size === 0 && (
1783
+ <div
1784
+ className="px-3 py-1 border-b border-border bg-green-600/10 text-[10px] text-green-700 flex items-center gap-1 flex-shrink-0"
1785
+ data-testid="clean-assets-ready"
1786
+ >
1787
+ <span aria-hidden>✓</span>
1788
+ <span>
1789
+ All {imageCutCount} clean image{imageCutCount === 1 ? "" : "s"}{" "}
1790
+ present — clean-asset generation is complete. Ready for lettering
1791
+ in OWS.
1792
+ </span>
1793
+ </div>
1794
+ )}
1197
1795
  {syncResult && (
1198
- <div className="px-3 py-1 border-b border-border text-[10px] text-muted flex-shrink-0" data-testid="sync-result">
1796
+ <div
1797
+ className="px-3 py-1 border-b border-border text-[10px] text-muted flex-shrink-0"
1798
+ data-testid="sync-result"
1799
+ >
1199
1800
  {syncResult}
1200
1801
  </div>
1201
1802
  )}
@@ -1204,10 +1805,17 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
1204
1805
  than red "Unsupported extension" errors. The raw reasons stay available
1205
1806
  under a collapsed "Technical details" disclosure. */}
1206
1807
  {conversionJobs.length > 0 && (
1207
- <div className="px-3 py-2 border-b border-amber-500/40 bg-amber-500/10 text-[11px] flex-shrink-0" data-testid="convert-artwork">
1808
+ <div
1809
+ className="px-3 py-2 border-b border-amber-500/40 bg-amber-500/10 text-[11px] flex-shrink-0"
1810
+ data-testid="convert-artwork"
1811
+ >
1208
1812
  <div className="flex items-center gap-2 flex-wrap">
1209
- <span className="font-medium text-amber-700" data-testid="convert-artwork-count">
1210
- {conversionJobs.length} PNG image{conversionJobs.length === 1 ? "" : "s"} found
1813
+ <span
1814
+ className="font-medium text-amber-700"
1815
+ data-testid="convert-artwork-count"
1816
+ >
1817
+ {conversionJobs.length} PNG image
1818
+ {conversionJobs.length === 1 ? "" : "s"} found
1211
1819
  </span>
1212
1820
  <button
1213
1821
  onClick={() => convertAll(conversionJobs)}
@@ -1219,14 +1827,26 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
1219
1827
  </button>
1220
1828
  </div>
1221
1829
  <p className="mt-1 text-[10px] text-muted">
1222
- PNG artwork is fine while drafting. Convert it before lettering/export so PlotLink can publish it safely.
1830
+ PNG artwork is fine while drafting. Convert it before
1831
+ lettering/export so PlotLink can publish it safely.
1223
1832
  </p>
1224
- {convertResult && <p className="mt-1 text-[10px] text-muted" data-testid="convert-result">{convertResult}</p>}
1833
+ {convertResult && (
1834
+ <p
1835
+ className="mt-1 text-[10px] text-muted"
1836
+ data-testid="convert-result"
1837
+ >
1838
+ {convertResult}
1839
+ </p>
1840
+ )}
1225
1841
  {conversionIssues.length > 0 && (
1226
1842
  <details className="mt-1" data-testid="convert-technical-details">
1227
- <summary className="text-[10px] text-muted cursor-pointer">Technical details</summary>
1843
+ <summary className="text-[10px] text-muted cursor-pointer">
1844
+ Technical details
1845
+ </summary>
1228
1846
  <ul className="mt-1 ml-3 list-disc text-[10px] text-muted">
1229
- {conversionIssues.map((m, i) => <li key={i}>{m}</li>)}
1847
+ {conversionIssues.map((m, i) => (
1848
+ <li key={i}>{m}</li>
1849
+ ))}
1230
1850
  </ul>
1231
1851
  </details>
1232
1852
  )}
@@ -1236,22 +1856,37 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
1236
1856
  state tally + a precise per-cut reason when a recorded path is broken,
1237
1857
  so "files exist but aren't shown" / a typoed path is a clear diagnostic
1238
1858
  rather than a generic publish warning. */}
1239
- {assetDiagnostics && assetDiagnostics.length > 0 && (() => {
1240
- const s = summarizeAssetDiagnostics(assetDiagnostics);
1241
- const missing = assetDiagnostics.filter((d) => d.state === "missing");
1242
- return (
1243
- <div className="px-3 py-1.5 border-b border-border bg-surface/40 text-[10px] flex-shrink-0" data-testid="asset-diagnostics">
1244
- <span className="text-muted" data-testid="asset-diag-summary">
1245
- Assets: {s.uploaded} uploaded · {s.finalReady} final · {s.cleanReady} clean · {s.planned} planned{s.needsConversion > 0 ? ` · ${s.needsConversion} needs conversion` : ""}{s.missing > 0 ? ` · ${s.missing} missing` : ""}
1246
- </span>
1247
- {missing.length > 0 && (
1248
- <ul className="mt-1 ml-3 list-disc text-error" data-testid="asset-diag-issues">
1249
- {missing.map((d) => <li key={d.cutId}>{d.issue}</li>)}
1250
- </ul>
1251
- )}
1252
- </div>
1253
- );
1254
- })()}
1859
+ {assetDiagnostics &&
1860
+ assetDiagnostics.length > 0 &&
1861
+ (() => {
1862
+ const s = summarizeAssetDiagnostics(assetDiagnostics);
1863
+ const missing = assetDiagnostics.filter((d) => d.state === "missing");
1864
+ return (
1865
+ <div
1866
+ className="px-3 py-1.5 border-b border-border bg-surface/40 text-[10px] flex-shrink-0"
1867
+ data-testid="asset-diagnostics"
1868
+ >
1869
+ <span className="text-muted" data-testid="asset-diag-summary">
1870
+ Assets: {s.uploaded} uploaded · {s.finalReady} final ·{" "}
1871
+ {s.cleanReady} clean · {s.planned} planned
1872
+ {s.needsConversion > 0
1873
+ ? ` · ${s.needsConversion} needs conversion`
1874
+ : ""}
1875
+ {s.missing > 0 ? ` · ${s.missing} missing` : ""}
1876
+ </span>
1877
+ {missing.length > 0 && (
1878
+ <ul
1879
+ className="mt-1 ml-3 list-disc text-error"
1880
+ data-testid="asset-diag-issues"
1881
+ >
1882
+ {missing.map((d) => (
1883
+ <li key={d.cutId}>{d.issue}</li>
1884
+ ))}
1885
+ </ul>
1886
+ )}
1887
+ </div>
1888
+ );
1889
+ })()}
1255
1890
  {/* Guided Finish-episode flow (#414): writer-language step status, one primary
1256
1891
  "Finish episode" action that uploads finals then prepares the publish
1257
1892
  markdown in order, and any blockers grouped by the step that fixes them —
@@ -1268,32 +1903,107 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
1268
1903
  published={episodeState.published}
1269
1904
  />
1270
1905
 
1271
- {/* Cut list */}
1272
- <div className="flex-1 min-h-56 overflow-y-auto p-3 space-y-2" data-testid="cut-list-scroll">
1273
- {cutsFile.cuts.map((cut) => (
1274
- <CutRow
1275
- key={cut.id}
1276
- cut={cut}
1277
- storyName={storyName}
1278
- plotFile={plotFile}
1279
- expanded={expandedCut === cut.id}
1280
- onToggle={() => setExpandedCut(expandedCut === cut.id ? null : cut.id)}
1281
- authFetch={authFetch}
1282
- onUpdated={() => { loadCuts(); loadDetect(); loadDiagnostics(); }}
1283
- onOpenEditor={() => setEditingCutId(cut.id)}
1284
- detectedLocalClean={detected.has(cut.id)}
1285
- onSyncClean={syncCleanImages}
1286
- syncing={syncing}
1287
- staleMessages={staleByCut.get(cut.id) ?? []}
1288
- onRepairStale={repairStalePaths}
1289
- repairing={repairing}
1290
- conversionPng={conversionByCut.get(cut.id) ?? null}
1291
- onConvert={convertCut}
1292
- converting={converting}
1293
- rowRef={(el) => { if (el) rowRefs.current.set(cut.id, el); else rowRefs.current.delete(cut.id); }}
1294
- />
1906
+ {/* Full cut review (#488): all clean cuts are shown vertically first, with
1907
+ explicit between-scene slots for narration/title cards. */}
1908
+ <div
1909
+ className="flex-1 min-h-56 overflow-y-auto p-3 space-y-3"
1910
+ data-testid="lettering-review-board"
1911
+ >
1912
+ {cutsFile.cuts.map((cut, index) => (
1913
+ <Fragment key={cut.id}>
1914
+ <BetweenSceneSlot
1915
+ index={index}
1916
+ beforeLabel={
1917
+ index === 0
1918
+ ? "Episode opening"
1919
+ : `After cut ${cutsFile.cuts[index - 1]?.id}`
1920
+ }
1921
+ afterLabel={`Before cut ${cut.id}`}
1922
+ disabled={addingPanel}
1923
+ onAdd={() => addTextPanelAt(index)}
1924
+ />
1925
+ <CutRow
1926
+ cut={cut}
1927
+ storyName={storyName}
1928
+ plotFile={plotFile}
1929
+ expanded={expandedCut === cut.id}
1930
+ onToggle={() =>
1931
+ setExpandedCut(expandedCut === cut.id ? null : cut.id)
1932
+ }
1933
+ authFetch={authFetch}
1934
+ onUpdated={() => {
1935
+ loadCuts();
1936
+ loadDetect();
1937
+ loadDiagnostics();
1938
+ }}
1939
+ onOpenEditor={() => setEditingCutId(cut.id)}
1940
+ detectedLocalClean={detected.has(cut.id)}
1941
+ onSyncClean={syncCleanImages}
1942
+ syncing={syncing}
1943
+ onAiDraft={() => {
1944
+ void draftCutWithAi(cut.id, { openEditor: true });
1945
+ }}
1946
+ aiDrafting={aiDraftingCutId === cut.id}
1947
+ staleMessages={staleByCut.get(cut.id) ?? []}
1948
+ onRepairStale={repairStalePaths}
1949
+ repairing={repairing}
1950
+ conversionPng={conversionByCut.get(cut.id) ?? null}
1951
+ onConvert={convertCut}
1952
+ converting={converting}
1953
+ rowRef={(el) => {
1954
+ if (el) rowRefs.current.set(cut.id, el);
1955
+ else rowRefs.current.delete(cut.id);
1956
+ }}
1957
+ />
1958
+ </Fragment>
1295
1959
  ))}
1960
+ <BetweenSceneSlot
1961
+ index={cutsFile.cuts.length}
1962
+ beforeLabel={`After cut ${cutsFile.cuts[cutsFile.cuts.length - 1]?.id}`}
1963
+ afterLabel="Episode ending"
1964
+ disabled={addingPanel}
1965
+ onAdd={() => addTextPanelAt(cutsFile.cuts.length)}
1966
+ />
1296
1967
  </div>
1297
1968
  </div>
1298
1969
  );
1299
1970
  }
1971
+
1972
+ function BetweenSceneSlot({
1973
+ index,
1974
+ beforeLabel,
1975
+ afterLabel,
1976
+ disabled,
1977
+ onAdd,
1978
+ }: {
1979
+ index: number;
1980
+ beforeLabel: string;
1981
+ afterLabel: string;
1982
+ disabled: boolean;
1983
+ onAdd: () => void;
1984
+ }) {
1985
+ return (
1986
+ <div
1987
+ className="rounded border border-dashed border-border bg-surface/35 px-3 py-2 text-[11px] text-muted flex items-center gap-3"
1988
+ data-testid={`between-scene-slot-${index}`}
1989
+ >
1990
+ <span className="min-w-0 flex-1">
1991
+ <span className="font-medium text-foreground">
1992
+ Between-scene lettering
1993
+ </span>
1994
+ <span className="block truncate">
1995
+ {beforeLabel} · {afterLabel}
1996
+ </span>
1997
+ </span>
1998
+ <button
1999
+ type="button"
2000
+ onClick={onAdd}
2001
+ disabled={disabled}
2002
+ className="flex-shrink-0 rounded border border-accent/40 px-2.5 py-1 text-[11px] font-medium text-accent hover:bg-accent/5 disabled:opacity-50"
2003
+ data-testid={`add-between-scene-${index}`}
2004
+ >
2005
+ Add card
2006
+ </button>
2007
+ </div>
2008
+ );
2009
+ }