plotlink-ows 1.2.95 → 1.2.97

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