plotlink-ows 1.2.95 → 1.2.96

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,12 +4,31 @@ import { AssetImage, assetUrl } from "./asset-image";
4
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,7 +208,11 @@ function boardStatus(cut: Cut, needsConversion: boolean, hasStale: boolean): Boa
141
208
  return { key: "needs-image", label: "Needs image", tone: "muted" };
142
209
  }
143
210
 
144
- function letteringReviewState(cut: Cut): { label: string; detail: string; tone: BoardTone } {
211
+ function letteringReviewState(cut: Cut): {
212
+ label: string;
213
+ detail: string;
214
+ tone: BoardTone;
215
+ } {
145
216
  if (cut.uploadedCid || cut.uploadedUrl) {
146
217
  return { label: "Complete", detail: "Final image uploaded", tone: "green" };
147
218
  }
@@ -149,15 +220,45 @@ function letteringReviewState(cut: Cut): { label: string; detail: string; tone:
149
220
  return { label: "Exported", detail: "Ready to upload", tone: "green" };
150
221
  }
151
222
  if ((cut.overlays?.length ?? 0) > 0) {
152
- return { label: "Draft saved", detail: `${cut.overlays.length} overlay${cut.overlays.length === 1 ? "" : "s"} placed`, tone: "amber" };
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
+ };
153
242
  }
154
243
  if (isTextPanel(cut)) {
155
- return { label: "Between-scene card", detail: "Open to add narration or title text", tone: "accent" };
244
+ return {
245
+ label: "Between-scene card",
246
+ detail: "Open to add narration or title text",
247
+ tone: "accent",
248
+ };
156
249
  }
157
250
  if (cut.cleanImagePath) {
158
- return { label: "Unlettered", detail: "Clean art ready for bubble placement", tone: "muted" };
251
+ return {
252
+ label: "Unlettered",
253
+ detail: "Clean art ready for bubble placement",
254
+ tone: "muted",
255
+ };
159
256
  }
160
- return { label: "Needs artwork", detail: "Add or sync clean art first", tone: "muted" };
257
+ return {
258
+ label: "Needs artwork",
259
+ detail: "Add or sync clean art first",
260
+ tone: "muted",
261
+ };
161
262
  }
162
263
 
163
264
  function CutRow({
@@ -172,6 +273,8 @@ function CutRow({
172
273
  detectedLocalClean,
173
274
  onSyncClean,
174
275
  syncing,
276
+ onAiDraft,
277
+ aiDrafting,
175
278
  staleMessages,
176
279
  onRepairStale,
177
280
  repairing,
@@ -191,6 +294,8 @@ function CutRow({
191
294
  detectedLocalClean: boolean;
192
295
  onSyncClean: () => void;
193
296
  syncing: boolean;
297
+ onAiDraft: () => void;
298
+ aiDrafting: boolean;
194
299
  staleMessages: string[];
195
300
  onRepairStale: () => void;
196
301
  repairing: boolean;
@@ -228,46 +333,54 @@ function CutRow({
228
333
 
229
334
  // Returns true on a successful upload so callers (e.g. the Codex import picker)
230
335
  // can close themselves only when the clean image was actually recorded.
231
- const handleUpload = useCallback(async (file: File): Promise<boolean> => {
232
- setUploading(true);
233
- setUploadError(null);
234
- try {
235
- // Accept Codex-generated images (e.g. large PNG) by converting/compressing
236
- // them to a compliant WebP/JPEG <=1MB in the browser first (#301). An
237
- // already-compliant WebP/JPEG is passed through untouched, so the manual
238
- // upload behavior is unchanged. A source that cannot be decoded or
239
- // compressed under 1MB surfaces a clear error instead of saving anything.
240
- let upload: Blob = file;
241
- if (!isCompliantImage(file)) {
242
- try {
243
- upload = await importImageToCompliantBlob(file);
244
- } catch (err) {
245
- setUploadError(err instanceof Error ? err.message : "Could not import image");
246
- 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
+ }
247
356
  }
248
- }
249
357
 
250
- const ext = upload.type === "image/jpeg" ? "jpg" : "webp";
251
- const formData = new FormData();
252
- formData.append("file", new File([upload], `clean.${ext}`, { type: upload.type }));
253
- const res = await authFetch(
254
- `/api/stories/${storyName}/cuts/${plotFile}/upload-clean/${cut.id}`,
255
- { method: "POST", body: formData },
256
- );
257
- if (!res.ok) {
258
- const data = await res.json();
259
- 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");
260
377
  return false;
378
+ } finally {
379
+ setUploading(false);
261
380
  }
262
- onUpdated();
263
- return true;
264
- } catch {
265
- setUploadError("Upload failed");
266
- return false;
267
- } finally {
268
- setUploading(false);
269
- }
270
- }, [authFetch, storyName, plotFile, cut.id, onUpdated]);
381
+ },
382
+ [authFetch, storyName, plotFile, cut.id, onUpdated],
383
+ );
271
384
 
272
385
  // Creator-facing board status + the single primary action for this cut (#440).
273
386
  const board = boardStatus(cut, needsConversion, hasStale);
@@ -279,15 +392,49 @@ function CutRow({
279
392
  // first-class Manual/AI-draft lettering choice instead of a single button.
280
393
  const bubblesPlaced = cut.overlays?.length ?? 0;
281
394
  const atLetteringStage =
282
- !isTextPanel(cut) && !!cut.cleanImagePath && !cut.finalImagePath &&
283
- !cut.uploadedCid && !cut.uploadedUrl && !hasStale && !needsConversion;
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";
284
411
 
285
412
  const primary: { label: string; onClick: () => void; testid: string } | null =
286
- board.key === "convert" ? { label: convertingThis ? "Converting…" : "Convert image", onClick: handleConvertThis, testid: `card-convert-${cut.id}` }
287
- : board.key === "review" ? { label: "Review cut", onClick: onOpenEditor, testid: `card-review-${cut.id}` }
288
- : board.key === "text" ? { label: "Add captions", onClick: onOpenEditor, testid: `card-letter-${cut.id}` }
289
- : board.key === "needs-image" ? { label: "Add artwork", onClick: onToggle, testid: `card-addart-${cut.id}` }
290
- : 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
291
438
  const reviewState = letteringReviewState(cut);
292
439
 
293
440
  return (
@@ -300,10 +447,19 @@ function CutRow({
300
447
  action, plus an "Open details" toggle for the technical controls (#440). */}
301
448
  <div className="px-3 py-2 space-y-2" data-testid={`cut-card-${cut.id}`}>
302
449
  <div className="flex items-center gap-2 text-sm">
303
- <span className={`w-2 h-2 rounded-full flex-shrink-0 ${BOARD_TONE_DOT[board.tone]}`} />
304
- <span className="font-medium text-xs text-foreground">Cut {String(cut.id).padStart(2, "0")}</span>
305
- <span className="font-mono text-[10px] text-muted">· {cut.shotType}</span>
306
- <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
+ >
307
463
  {board.label}
308
464
  </span>
309
465
  </div>
@@ -316,8 +472,13 @@ function CutRow({
316
472
  className="w-full max-h-[32rem] object-contain rounded border border-border bg-white"
317
473
  />
318
474
  ) : (
319
- <div className="w-full min-h-28 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}`}>
320
- {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"}
321
482
  </div>
322
483
  )}
323
484
  <div
@@ -336,23 +497,47 @@ function CutRow({
336
497
  </button>
337
498
  <div className="flex items-center gap-2 flex-wrap">
338
499
  {atLetteringStage ? (
339
- <button
340
- onClick={onOpenEditor}
341
- data-testid={`add-bubbles-${cut.id}`}
342
- className="px-2.5 py-1 text-[11px] font-medium rounded bg-accent text-white hover:bg-accent-dim"
343
- >
344
- {bubblesPlaced > 0 ? "Review lettering" : "Open focused editor"}
345
- </button>
500
+ <>
501
+ <button
502
+ onClick={onOpenEditor}
503
+ data-testid={`add-bubbles-${cut.id}`}
504
+ className="px-2.5 py-1 text-[11px] font-medium rounded bg-accent text-white hover:bg-accent-dim"
505
+ >
506
+ {bubblesPlaced > 0 ? "Review lettering" : "Open focused editor"}
507
+ </button>
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
+ </>
346
519
  ) : primary ? (
347
520
  <button
348
521
  onClick={primary.onClick}
349
- disabled={board.key === "convert" && (convertingThis || converting)}
522
+ disabled={
523
+ board.key === "convert" && (convertingThis || converting)
524
+ }
350
525
  data-testid={primary.testid}
351
526
  className="px-2.5 py-1 text-[11px] font-medium rounded bg-accent text-white hover:bg-accent-dim disabled:opacity-50"
352
527
  >
353
528
  {primary.label}
354
529
  </button>
355
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
+ )}
356
541
  <button
357
542
  onClick={onToggle}
358
543
  data-testid={`cut-details-${cut.id}`}
@@ -374,9 +559,13 @@ function CutRow({
374
559
  draft art). The raw unsupported-extension reason stays hidden in the
375
560
  Convert artwork banner's technical details. */}
376
561
  {needsConversion && (
377
- <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
+ >
378
566
  <p className="text-[11px] text-amber-800">
379
- 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.
380
569
  </p>
381
570
  <button
382
571
  onClick={handleConvertThis}
@@ -389,9 +578,14 @@ function CutRow({
389
578
  </div>
390
579
  )}
391
580
  {hasStale && !needsConversion && (
392
- <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
+ >
393
585
  {staleMessages.map((m, i) => (
394
- <p key={i} className="text-[11px] text-error">{m}</p>
586
+ <p key={i} className="text-[11px] text-error">
587
+ {m}
588
+ </p>
395
589
  ))}
396
590
  <button
397
591
  onClick={onRepairStale}
@@ -411,111 +605,137 @@ function CutRow({
411
605
  Text/interstitial panels need no clean image (#351), so this whole
412
606
  generation/upload handoff is image-cut only. */}
413
607
  {!isTextPanel(cut) && (
414
- <div className="mt-2 space-y-2">
415
- <button
416
- onClick={() => {
417
- navigator.clipboard.writeText(buildCodexTaskPrompt(cut as unknown as LibCut, plotFile));
418
- setCopied(true);
419
- setTimeout(() => setCopied(false), 2000);
420
- }}
421
- data-testid={`copy-prompt-${cut.id}`}
422
- className="px-3 py-1.5 text-xs border border-border rounded hover:border-accent hover:bg-accent/5"
423
- >
424
- {copied ? "Copied!" : "Copy Codex task"}
425
- </button>
426
-
427
- <input
428
- ref={fileInputRef}
429
- type="file"
430
- accept="image/webp,image/jpeg,image/png"
431
- className="hidden"
432
- onChange={(e) => {
433
- const file = e.target.files?.[0];
434
- if (file) handleUpload(file);
435
- e.target.value = "";
436
- }}
437
- />
438
- <div className="flex items-center gap-2 flex-wrap">
608
+ <div className="mt-2 space-y-2">
439
609
  <button
440
- onClick={() => fileInputRef.current?.click()}
441
- disabled={uploading}
442
- className="px-3 py-1.5 text-xs border border-border rounded hover:border-accent hover:bg-accent/5 disabled:opacity-50"
443
- >
444
- {uploading ? "Uploading..." : cut.cleanImagePath ? "Replace clean image" : "Upload clean image"}
445
- </button>
446
- {/* #403: import a Codex-generated PNG straight from its cache, so a
447
- writer never has to hunt through ~/.codex/generated_images in an
448
- OS file dialog. Same in-browser PNG→WebP conversion + upload. */}
449
- <button
450
- onClick={() => setShowCodexPicker((v) => !v)}
451
- disabled={uploading}
452
- data-testid={`import-codex-${cut.id}`}
453
- 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"
454
619
  >
455
- {showCodexPicker ? "Hide Codex images" : "Import from Codex"}
620
+ {copied ? "Copied!" : "Copy Codex task"}
456
621
  </button>
457
- </div>
458
- {showCodexPicker && (
459
- <CodexImportPicker
460
- authFetch={authFetch}
461
- cutId={cut.id}
462
- onImport={async (file) => {
463
- const ok = await handleUpload(file);
464
- 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 = "";
465
632
  }}
466
- onClose={() => setShowCodexPicker(false)}
467
633
  />
468
- )}
469
- {!cut.cleanImagePath && (
470
- <p className="text-xs text-muted" data-testid={`clean-image-handoff-${cut.id}`}>
471
- Generate this cut in Codex, then import the cached PNG with &ldquo;Import from Codex&rdquo; — or
472
- upload an image manually. Letter it next.
473
- </p>
474
- )}
475
- {status === "missing" && (
476
- <div
477
- className="rounded border border-border bg-surface/60 p-2 space-y-1"
478
- data-testid={`ask-codex-${cut.id}`}
479
- >
480
- <p className="text-[11px] font-medium text-foreground">Generate this cut in Codex</p>
481
- <p className="text-[10px] text-muted">
482
- Copy the task below and paste it into Codex. Codex usually saves a PNG to its
483
- image cache — bring it into this cut with &ldquo;Import from Codex&rdquo; above (the PNG
484
- becomes a WebP automatically). If Codex instead writes a WebP/JPEG at{" "}
485
- <span className="font-mono">assets/{plotFile}/cut-{String(cut.id).padStart(2, "0")}-clean.webp</span>,
486
- it&rsquo;s picked up by &ldquo;Sync clean images&rdquo;.
487
- </p>
634
+ <div className="flex items-center gap-2 flex-wrap">
488
635
  <button
489
- onClick={() => {
490
- navigator.clipboard.writeText(buildCodexTaskPrompt(cut as unknown as LibCut, plotFile));
491
- setAskCopied(true);
492
- setTimeout(() => setAskCopied(false), 2000);
493
- }}
494
- data-testid={`ask-codex-copy-${cut.id}`}
495
- 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"
496
654
  >
497
- {askCopied ? "Copied!" : "Copy Codex task"}
655
+ {showCodexPicker ? "Hide Codex images" : "Import from Codex"}
498
656
  </button>
499
657
  </div>
500
- )}
501
- {status === "missing" && detectedLocalClean && (
502
- <button
503
- onClick={onSyncClean}
504
- disabled={syncing}
505
- data-testid={`found-local-clean-${cut.id}`}
506
- 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"
507
- >
508
- {syncing ? "Syncing..." : "Found local clean image — sync to cut plan"}
509
- </button>
510
- )}
511
- {uploadError && (
512
- <p className="text-xs text-error mt-1">{uploadError}</p>
513
- )}
514
- </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>
515
732
  )}
516
733
 
517
734
  {/* Open editor — image cuts, narration cuts, and text panels (#351) */}
518
- {(cut.cleanImagePath || cut.narration || cut.dialogue.length > 0 || isTextPanel(cut)) && (
735
+ {(cut.cleanImagePath ||
736
+ cut.narration ||
737
+ cut.dialogue.length > 0 ||
738
+ isTextPanel(cut)) && (
519
739
  <button
520
740
  onClick={onOpenEditor}
521
741
  data-testid={`open-editor-${cut.id}`}
@@ -527,12 +747,16 @@ function CutRow({
527
747
 
528
748
  {/* Cut metadata */}
529
749
  {cut.characters.length > 0 && (
530
- <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>
531
753
  )}
532
754
  {cut.dialogue.length > 0 && (
533
755
  <div className="text-xs text-muted">
534
756
  {cut.dialogue.map((d, i) => (
535
- <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>
536
760
  ))}
537
761
  </div>
538
762
  )}
@@ -545,7 +769,19 @@ function CutRow({
545
769
  );
546
770
  }
547
771
 
548
- 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) {
549
785
  const [cutsFile, setCutsFile] = useState<CutsFile | null>(null);
550
786
  // Latest onCutsChanged in a ref so loadCuts can notify the parent without
551
787
  // taking the callback as a dependency (which would churn loadCuts/effects).
@@ -564,7 +800,10 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
564
800
  // Episode publish markdown + on-chain state, for the guided Finish panel (#414):
565
801
  // distinguishes uploaded-but-not-prepared from a prepared/ready-to-publish or
566
802
  // already-published episode (which cuts.json alone cannot tell apart).
567
- const [episodeState, setEpisodeState] = useState<{ markdownReady: boolean; published: boolean }>({
803
+ const [episodeState, setEpisodeState] = useState<{
804
+ markdownReady: boolean;
805
+ published: boolean;
806
+ }>({
568
807
  markdownReady: false,
569
808
  published: false,
570
809
  });
@@ -573,9 +812,13 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
573
812
  const [converting, setConverting] = useState(false);
574
813
  const [convertResult, setConvertResult] = useState<string | null>(null);
575
814
  const [syncResult, setSyncResult] = useState<string | null>(null);
815
+ const [aiDraftingCutId, setAiDraftingCutId] = useState<number | null>(null);
816
+ const [aiDraftingAll, setAiDraftingAll] = useState(false);
576
817
  const [detected, setDetected] = useState<Set<number>>(new Set());
577
818
  // cutId → precise stale-path messages (#302), from detect-clean-images.
578
- const [staleByCut, setStaleByCut] = useState<Map<number, string[]>>(new Map());
819
+ const [staleByCut, setStaleByCut] = useState<Map<number, string[]>>(
820
+ new Map(),
821
+ );
579
822
  // True only after /detect-clean-images has SUCCESSFULLY verified the recorded
580
823
  // paths against disk (#311). Gates the "clean-assets-ready" banner so it never
581
824
  // claims completion from unverified cut-plan fields while detection is pending
@@ -583,11 +826,15 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
583
826
  const [detectConfirmed, setDetectConfirmed] = useState(false);
584
827
  // Read-only per-cut asset diagnostics validated against disk (#427): the real
585
828
  // state (planned/missing/clean-ready/final-ready/uploaded) + precise issues.
586
- const [assetDiagnostics, setAssetDiagnostics] = useState<CutAssetDiagnostic[] | null>(null);
829
+ const [assetDiagnostics, setAssetDiagnostics] = useState<
830
+ CutAssetDiagnostic[] | null
831
+ >(null);
587
832
  const [rescanning, setRescanning] = useState(false);
588
833
  // #371: cut whose row should be scrolled into view after a Preview→Edit deep
589
834
  // link. Applied once its row has rendered (see the scroll effect below).
590
- const [scrollTargetCutId, setScrollTargetCutId] = useState<number | null>(null);
835
+ const [scrollTargetCutId, setScrollTargetCutId] = useState<number | null>(
836
+ null,
837
+ );
591
838
  // Live DOM refs for cut rows, keyed by cut id, used to scroll a focused cut
592
839
  // into view. A ref map avoids re-rendering on registration.
593
840
  const rowRefs = useRef<Map<number, HTMLDivElement>>(new Map());
@@ -613,6 +860,11 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
613
860
  onFocusHandledRef.current?.();
614
861
  }, [focusRequest]);
615
862
 
863
+ useEffect(() => {
864
+ onFocusedLetteringModeChange?.(editingCutId !== null);
865
+ return () => onFocusedLetteringModeChange?.(false);
866
+ }, [editingCutId, onFocusedLetteringModeChange]);
867
+
616
868
  // Scroll a deep-linked, expanded cut into view once its row is on screen. Runs
617
869
  // when the target is set and again after the cut plan loads (rows mount). Best
618
870
  // effort: `scrollIntoView` is a no-op/undefined under jsdom.
@@ -644,13 +896,19 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
644
896
  // distinctly, not just upload progress (#414). Best-effort — a missing file
645
897
  // or error simply leaves those steps as not-yet-prepared.
646
898
  try {
647
- const fileRes = await authFetch(`/api/stories/${storyName}/${fileName}`);
899
+ const fileRes = await authFetch(
900
+ `/api/stories/${storyName}/${fileName}`,
901
+ );
648
902
  if (fileRes.ok) {
649
903
  const fd = await fileRes.json();
650
- const content: string = typeof fd?.content === "string" ? fd.content : "";
904
+ const content: string =
905
+ typeof fd?.content === "string" ? fd.content : "";
651
906
  const cuts = Array.isArray(parsed?.cuts) ? parsed.cuts : [];
652
- const markdownReady = content.length > 0 && checkMarkdownReadiness(content, cuts).ready;
653
- 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";
654
912
  setEpisodeState({ markdownReady, published });
655
913
  } else {
656
914
  setEpisodeState({ markdownReady: false, published: false });
@@ -668,6 +926,22 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
668
926
  }
669
927
  }, [authFetch, storyName, plotFile, fileName]);
670
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
+
671
945
  // Server-confirmed detection of local clean files for cuts whose cleanImagePath
672
946
  // is still null. Best-effort: failures leave the detected set unchanged.
673
947
  const loadDetect = useCallback(async () => {
@@ -675,15 +949,20 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
675
949
  // unverified — don't let the done banner claim completion (#311).
676
950
  setDetectConfirmed(false);
677
951
  try {
678
- 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
+ );
679
955
  if (!res.ok) return;
680
956
  const data = await res.json();
681
- setDetected(new Set<number>(Array.isArray(data.detected) ? data.detected : []));
957
+ setDetected(
958
+ new Set<number>(Array.isArray(data.detected) ? data.detected : []),
959
+ );
682
960
  const staleMap = new Map<number, string[]>();
683
961
  const staleList: unknown = data.stale;
684
962
  if (Array.isArray(staleList)) {
685
963
  for (const s of staleList) {
686
- if (typeof s?.cutId !== "number" || typeof s?.message !== "string") continue;
964
+ if (typeof s?.cutId !== "number" || typeof s?.message !== "string")
965
+ continue;
687
966
  const arr = staleMap.get(s.cutId) ?? [];
688
967
  arr.push(s.message);
689
968
  staleMap.set(s.cutId, arr);
@@ -703,11 +982,17 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
703
982
  const loadDiagnostics = useCallback(async () => {
704
983
  setAssetDiagnostics(null);
705
984
  try {
706
- 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
+ );
707
988
  if (!res.ok) return; // stays cleared
708
989
  const data = await res.json();
709
- setAssetDiagnostics(Array.isArray(data.diagnostics) ? data.diagnostics : null);
710
- } 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
+ }
711
996
  }, [authFetch, storyName, plotFile]);
712
997
 
713
998
  // "Refresh assets / Check generated images" (#427): a read-only rescan that
@@ -723,26 +1008,125 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
723
1008
  }
724
1009
  }, [loadCuts, loadDetect, loadDiagnostics]);
725
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
+
726
1095
  const syncCleanImages = useCallback(async () => {
727
1096
  setSyncing(true);
728
1097
  setSyncResult(null);
729
1098
  setGenWarnings([]);
730
1099
  try {
731
- 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
+ );
732
1104
  const data = await res.json().catch(() => ({}));
733
1105
  if (!res.ok) {
734
1106
  setSyncResult(data.error || "Sync failed");
735
1107
  } else {
736
1108
  const syncedCount = Array.isArray(data.synced) ? data.synced.length : 0;
737
- const clearedCount = Array.isArray(data.cleared) ? data.cleared.length : 0;
1109
+ const clearedCount = Array.isArray(data.cleared)
1110
+ ? data.cleared.length
1111
+ : 0;
738
1112
  const rejected = Array.isArray(data.rejected) ? data.rejected : [];
739
1113
  if (rejected.length > 0) {
740
- 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
+ );
741
1120
  }
742
1121
  const parts: string[] = [];
743
1122
  if (syncedCount > 0) parts.push(`Synced ${syncedCount}`);
744
- if (clearedCount > 0) parts.push(`Cleared ${clearedCount} stale path${clearedCount === 1 ? "" : "s"}`);
745
- 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
+ );
746
1130
  await loadCuts();
747
1131
  await loadDetect();
748
1132
  await loadDiagnostics();
@@ -758,42 +1142,57 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
758
1142
  // upload), and persist via the existing upload-clean route, which records the
759
1143
  // new cleanImagePath. Returns true on success. Publish stays strict — the route
760
1144
  // only accepts WebP/JPEG ≤1MB, so conversion is the safe bridge from a draft PNG.
761
- const convertCut = useCallback(async (cutId: number, pngPath: string): Promise<boolean> => {
762
- try {
763
- const res = await authFetch(assetUrl(storyName, pngPath));
764
- if (!res.ok) return false;
765
- const blob = await res.blob();
766
- const compliant = await importImageToCompliantBlob(new File([blob], "clean.png", { type: blob.type || "image/png" }));
767
- const ext = compliant.type === "image/jpeg" ? "jpg" : "webp";
768
- const formData = new FormData();
769
- formData.append("file", new File([compliant], `clean.${ext}`, { type: compliant.type }));
770
- const up = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}/upload-clean/${cutId}`, { method: "POST", body: formData });
771
- return up.ok;
772
- } catch {
773
- return false;
774
- }
775
- }, [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
+ );
776
1171
 
777
1172
  // "Convert all artwork" (#441): batch-convert every cut flagged needs-conversion.
778
- const convertAll = useCallback(async (jobs: { cutId: number; pngPath: string }[]) => {
779
- setConverting(true);
780
- setConvertResult(null);
781
- let done = 0;
782
- const failed: number[] = [];
783
- for (const job of jobs) {
784
- // Sequential on purpose: avoid hammering browser canvas conversion + the
785
- // upload-clean write all at once for a 10-cut episode.
786
- const ok = await convertCut(job.cutId, job.pngPath);
787
- if (ok) done++; else failed.push(job.cutId);
788
- }
789
- await refreshAssets();
790
- setConverting(false);
791
- setConvertResult(
792
- failed.length === 0
793
- ? `Converted ${done} image${done === 1 ? "" : "s"} to WebP`
794
- : `Converted ${done}; ${failed.length} failed (Cut ${failed.join(", ")}) — try Convert image on each`,
795
- );
796
- }, [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
+ );
797
1196
 
798
1197
  // Guided "Finish episode" orchestration (#414): upload every exported final
799
1198
  // image (paced under the rate limit, #413/#288), then prepare the publish
@@ -805,7 +1204,9 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
805
1204
  setUploading(true);
806
1205
  setUploadProgress("");
807
1206
  setGenWarnings([]);
808
- const toUpload = cutsFile.cuts.filter((ct) => ct.finalImagePath && !ct.uploadedCid);
1207
+ const toUpload = cutsFile.cuts.filter(
1208
+ (ct) => ct.finalImagePath && !ct.uploadedCid,
1209
+ );
809
1210
  const errors: string[] = [];
810
1211
  // Proactively pace uploads under PlotLink's 5/min limit so a 7–10 cut episode
811
1212
  // completes without manual waiting, instead of firing all at once and thrashing
@@ -820,14 +1221,27 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
820
1221
  });
821
1222
  for (let i = 0; i < toUpload.length; i++) {
822
1223
  const ct = toUpload[i];
823
- setUploadProgress(`Uploading cut ${ct.id} (${i + 1}/${toUpload.length})...`);
1224
+ setUploadProgress(
1225
+ `Uploading cut ${ct.id} (${i + 1}/${toUpload.length})...`,
1226
+ );
824
1227
  try {
825
- const assetRel = ct.finalImagePath!.startsWith("assets/") ? ct.finalImagePath!.slice(7) : ct.finalImagePath!;
826
- const imgRes = await authFetch(`/api/stories/${storyName}/asset/${assetRel}`);
827
- 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
+ }
828
1238
  const blob = await imgRes.blob();
829
1239
  const fd = new FormData();
830
- 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
+ );
831
1245
  // Proactively wait if we've already used the 5/min budget (#413), then retry
832
1246
  // with backoff while the PlotLink endpoint rate-limits us anyway (5
833
1247
  // uploads/min), instead of failing the whole batch (#288). Already-uploaded
@@ -836,13 +1250,20 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
836
1250
  await throttle();
837
1251
  const upload = await withRateLimitRetry(
838
1252
  async () => {
839
- 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
+ });
840
1257
  if (res.ok) {
841
1258
  const { cid, url } = await res.json();
842
1259
  return { ok: true as const, status: res.status, cid, url };
843
1260
  }
844
1261
  const e = await res.json().catch(() => ({}));
845
- 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
+ };
846
1267
  },
847
1268
  {
848
1269
  ...uploadRetry,
@@ -852,16 +1273,28 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
852
1273
  ),
853
1274
  },
854
1275
  );
855
- 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
+ }
856
1282
  const { cid, url } = upload;
857
- const setRes = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}/set-uploaded/${ct.id}`, {
858
- method: "POST",
859
- headers: { "Content-Type": "application/json" },
860
- body: JSON.stringify({ cid, url }),
861
- });
862
- 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
+ }
863
1294
  } catch (err) {
864
- 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
+ );
865
1298
  }
866
1299
  }
867
1300
  if (errors.length > 0) {
@@ -872,7 +1305,10 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
872
1305
  return;
873
1306
  }
874
1307
  setUploadProgress("Preparing episode for publishing…");
875
- 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
+ );
876
1312
  if (mdRes.ok) {
877
1313
  const data = await mdRes.json();
878
1314
  if (data.warnings?.length > 0) setGenWarnings(data.warnings);
@@ -889,13 +1325,22 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
889
1325
  setRepairing(true);
890
1326
  setSyncResult(null);
891
1327
  try {
892
- 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
+ );
893
1332
  const data = await res.json().catch(() => ({}));
894
1333
  if (!res.ok) {
895
1334
  setSyncResult(data.error || "Repair failed");
896
1335
  } else {
897
- const clearedCount = Array.isArray(data.cleared) ? data.cleared.length : 0;
898
- 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
+ );
899
1344
  await loadCuts();
900
1345
  await loadDetect();
901
1346
  }
@@ -908,40 +1353,64 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
908
1353
  // Insert a text/interstitial panel to the cut plan (#352/#488) — a one-click way
909
1354
  // to add a narration/title card between image cuts without hand-editing cuts.json.
910
1355
  const [addingPanel, setAddingPanel] = useState(false);
911
- const addTextPanelAt = useCallback(async (insertIndex: number, openEditor = true) => {
912
- if (!cutsFile) return;
913
- setAddingPanel(true);
914
- try {
915
- const nextId = cutsFile.cuts.reduce((m, c) => Math.max(m, c.id), 0) + 1;
916
- const panel = {
917
- id: nextId, shotType: "wide", description: "Text panel", characters: [],
918
- dialogue: [], narration: "", sfx: "",
919
- cleanImagePath: null, finalImagePath: null, exportedAt: null,
920
- uploadedCid: null, uploadedUrl: null, overlays: [],
921
- kind: "text" as const, background: "#101820", aspectRatio: "4:5",
922
- };
923
- const nextCuts = [...cutsFile.cuts];
924
- nextCuts.splice(Math.max(0, Math.min(insertIndex, nextCuts.length)), 0, panel);
925
- const updated = { ...cutsFile, cuts: nextCuts };
926
- const res = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}`, {
927
- method: "PUT",
928
- headers: { "Content-Type": "application/json" },
929
- body: JSON.stringify(updated),
930
- });
931
- if (res.ok) {
932
- if (openEditor) setEditingCutId(nextId);
933
- else setExpandedCut(nextId);
934
- await loadCuts();
935
- } else {
936
- const data = await res.json().catch(() => ({}));
937
- 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");
938
1405
  }
939
- } catch {
940
- setSyncResult("Could not add text panel");
941
- }
942
- setAddingPanel(false);
943
- }, [cutsFile, authFetch, storyName, plotFile, loadCuts]);
944
- const addTextPanel = useCallback(() => addTextPanelAt(cutsFile?.cuts.length ?? 0, true), [addTextPanelAt, cutsFile]);
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
+ );
945
1414
 
946
1415
  useEffect(() => {
947
1416
  loadCuts();
@@ -959,9 +1428,16 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
959
1428
  <p className="text-sm text-error font-medium">Invalid cuts file</p>
960
1429
  <p className="text-xs text-error">{error}</p>
961
1430
  <p className="text-xs text-muted">
962
- {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.
963
1434
  </p>
964
- <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>
965
1441
  </div>
966
1442
  );
967
1443
  }
@@ -970,12 +1446,17 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
970
1446
  return (
971
1447
  <div className="p-4 text-center space-y-1">
972
1448
  <p className="text-sm text-muted">No cuts yet</p>
973
- <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>
974
1452
  </div>
975
1453
  );
976
1454
  }
977
1455
 
978
- 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;
979
1460
 
980
1461
  if (editingCut) {
981
1462
  return (
@@ -985,22 +1466,37 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
985
1466
  plotFile={plotFile}
986
1467
  language={language}
987
1468
  authFetch={authFetch}
988
- targetLabel={isTextPanel(editingCut) ? `Between-scene card ${editingCut.id}` : `Cut ${String(editingCut.id).padStart(2, "0")}`}
1469
+ targetLabel={
1470
+ isTextPanel(editingCut)
1471
+ ? `Between-scene card ${editingCut.id}`
1472
+ : `Cut ${String(editingCut.id).padStart(2, "0")}`
1473
+ }
989
1474
  returnOnSave
990
- onSave={async (overlays: Overlay[]) => {
991
- const updated = { ...cutsFile, cuts: cutsFile.cuts.map((c) => c.id === editingCutId ? { ...c, overlays } : c) };
992
- const res = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}`, {
993
- method: "PUT",
994
- headers: { "Content-Type": "application/json" },
995
- body: JSON.stringify(updated),
996
- });
997
- if (!res.ok) {
998
- const data = await res.json().catch(() => ({}));
999
- throw new Error(data.error || "Failed to save overlays");
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");
1000
1493
  }
1001
1494
  }}
1002
1495
  onExported={() => loadCuts()}
1003
- onClose={() => { setEditingCutId(null); loadCuts(); }}
1496
+ onClose={() => {
1497
+ setEditingCutId(null);
1498
+ loadCuts();
1499
+ }}
1004
1500
  />
1005
1501
  );
1006
1502
  }
@@ -1011,21 +1507,30 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
1011
1507
  acc[s]++;
1012
1508
  return acc;
1013
1509
  },
1014
- { 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
+ >,
1015
1514
  );
1016
1515
  // Text/interstitial panels need no clean image (#351), so the clean-assets
1017
1516
  // banner/claims reason about IMAGE cuts only — never the total cut count.
1018
1517
  const imageCutCount = cutsFile.cuts.filter((c) => !isTextPanel(c)).length;
1019
1518
  // #381: final images lettered by an older bubble renderer (separate-tail seam)
1020
1519
  // must be re-exported before publish. Only tailed speech bubbles are affected.
1021
- 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);
1022
1523
 
1023
1524
  // Guided "Finish episode" state (#414). The checklist's publish step reflects the
1024
1525
  // real on-chain status; markdownReady distinguishes uploaded-but-not-prepared from
1025
1526
  // a prepared/ready-to-publish episode. canFinish = something the Finish action can
1026
1527
  // still do: a final to upload, or uploads done but the sequence not yet prepared.
1027
- const finishChecklist = cartoonChecklist({ cuts: cutsFile.cuts, published: episodeState.published });
1028
- 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";
1029
1534
  const canFinish =
1030
1535
  cutsFile.cuts.some((ct) => ct.finalImagePath && !ct.uploadedCid) ||
1031
1536
  (uploadStepDone && !episodeState.markdownReady);
@@ -1035,7 +1540,9 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
1035
1540
  const conversionJobs = (assetDiagnostics ?? [])
1036
1541
  .filter((d) => d.state === "needs-conversion" && d.convertiblePng)
1037
1542
  .map((d) => ({ cutId: d.cutId, pngPath: d.convertiblePng as string }));
1038
- 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
+ );
1039
1546
  const conversionIssues = (assetDiagnostics ?? [])
1040
1547
  .filter((d) => d.state === "needs-conversion" && d.issue)
1041
1548
  .map((d) => d.issue as string);
@@ -1044,119 +1551,211 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
1044
1551
  // milestones, not internal fields: artwork found (any clean image incl. a draft
1045
1552
  // PNG), converted (publishable WebP/JPEG), lettered (bubbles placed/exported),
1046
1553
  // uploaded. PNG-only cuts read as "artwork found" but not yet "converted".
1047
- const episodeLabel = fileName === "genesis.md"
1048
- ? "Genesis / Episode 1"
1049
- : `Episode ${parseInt(plotFile.match(/\d+/)?.[0] ?? "0", 10) + 1}`;
1050
- 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;
1051
1562
  const imageCuts = cutsFile.cuts.filter((c) => !isTextPanel(c));
1052
1563
  const boardSummary = {
1053
1564
  cuts: cutsFile.cuts.length,
1054
- artwork: imageCuts.filter((c) => c.cleanImagePath || conversionByCut.has(c.id)).length,
1055
- converted: imageCuts.filter((c) => c.cleanImagePath && /\.(webp|jpe?g)$/i.test(c.cleanImagePath)).length,
1056
- lettered: cutsFile.cuts.filter((c) => (c.overlays?.length ?? 0) > 0 || !!c.finalImagePath).length,
1057
- 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,
1058
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;
1059
1585
 
1060
1586
  return (
1061
- <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
+ >
1062
1591
  {/* Episode header + creator-facing progress summary (#440). */}
1063
- <div className="px-3 py-2 border-b border-border flex-shrink-0" data-testid="cut-board-header">
1064
- <div className="flex items-center gap-2 text-xs">
1065
- <span className="font-serif text-foreground truncate">{episodeLabel}</span>
1066
- {episodeTitle && <span className="text-muted truncate">· {episodeTitle}</span>}
1067
- </div>
1068
- <div className="mt-0.5 text-[10px] text-muted" data-testid="cut-board-summary">
1069
- {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
+ )}
1070
1627
  </div>
1071
1628
  </div>
1072
1629
  {/* Lower-level / manual controls, collapsed by default so the board stays
1073
1630
  focused on per-cut actions (#440). The guided Finish flow + per-cut
1074
1631
  primary actions are the main path; these stay for power users. */}
1075
- <details className="border-b border-border flex-shrink-0" data-testid="cut-advanced">
1076
- <summary className="px-3 py-1.5 text-[10px] text-muted cursor-pointer hover:text-foreground">Technical details</summary>
1077
- <div className="px-3 py-2 flex flex-wrap items-center gap-2 text-[10px]">
1078
- <span className="font-mono text-muted">{cutsFile.cuts.length} cuts</span>
1079
- {stats.missing > 0 && <span className="text-muted">{stats.missing} missing</span>}
1080
- {stats.clean > 0 && <span className="text-green-700">{stats.clean} clean</span>}
1081
- {stats.lettered > 0 && <span className="text-amber-700">{stats.lettered} lettered</span>}
1082
- {stats.uploaded > 0 && <span className="text-green-700">{stats.uploaded} uploaded</span>}
1083
- {stats.text > 0 && <span className="text-accent">{stats.text} text {stats.text === 1 ? "panel" : "panels"}</span>}
1084
- <button
1085
- onClick={async () => {
1086
- setGenerating(true);
1087
- setGenWarnings([]);
1088
- try {
1089
- const res = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}/generate-markdown`, { method: "POST" });
1090
- if (res.ok) {
1091
- const data = await res.json();
1092
- 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 */
1093
1675
  }
1094
- } catch { /* ignore */ }
1095
- setGenerating(false);
1096
- }}
1097
- disabled={generating}
1098
- className="ml-auto px-2 py-0.5 border border-accent/30 text-accent rounded hover:bg-accent/5 disabled:opacity-50"
1099
- data-testid="generate-markdown-btn"
1100
- title="Build the publish-ready episode from the uploaded cut images"
1101
- >
1102
- {generating ? "Preparing…" : "Prepare episode for publish"}
1103
- </button>
1104
- <button
1105
- onClick={addTextPanel}
1106
- disabled={addingPanel}
1107
- className="px-2 py-0.5 border border-accent/30 text-accent rounded hover:bg-accent/5 disabled:opacity-50"
1108
- data-testid="add-text-panel-btn"
1109
- title="Insert a narration/title card between art panels — a solid card exported as a final image panel, no drawing needed"
1110
- >
1111
- {addingPanel ? "Adding…" : "Add narration/text panel"}
1112
- </button>
1113
- <button
1114
- onClick={refreshAssets}
1115
- disabled={rescanning}
1116
- className="px-2 py-0.5 border border-border text-muted rounded hover:border-accent hover:text-accent disabled:opacity-50"
1117
- data-testid="refresh-assets-btn"
1118
- title="Re-check the story folder for agent-generated images and report each cut's asset state — read only, nothing is uploaded or published"
1119
- >
1120
- {rescanning ? "Checking…" : "Refresh assets"}
1121
- </button>
1122
- <button
1123
- onClick={syncCleanImages}
1124
- disabled={syncing}
1125
- className="px-2 py-0.5 border border-accent/30 text-accent rounded hover:bg-accent/5 disabled:opacity-50"
1126
- data-testid="sync-clean-btn"
1127
- >
1128
- {syncing ? "Syncing..." : "Sync clean images"}
1129
- </button>
1130
- <button
1131
- onClick={finishEpisode}
1132
- disabled={uploading || !cutsFile?.cuts.some((ct) => ct.finalImagePath && !ct.uploadedCid)}
1133
- className="px-2 py-0.5 border border-accent/30 text-accent rounded hover:bg-accent/5 disabled:opacity-50"
1134
- data-testid="upload-generate-btn"
1135
- title="Upload each cut's final lettered image, then prepare the episode for publishing"
1136
- >
1137
- {uploadProgress || "Upload & Prepare for Publish"}
1138
- </button>
1139
- </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>
1140
1724
  </details>
1141
1725
  {/* Plain-language workflow + text-panel explainer (#360) so a non-technical
1142
1726
  writer understands the order of operations and what a text panel is. */}
1143
- <div
1144
- 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"
1145
1729
  data-testid="cartoon-workflow-help"
1146
1730
  >
1147
- <div className="flex flex-wrap items-center gap-1.5 text-[10px] text-muted">
1148
- <span className="rounded-full border border-border bg-background px-2 py-0.5 text-foreground">1. Letter</span>
1149
- <span aria-hidden>→</span>
1150
- <span className="rounded-full border border-border bg-background px-2 py-0.5 text-foreground">2. Export</span>
1151
- <span aria-hidden>→</span>
1152
- <span className="rounded-full border border-border bg-background px-2 py-0.5 text-foreground">3. Upload</span>
1153
- <span aria-hidden>→</span>
1154
- <span className="rounded-full border border-border bg-background px-2 py-0.5 text-foreground">4. Prepare episode for publish</span>
1155
- </div>
1156
- <div className="mt-1 text-[10px] text-muted">
1157
- 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>
1158
1757
  </div>
1159
- </div>
1758
+ </details>
1160
1759
  {/* Stale bubble-renderer warning (#381): a final image lettered before the
1161
1760
  current seamless-tail renderer may show the old separate-tail seam.
1162
1761
  Mark those cuts so the writer re-exports (open lettering → Export) and
@@ -1166,23 +1765,38 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
1166
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"
1167
1766
  data-testid="stale-bubble-export-warning"
1168
1767
  >
1169
- {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.
1170
1773
  </div>
1171
1774
  )}
1172
1775
  {/* Clean-asset generation done-state (#311): when every cut has a present,
1173
1776
  valid clean image, surface a clear "done" signal so the operator knows
1174
1777
  Codex generation is complete even if the terminal session is still
1175
1778
  connected — no more guessing whether it is still Working. */}
1176
- {detectConfirmed && imageCutCount > 0 && stats.missing === 0 && staleByCut.size === 0 && (
1177
- <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">
1178
- <span aria-hidden>✓</span>
1179
- <span>
1180
- All {imageCutCount} clean image{imageCutCount === 1 ? "" : "s"} present — clean-asset generation is complete. Ready for lettering in OWS.
1181
- </span>
1182
- </div>
1183
- )}
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
+ )}
1184
1795
  {syncResult && (
1185
- <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
+ >
1186
1800
  {syncResult}
1187
1801
  </div>
1188
1802
  )}
@@ -1191,10 +1805,17 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
1191
1805
  than red "Unsupported extension" errors. The raw reasons stay available
1192
1806
  under a collapsed "Technical details" disclosure. */}
1193
1807
  {conversionJobs.length > 0 && (
1194
- <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
+ >
1195
1812
  <div className="flex items-center gap-2 flex-wrap">
1196
- <span className="font-medium text-amber-700" data-testid="convert-artwork-count">
1197
- {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
1198
1819
  </span>
1199
1820
  <button
1200
1821
  onClick={() => convertAll(conversionJobs)}
@@ -1206,14 +1827,26 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
1206
1827
  </button>
1207
1828
  </div>
1208
1829
  <p className="mt-1 text-[10px] text-muted">
1209
- 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.
1210
1832
  </p>
1211
- {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
+ )}
1212
1841
  {conversionIssues.length > 0 && (
1213
1842
  <details className="mt-1" data-testid="convert-technical-details">
1214
- <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>
1215
1846
  <ul className="mt-1 ml-3 list-disc text-[10px] text-muted">
1216
- {conversionIssues.map((m, i) => <li key={i}>{m}</li>)}
1847
+ {conversionIssues.map((m, i) => (
1848
+ <li key={i}>{m}</li>
1849
+ ))}
1217
1850
  </ul>
1218
1851
  </details>
1219
1852
  )}
@@ -1223,22 +1856,37 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
1223
1856
  state tally + a precise per-cut reason when a recorded path is broken,
1224
1857
  so "files exist but aren't shown" / a typoed path is a clear diagnostic
1225
1858
  rather than a generic publish warning. */}
1226
- {assetDiagnostics && assetDiagnostics.length > 0 && (() => {
1227
- const s = summarizeAssetDiagnostics(assetDiagnostics);
1228
- const missing = assetDiagnostics.filter((d) => d.state === "missing");
1229
- return (
1230
- <div className="px-3 py-1.5 border-b border-border bg-surface/40 text-[10px] flex-shrink-0" data-testid="asset-diagnostics">
1231
- <span className="text-muted" data-testid="asset-diag-summary">
1232
- 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` : ""}
1233
- </span>
1234
- {missing.length > 0 && (
1235
- <ul className="mt-1 ml-3 list-disc text-error" data-testid="asset-diag-issues">
1236
- {missing.map((d) => <li key={d.cutId}>{d.issue}</li>)}
1237
- </ul>
1238
- )}
1239
- </div>
1240
- );
1241
- })()}
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
+ })()}
1242
1890
  {/* Guided Finish-episode flow (#414): writer-language step status, one primary
1243
1891
  "Finish episode" action that uploads finals then prepares the publish
1244
1892
  markdown in order, and any blockers grouped by the step that fixes them —
@@ -1257,12 +1905,19 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
1257
1905
 
1258
1906
  {/* Full cut review (#488): all clean cuts are shown vertically first, with
1259
1907
  explicit between-scene slots for narration/title cards. */}
1260
- <div className="flex-1 min-h-56 overflow-y-auto p-3 space-y-3" data-testid="lettering-review-board">
1908
+ <div
1909
+ className="flex-1 min-h-56 overflow-y-auto p-3 space-y-3"
1910
+ data-testid="lettering-review-board"
1911
+ >
1261
1912
  {cutsFile.cuts.map((cut, index) => (
1262
1913
  <Fragment key={cut.id}>
1263
1914
  <BetweenSceneSlot
1264
1915
  index={index}
1265
- beforeLabel={index === 0 ? "Episode opening" : `After cut ${cutsFile.cuts[index - 1]?.id}`}
1916
+ beforeLabel={
1917
+ index === 0
1918
+ ? "Episode opening"
1919
+ : `After cut ${cutsFile.cuts[index - 1]?.id}`
1920
+ }
1266
1921
  afterLabel={`Before cut ${cut.id}`}
1267
1922
  disabled={addingPanel}
1268
1923
  onAdd={() => addTextPanelAt(index)}
@@ -1272,20 +1927,33 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
1272
1927
  storyName={storyName}
1273
1928
  plotFile={plotFile}
1274
1929
  expanded={expandedCut === cut.id}
1275
- onToggle={() => setExpandedCut(expandedCut === cut.id ? null : cut.id)}
1930
+ onToggle={() =>
1931
+ setExpandedCut(expandedCut === cut.id ? null : cut.id)
1932
+ }
1276
1933
  authFetch={authFetch}
1277
- onUpdated={() => { loadCuts(); loadDetect(); loadDiagnostics(); }}
1934
+ onUpdated={() => {
1935
+ loadCuts();
1936
+ loadDetect();
1937
+ loadDiagnostics();
1938
+ }}
1278
1939
  onOpenEditor={() => setEditingCutId(cut.id)}
1279
1940
  detectedLocalClean={detected.has(cut.id)}
1280
1941
  onSyncClean={syncCleanImages}
1281
1942
  syncing={syncing}
1943
+ onAiDraft={() => {
1944
+ void draftCutWithAi(cut.id, { openEditor: true });
1945
+ }}
1946
+ aiDrafting={aiDraftingCutId === cut.id}
1282
1947
  staleMessages={staleByCut.get(cut.id) ?? []}
1283
1948
  onRepairStale={repairStalePaths}
1284
1949
  repairing={repairing}
1285
1950
  conversionPng={conversionByCut.get(cut.id) ?? null}
1286
1951
  onConvert={convertCut}
1287
1952
  converting={converting}
1288
- rowRef={(el) => { if (el) rowRefs.current.set(cut.id, el); else rowRefs.current.delete(cut.id); }}
1953
+ rowRef={(el) => {
1954
+ if (el) rowRefs.current.set(cut.id, el);
1955
+ else rowRefs.current.delete(cut.id);
1956
+ }}
1289
1957
  />
1290
1958
  </Fragment>
1291
1959
  ))}
@@ -1320,8 +1988,12 @@ function BetweenSceneSlot({
1320
1988
  data-testid={`between-scene-slot-${index}`}
1321
1989
  >
1322
1990
  <span className="min-w-0 flex-1">
1323
- <span className="font-medium text-foreground">Between-scene lettering</span>
1324
- <span className="block truncate">{beforeLabel} · {afterLabel}</span>
1991
+ <span className="font-medium text-foreground">
1992
+ Between-scene lettering
1993
+ </span>
1994
+ <span className="block truncate">
1995
+ {beforeLabel} · {afterLabel}
1996
+ </span>
1325
1997
  </span>
1326
1998
  <button
1327
1999
  type="button"