plotlink-ows 1.0.32 → 1.2.94

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.
Files changed (145) hide show
  1. package/README.md +4 -0
  2. package/app/lib/agent-command.ts +85 -0
  3. package/app/lib/agent-readiness.ts +133 -0
  4. package/app/lib/apply-schema.ts +55 -0
  5. package/app/lib/bubble-text.ts +160 -0
  6. package/app/lib/cartoon-coach.ts +198 -0
  7. package/app/lib/cartoon-markdown.ts +83 -0
  8. package/app/lib/cartoon-prompt.ts +122 -0
  9. package/app/lib/cartoon-readiness.ts +811 -0
  10. package/app/lib/clean-image-sync.ts +245 -0
  11. package/app/lib/codex-images.ts +152 -0
  12. package/app/lib/cut-asset-diagnostics.ts +120 -0
  13. package/app/lib/cuts.ts +302 -0
  14. package/app/lib/fonts.ts +109 -0
  15. package/app/lib/generate-claude-md.ts +10 -3
  16. package/app/lib/generate-story-instructions.ts +731 -0
  17. package/app/lib/image-asset-validate.ts +123 -0
  18. package/app/lib/lettering-status.ts +133 -0
  19. package/app/lib/overlays.ts +637 -0
  20. package/app/lib/paths.ts +10 -0
  21. package/app/lib/public-title.ts +65 -0
  22. package/app/lib/publish.ts +16 -2
  23. package/app/lib/story-progress.ts +243 -0
  24. package/app/lib/terminal-protocol.ts +16 -0
  25. package/app/lib/terminal-redact.ts +50 -0
  26. package/app/prisma/schema.sql +25 -0
  27. package/app/routes/agent.ts +42 -0
  28. package/app/routes/codex-images.ts +67 -0
  29. package/app/routes/publish.ts +209 -28
  30. package/app/routes/stories.ts +961 -5
  31. package/app/routes/terminal.ts +383 -31
  32. package/app/server.ts +47 -12
  33. package/app/vite.config.ts +6 -0
  34. package/app/web/components/CartoonPreview.tsx +267 -0
  35. package/app/web/components/CartoonPublishPage.tsx +407 -0
  36. package/app/web/components/CartoonPublishPreview.tsx +121 -0
  37. package/app/web/components/CartoonStepGuide.tsx +90 -0
  38. package/app/web/components/CartoonWorkflowNav.tsx +68 -0
  39. package/app/web/components/CodexImportPicker.tsx +230 -0
  40. package/app/web/components/CutListPanel.tsx +1299 -0
  41. package/app/web/components/EpisodesPage.tsx +80 -0
  42. package/app/web/components/FinishEpisodePanel.tsx +151 -0
  43. package/app/web/components/Layout.tsx +7 -4
  44. package/app/web/components/LetteringEditor.tsx +1141 -0
  45. package/app/web/components/PreviewPanel.tsx +1017 -144
  46. package/app/web/components/Settings.tsx +63 -0
  47. package/app/web/components/StoriesPage.tsx +710 -33
  48. package/app/web/components/StoryBrowser.tsx +22 -14
  49. package/app/web/components/StoryInfoPage.tsx +266 -0
  50. package/app/web/components/StoryProgressPanel.tsx +516 -0
  51. package/app/web/components/TerminalPanel.tsx +233 -11
  52. package/app/web/components/WorkflowCoach.tsx +128 -0
  53. package/app/web/components/asset-image.tsx +114 -0
  54. package/app/web/components/asset-test-utils.ts +44 -0
  55. package/app/web/components/export-cut.ts +320 -0
  56. package/app/web/dist/assets/export-cut-nKQ_n2-J.js +1 -0
  57. package/app/web/dist/assets/index-BAZGwVwj.js +143 -0
  58. package/app/web/dist/assets/index-DoXH2OlP.css +32 -0
  59. package/app/web/dist/index.html +2 -2
  60. package/app/web/lib/cartoon-publish-summary.ts +43 -0
  61. package/app/web/lib/codex-import.ts +94 -0
  62. package/app/web/lib/image-compress.ts +53 -0
  63. package/app/web/lib/import-image.ts +58 -0
  64. package/app/web/lib/publish-helpers.ts +385 -0
  65. package/app/web/lib/upload-retry.ts +130 -0
  66. package/app/web/lib/verify-public-title.ts +105 -0
  67. package/app/web/styles.css +9 -0
  68. package/bin/plotlink-ows.js +53 -16
  69. package/bin/startup-plan.cjs +58 -0
  70. package/lib/genres.ts +92 -0
  71. package/package.json +60 -20
  72. package/scripts/gen-schema-sql.mjs +49 -0
  73. package/scripts/package-hygiene.mjs +116 -0
  74. package/scripts/preflight.mjs +173 -0
  75. package/scripts/start-smoke.mjs +128 -0
  76. package/app/node_modules/.prisma/local-client/client.d.ts +0 -1
  77. package/app/node_modules/.prisma/local-client/client.js +0 -5
  78. package/app/node_modules/.prisma/local-client/default.d.ts +0 -1
  79. package/app/node_modules/.prisma/local-client/default.js +0 -5
  80. package/app/node_modules/.prisma/local-client/edge.d.ts +0 -1
  81. package/app/node_modules/.prisma/local-client/edge.js +0 -184
  82. package/app/node_modules/.prisma/local-client/index-browser.js +0 -173
  83. package/app/node_modules/.prisma/local-client/index.d.ts +0 -3304
  84. package/app/node_modules/.prisma/local-client/index.js +0 -207
  85. package/app/node_modules/.prisma/local-client/libquery_engine-darwin-arm64.dylib.node +0 -0
  86. package/app/node_modules/.prisma/local-client/package.json +0 -183
  87. package/app/node_modules/.prisma/local-client/query_engine_bg.js +0 -2
  88. package/app/node_modules/.prisma/local-client/query_engine_bg.wasm +0 -0
  89. package/app/node_modules/.prisma/local-client/runtime/edge-esm.js +0 -35
  90. package/app/node_modules/.prisma/local-client/runtime/edge.js +0 -35
  91. package/app/node_modules/.prisma/local-client/runtime/index-browser.d.ts +0 -370
  92. package/app/node_modules/.prisma/local-client/runtime/index-browser.js +0 -17
  93. package/app/node_modules/.prisma/local-client/runtime/library.d.ts +0 -3982
  94. package/app/node_modules/.prisma/local-client/runtime/library.js +0 -147
  95. package/app/node_modules/.prisma/local-client/runtime/react-native.js +0 -84
  96. package/app/node_modules/.prisma/local-client/runtime/wasm-compiler-edge.js +0 -85
  97. package/app/node_modules/.prisma/local-client/runtime/wasm-engine-edge.js +0 -38
  98. package/app/node_modules/.prisma/local-client/schema.prisma +0 -21
  99. package/app/node_modules/.prisma/local-client/wasm-edge-light-loader.mjs +0 -5
  100. package/app/node_modules/.prisma/local-client/wasm-worker-loader.mjs +0 -5
  101. package/app/node_modules/.prisma/local-client/wasm.d.ts +0 -1
  102. package/app/node_modules/.prisma/local-client/wasm.js +0 -191
  103. package/app/web/dist/assets/index-B-2Ft7Yv.css +0 -32
  104. package/app/web/dist/assets/index-BFw-v-OZ.js +0 -134
  105. package/packages/cli/node_modules/commander/LICENSE +0 -22
  106. package/packages/cli/node_modules/commander/Readme.md +0 -1149
  107. package/packages/cli/node_modules/commander/esm.mjs +0 -16
  108. package/packages/cli/node_modules/commander/index.js +0 -24
  109. package/packages/cli/node_modules/commander/lib/argument.js +0 -149
  110. package/packages/cli/node_modules/commander/lib/command.js +0 -2662
  111. package/packages/cli/node_modules/commander/lib/error.js +0 -39
  112. package/packages/cli/node_modules/commander/lib/help.js +0 -709
  113. package/packages/cli/node_modules/commander/lib/option.js +0 -367
  114. package/packages/cli/node_modules/commander/lib/suggestSimilar.js +0 -101
  115. package/packages/cli/node_modules/commander/package-support.json +0 -16
  116. package/packages/cli/node_modules/commander/package.json +0 -82
  117. package/packages/cli/node_modules/commander/typings/esm.d.mts +0 -3
  118. package/packages/cli/node_modules/commander/typings/index.d.ts +0 -1045
  119. package/packages/cli/node_modules/resolve-from/index.d.ts +0 -31
  120. package/packages/cli/node_modules/resolve-from/index.js +0 -47
  121. package/packages/cli/node_modules/resolve-from/license +0 -9
  122. package/packages/cli/node_modules/resolve-from/package.json +0 -36
  123. package/packages/cli/node_modules/resolve-from/readme.md +0 -72
  124. package/packages/cli/node_modules/tsup/LICENSE +0 -21
  125. package/packages/cli/node_modules/tsup/README.md +0 -75
  126. package/packages/cli/node_modules/tsup/assets/cjs_shims.js +0 -13
  127. package/packages/cli/node_modules/tsup/assets/esm_shims.js +0 -9
  128. package/packages/cli/node_modules/tsup/assets/package.json +0 -3
  129. package/packages/cli/node_modules/tsup/dist/chunk-DI5BO6XE.js +0 -153
  130. package/packages/cli/node_modules/tsup/dist/chunk-JZ25TPTY.js +0 -42
  131. package/packages/cli/node_modules/tsup/dist/chunk-PEEXUWMS.js +0 -6
  132. package/packages/cli/node_modules/tsup/dist/chunk-TWFEYLU4.js +0 -352
  133. package/packages/cli/node_modules/tsup/dist/chunk-VGC3FXLU.js +0 -203
  134. package/packages/cli/node_modules/tsup/dist/cli-default.js +0 -12
  135. package/packages/cli/node_modules/tsup/dist/cli-main.js +0 -8
  136. package/packages/cli/node_modules/tsup/dist/cli-node.js +0 -14
  137. package/packages/cli/node_modules/tsup/dist/index.d.ts +0 -511
  138. package/packages/cli/node_modules/tsup/dist/index.js +0 -1711
  139. package/packages/cli/node_modules/tsup/dist/rollup.js +0 -6949
  140. package/packages/cli/node_modules/tsup/package.json +0 -99
  141. package/packages/cli/node_modules/tsup/schema.json +0 -362
  142. package/public/screenshot-1.png +0 -0
  143. package/public/screenshot-2.png +0 -0
  144. package/public/screenshot-3.png +0 -0
  145. package/scripts/e2e-verify.ts +0 -1100
@@ -0,0 +1,1299 @@
1
+ import { useState, useEffect, useCallback, useRef } from "react";
2
+ import { LetteringEditor } from "./LetteringEditor";
3
+ import { AssetImage, assetUrl } from "./asset-image";
4
+ import { buildCodexTaskPrompt, buildLetteringPrompt } from "@app-lib/cartoon-prompt";
5
+ import type { Cut as LibCut } from "@app-lib/cuts";
6
+ import { isTextPanel, isStaleTailedExport } from "@app-lib/cuts";
7
+ import { withRateLimitRetry, createUploadThrottle, type RetryDeps } from "../lib/upload-retry";
8
+ import { importImageToCompliantBlob, isCompliantImage } from "../lib/import-image";
9
+ import { CodexImportPicker } from "./CodexImportPicker";
10
+ import { FinishEpisodePanel } from "./FinishEpisodePanel";
11
+ import { cartoonChecklist, checkMarkdownReadiness } from "@app-lib/cartoon-readiness";
12
+ import { summarizeAssetDiagnostics, type CutAssetDiagnostic } from "@app-lib/cut-asset-diagnostics";
13
+
14
+ interface Overlay {
15
+ id: string;
16
+ type: "speech" | "narration" | "sfx";
17
+ x: number;
18
+ y: number;
19
+ width: number;
20
+ height: number;
21
+ text: string;
22
+ speaker?: string;
23
+ tailAnchor?: { x: number; y: number };
24
+ textStyle?: {
25
+ mode?: "auto" | "manual";
26
+ fontScale?: number;
27
+ lineHeightFactor?: number;
28
+ speakerScale?: number;
29
+ };
30
+ bubbleStyle?: {
31
+ paddingX?: number;
32
+ paddingY?: number;
33
+ cornerRadius?: number;
34
+ };
35
+ }
36
+
37
+ interface CutDialogue {
38
+ speaker: string;
39
+ text: string;
40
+ }
41
+
42
+ interface Cut {
43
+ id: number;
44
+ shotType: string;
45
+ description: string;
46
+ characters: string[];
47
+ dialogue: CutDialogue[];
48
+ narration: string;
49
+ sfx: string;
50
+ cleanImagePath: string | null;
51
+ finalImagePath: string | null;
52
+ exportedAt: string | null;
53
+ uploadedCid: string | null;
54
+ uploadedUrl: string | null;
55
+ overlays: Overlay[];
56
+ kind?: "image" | "text";
57
+ background?: string;
58
+ aspectRatio?: string;
59
+ finalRendererVersion?: number;
60
+ }
61
+
62
+ interface CutsFile {
63
+ version: number;
64
+ plotFile: string;
65
+ cuts: Cut[];
66
+ }
67
+
68
+ interface CutListPanelProps {
69
+ storyName: string;
70
+ fileName: string;
71
+ authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
72
+ language?: string;
73
+ // Rate-limit retry knobs (sleep/maxRetries/baseDelayMs) — injectable so tests
74
+ // can run retries instantly. Production uses the defaults (#288).
75
+ uploadRetry?: Pick<RetryDeps, "sleep" | "maxRetries" | "baseDelayMs">;
76
+ // Notified whenever the cut plan is (re)loaded after a mutation — export,
77
+ // upload, save overlays, generate-markdown (#343). Lets the parent PreviewPanel
78
+ // refresh its own readiness/Episode-steps fetch so all status surfaces agree.
79
+ onCutsChanged?: () => void;
80
+ // #371: a deep-link request from the Preview / Cut Inspector CTA. When it
81
+ // changes (by `seq`), focus that cut — open the lettering editor when
82
+ // `openEditor`, otherwise expand and scroll its row into view. `onFocusHandled`
83
+ // is called once applied so the parent can clear the request.
84
+ focusRequest?: { cutId: number; openEditor: boolean; seq: number } | null;
85
+ onFocusHandled?: () => void;
86
+ }
87
+
88
+ type CutStatus = "missing" | "clean" | "lettered" | "uploaded" | "text";
89
+
90
+ function getCutStatus(cut: Cut): CutStatus {
91
+ if (cut.uploadedCid) return "uploaded";
92
+ if (cut.finalImagePath || cut.exportedAt) return "lettered";
93
+ if (cut.cleanImagePath) return "clean";
94
+ // A text/interstitial panel needs no clean image, so it's never "missing"
95
+ // (#351) — it's ready to letter on its background.
96
+ if (isTextPanel(cut)) return "text";
97
+ return "missing";
98
+ }
99
+
100
+ // Creator-facing production-board status for a cut card (#440). One clear label
101
+ // per cut + the single primary human action, instead of internal field names.
102
+ type BoardTone = "muted" | "amber" | "green" | "accent";
103
+ const BOARD_TONE_TEXT: Record<BoardTone, string> = {
104
+ muted: "text-muted", amber: "text-amber-700", green: "text-green-700", accent: "text-accent",
105
+ };
106
+ const BOARD_TONE_DOT: Record<BoardTone, string> = {
107
+ muted: "bg-muted/40", amber: "bg-amber-500", green: "bg-green-600", accent: "bg-accent",
108
+ };
109
+
110
+ type BoardStatusKey = "uploaded" | "exported" | "convert" | "text" | "review" | "letter" | "needs-image";
111
+ interface BoardStatus { key: BoardStatusKey; label: string; tone: BoardTone }
112
+
113
+ /**
114
+ * Map a cut's real state to one creator-facing board status (#440). `.png` clean
115
+ * images are "Needs conversion" (#441), never a red error; a recorded-but-missing
116
+ * path reads as "Needs image" (re-add the art) with the precise repair kept in
117
+ * Details. Precedence follows the pipeline: uploaded → exported → convert →
118
+ * letter/review → needs image.
119
+ */
120
+ function boardStatus(cut: Cut, needsConversion: boolean, hasStale: boolean): BoardStatus {
121
+ // 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" };
123
+ // PNG clean art is an actionable conversion step (#441).
124
+ if (needsConversion) return { key: "convert", label: "Needs conversion", tone: "amber" };
125
+ // A recorded asset path that's broken on disk (#302) must NOT read as a
126
+ // finished "Exported"/clean cut (#440 RE1): a recorded final needs
127
+ // re-review/re-export; otherwise the clean art is gone → needs image. The
128
+ // precise repair lives in the card's Open details.
129
+ if (hasStale) {
130
+ return cut.finalImagePath
131
+ ? { key: "review", label: "Needs review", tone: "amber" }
132
+ : { key: "needs-image", label: "Needs image", tone: "muted" };
133
+ }
134
+ if (cut.finalImagePath) return { key: "exported", label: "Exported", tone: "green" };
135
+ if (isTextPanel(cut)) return { key: "text", label: "Ready for captions", tone: "accent" };
136
+ if (cut.cleanImagePath) {
137
+ return (cut.overlays?.length ?? 0) > 0
138
+ ? { key: "review", label: "Needs review", tone: "amber" }
139
+ : { key: "letter", label: "Ready for lettering", tone: "green" };
140
+ }
141
+ return { key: "needs-image", label: "Needs image", tone: "muted" };
142
+ }
143
+
144
+ function CutRow({
145
+ cut,
146
+ storyName,
147
+ plotFile,
148
+ expanded,
149
+ onToggle,
150
+ authFetch,
151
+ onUpdated,
152
+ onOpenEditor,
153
+ detectedLocalClean,
154
+ onSyncClean,
155
+ syncing,
156
+ staleMessages,
157
+ onRepairStale,
158
+ repairing,
159
+ conversionPng,
160
+ onConvert,
161
+ converting,
162
+ rowRef,
163
+ }: {
164
+ cut: Cut;
165
+ storyName: string;
166
+ plotFile: string;
167
+ expanded: boolean;
168
+ onToggle: () => void;
169
+ authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
170
+ onUpdated: () => void;
171
+ onOpenEditor: () => void;
172
+ detectedLocalClean: boolean;
173
+ onSyncClean: () => void;
174
+ syncing: boolean;
175
+ staleMessages: string[];
176
+ onRepairStale: () => void;
177
+ repairing: boolean;
178
+ /** When set, this cut has a PNG clean image at this path awaiting conversion (#441). */
179
+ conversionPng: string | null;
180
+ onConvert: (cutId: number, pngPath: string) => Promise<boolean>;
181
+ converting: boolean;
182
+ rowRef?: (el: HTMLDivElement | null) => void;
183
+ }) {
184
+ const fileInputRef = useRef<HTMLInputElement>(null);
185
+ const [uploading, setUploading] = useState(false);
186
+ const [uploadError, setUploadError] = useState<string | null>(null);
187
+ const [copied, setCopied] = useState(false);
188
+ const [askCopied, setAskCopied] = useState(false);
189
+ // #403: show the Codex generated-image cache picker so a writer imports a
190
+ // generated PNG into this cut without hunting through a hidden folder.
191
+ const [showCodexPicker, setShowCodexPicker] = useState(false);
192
+ const [convertingThis, setConvertingThis] = useState(false);
193
+ // Lettering is a first-class step (#442): an intentional Manual vs AI-draft
194
+ // choice per cut, surfaced on the card (not hidden under Edit).
195
+ const [letteringMode, setLetteringMode] = useState<"manual" | "ai">("manual");
196
+ const [letteringCopied, setLetteringCopied] = useState(false);
197
+ const status = getCutStatus(cut);
198
+ // A recorded cleanImagePath/finalImagePath whose file is missing/invalid (#302):
199
+ // surface it precisely rather than letting the field-based status claim the cut
200
+ // is image-ready.
201
+ const hasStale = staleMessages.length > 0;
202
+ // A PNG clean image awaiting conversion (#441) is a normal step, not an error —
203
+ // it takes precedence over the stale/missing framing for this cut.
204
+ const needsConversion = !!conversionPng;
205
+
206
+ const handleConvertThis = useCallback(async () => {
207
+ if (!conversionPng) return;
208
+ setConvertingThis(true);
209
+ await onConvert(cut.id, conversionPng);
210
+ setConvertingThis(false);
211
+ onUpdated();
212
+ }, [conversionPng, onConvert, cut.id, onUpdated]);
213
+
214
+ // Returns true on a successful upload so callers (e.g. the Codex import picker)
215
+ // can close themselves only when the clean image was actually recorded.
216
+ const handleUpload = useCallback(async (file: File): Promise<boolean> => {
217
+ setUploading(true);
218
+ setUploadError(null);
219
+ try {
220
+ // Accept Codex-generated images (e.g. large PNG) by converting/compressing
221
+ // them to a compliant WebP/JPEG <=1MB in the browser first (#301). An
222
+ // already-compliant WebP/JPEG is passed through untouched, so the manual
223
+ // upload behavior is unchanged. A source that cannot be decoded or
224
+ // compressed under 1MB surfaces a clear error instead of saving anything.
225
+ let upload: Blob = file;
226
+ if (!isCompliantImage(file)) {
227
+ try {
228
+ upload = await importImageToCompliantBlob(file);
229
+ } catch (err) {
230
+ setUploadError(err instanceof Error ? err.message : "Could not import image");
231
+ return false;
232
+ }
233
+ }
234
+
235
+ const ext = upload.type === "image/jpeg" ? "jpg" : "webp";
236
+ const formData = new FormData();
237
+ formData.append("file", new File([upload], `clean.${ext}`, { type: upload.type }));
238
+ const res = await authFetch(
239
+ `/api/stories/${storyName}/cuts/${plotFile}/upload-clean/${cut.id}`,
240
+ { method: "POST", body: formData },
241
+ );
242
+ if (!res.ok) {
243
+ const data = await res.json();
244
+ setUploadError(data.error || "Upload failed");
245
+ return false;
246
+ }
247
+ onUpdated();
248
+ return true;
249
+ } catch {
250
+ setUploadError("Upload failed");
251
+ return false;
252
+ } finally {
253
+ setUploading(false);
254
+ }
255
+ }, [authFetch, storyName, plotFile, cut.id, onUpdated]);
256
+
257
+ // Creator-facing board status + the single primary action for this cut (#440).
258
+ const board = boardStatus(cut, needsConversion, hasStale);
259
+ // A viewable thumbnail: the recorded clean image (the asset route serves PNG
260
+ // too, so a draft PNG previews) or the unrecorded convertible PNG.
261
+ const thumbPath = cut.cleanImagePath ?? conversionPng ?? null;
262
+ // A cut sitting at the lettering step (#442): clean art is ready, nothing is
263
+ // exported/uploaded yet, and it isn't blocked on convert/stale. These get the
264
+ // first-class Manual/AI-draft lettering choice instead of a single button.
265
+ const bubblesPlaced = cut.overlays?.length ?? 0;
266
+ const atLetteringStage =
267
+ !isTextPanel(cut) && !!cut.cleanImagePath && !cut.finalImagePath &&
268
+ !cut.uploadedCid && !cut.uploadedUrl && !hasStale && !needsConversion;
269
+
270
+ const copyLetteringPrompt = useCallback(() => {
271
+ navigator.clipboard?.writeText(buildLetteringPrompt(cut as unknown as LibCut, plotFile));
272
+ setLetteringCopied(true);
273
+ setTimeout(() => setLetteringCopied(false), 2000);
274
+ }, [cut, plotFile]);
275
+
276
+ const primary: { label: string; onClick: () => void; testid: string } | null =
277
+ board.key === "convert" ? { label: convertingThis ? "Converting…" : "Convert image", onClick: handleConvertThis, testid: `card-convert-${cut.id}` }
278
+ : board.key === "review" ? { label: "Review cut", onClick: onOpenEditor, testid: `card-review-${cut.id}` }
279
+ : board.key === "text" ? { label: "Add captions", onClick: onOpenEditor, testid: `card-letter-${cut.id}` }
280
+ : board.key === "needs-image" ? { label: "Add artwork", onClick: onToggle, testid: `card-addart-${cut.id}` }
281
+ : null; // exported / uploaded — the next action is the episode-level upload/publish
282
+
283
+ return (
284
+ <div
285
+ ref={rowRef}
286
+ data-cut-row={cut.id}
287
+ className={`border rounded ${expanded ? "border-accent/30" : "border-border"}`}
288
+ >
289
+ {/* Card head — always visible: status, identity, thumbnail, one primary
290
+ action, plus an "Open details" toggle for the technical controls (#440). */}
291
+ <div className="px-3 py-2 space-y-2" data-testid={`cut-card-${cut.id}`}>
292
+ <div className="flex items-center gap-2 text-sm">
293
+ <span className={`w-2 h-2 rounded-full flex-shrink-0 ${BOARD_TONE_DOT[board.tone]}`} />
294
+ <span className="font-medium text-xs text-foreground">Cut {String(cut.id).padStart(2, "0")}</span>
295
+ <span className="font-mono text-[10px] text-muted">· {cut.shotType}</span>
296
+ <span className={`ml-auto text-[10px] font-medium flex-shrink-0 ${BOARD_TONE_TEXT[board.tone]}`} data-testid={`cut-card-status-${cut.id}`}>
297
+ {board.label}
298
+ </span>
299
+ </div>
300
+ {thumbPath ? (
301
+ <AssetImage
302
+ storyName={storyName}
303
+ assetPath={thumbPath}
304
+ authFetch={authFetch}
305
+ alt={`Cut ${cut.id} artwork`}
306
+ className="w-full max-h-44 object-contain rounded border border-border bg-white"
307
+ />
308
+ ) : (
309
+ <div className="w-full h-20 rounded border border-dashed border-border bg-surface/40 flex items-center justify-center text-[10px] text-muted" data-testid={`cut-card-noart-${cut.id}`}>
310
+ {isTextPanel(cut) ? "Text panel — no artwork needed" : "No artwork yet"}
311
+ </div>
312
+ )}
313
+ <button
314
+ onClick={onToggle}
315
+ data-testid={`cut-desc-${cut.id}`}
316
+ className="block w-full text-left text-[11px] text-muted hover:text-foreground"
317
+ >
318
+ {cut.description || "No description"}
319
+ </button>
320
+ {/* Lettering is a first-class, visible step (#442): an intentional
321
+ Manual vs AI-draft choice on the card, then the matching CTA. */}
322
+ {atLetteringStage && (
323
+ <div className="space-y-1" data-testid={`lettering-${cut.id}`}>
324
+ <div className="text-[10px] font-medium text-muted uppercase tracking-wider">Lettering</div>
325
+ <label className="flex items-center gap-1.5 text-[11px] text-foreground">
326
+ <input
327
+ type="radio" name={`lettering-mode-${cut.id}`} checked={letteringMode === "manual"}
328
+ onChange={() => setLetteringMode("manual")} data-testid={`lettering-mode-manual-${cut.id}`}
329
+ />
330
+ Manual — I place bubbles myself
331
+ </label>
332
+ <label className="flex items-center gap-1.5 text-[11px] text-foreground">
333
+ <input
334
+ type="radio" name={`lettering-mode-${cut.id}`} checked={letteringMode === "ai"}
335
+ onChange={() => setLetteringMode("ai")} data-testid={`lettering-mode-ai-${cut.id}`}
336
+ />
337
+ AI draft — ask the agent to place initial bubbles
338
+ </label>
339
+ {letteringMode === "ai" && (
340
+ <p className="text-[10px] text-muted">
341
+ Paste it to your agent, then review the draft bubbles here and export the final cut.
342
+ </p>
343
+ )}
344
+ </div>
345
+ )}
346
+ <div className="flex items-center gap-2 flex-wrap">
347
+ {atLetteringStage ? (
348
+ letteringMode === "manual" ? (
349
+ <button
350
+ onClick={onOpenEditor}
351
+ data-testid={`add-bubbles-${cut.id}`}
352
+ className="px-2.5 py-1 text-[11px] font-medium rounded bg-accent text-white hover:bg-accent-dim"
353
+ >
354
+ {bubblesPlaced > 0 ? "Review lettering" : "Add speech bubbles"}
355
+ </button>
356
+ ) : (
357
+ <button
358
+ onClick={copyLetteringPrompt}
359
+ data-testid={`copy-lettering-${cut.id}`}
360
+ className="px-2.5 py-1 text-[11px] font-medium rounded border border-accent/40 text-accent hover:bg-accent/5"
361
+ >
362
+ {letteringCopied ? "Copied!" : "Copy AI lettering prompt"}
363
+ </button>
364
+ )
365
+ ) : primary ? (
366
+ <button
367
+ onClick={primary.onClick}
368
+ disabled={board.key === "convert" && (convertingThis || converting)}
369
+ data-testid={primary.testid}
370
+ className="px-2.5 py-1 text-[11px] font-medium rounded bg-accent text-white hover:bg-accent-dim disabled:opacity-50"
371
+ >
372
+ {primary.label}
373
+ </button>
374
+ ) : null}
375
+ <button
376
+ onClick={onToggle}
377
+ data-testid={`cut-details-${cut.id}`}
378
+ className="px-2.5 py-1 text-[11px] rounded border border-border text-muted hover:border-accent hover:text-accent"
379
+ >
380
+ {expanded ? "Hide details" : "Open details"}
381
+ </button>
382
+ </div>
383
+ </div>
384
+
385
+ {expanded && (
386
+ <div className="px-3 pb-3 space-y-3 border-t border-border">
387
+ {/* Stale recorded asset path (#302): the cut records a clean/final image
388
+ path but the file is missing/invalid. Show the precise reason and a
389
+ repair action that clears the stale clean AND final fields back to
390
+ null (valid paths and uploaded URLs are preserved). */}
391
+ {/* PNG clean image awaiting conversion (#441): offer the conversion
392
+ rather than the stale-path "Clear" repair (which would discard the
393
+ draft art). The raw unsupported-extension reason stays hidden in the
394
+ Convert artwork banner's technical details. */}
395
+ {needsConversion && (
396
+ <div className="mt-2 rounded border border-amber-500/40 bg-amber-500/10 p-2 space-y-1" data-testid={`needs-conversion-${cut.id}`}>
397
+ <p className="text-[11px] text-amber-800">
398
+ This cut’s artwork is a PNG. Convert it to WebP so it can be lettered and published.
399
+ </p>
400
+ <button
401
+ onClick={handleConvertThis}
402
+ disabled={convertingThis || converting}
403
+ data-testid={`convert-cut-${cut.id}`}
404
+ className="px-2 py-1 text-[11px] border border-amber-500/50 text-amber-800 rounded hover:bg-amber-500/20 disabled:opacity-50"
405
+ >
406
+ {convertingThis ? "Converting…" : "Convert image"}
407
+ </button>
408
+ </div>
409
+ )}
410
+ {hasStale && !needsConversion && (
411
+ <div className="mt-2 rounded border border-error/40 bg-error/5 p-2 space-y-1" data-testid={`stale-asset-${cut.id}`}>
412
+ {staleMessages.map((m, i) => (
413
+ <p key={i} className="text-[11px] text-error">{m}</p>
414
+ ))}
415
+ <button
416
+ onClick={onRepairStale}
417
+ disabled={repairing}
418
+ data-testid={`repair-stale-${cut.id}`}
419
+ className="px-2 py-1 text-[11px] border border-error/40 text-error rounded hover:bg-error/10 disabled:opacity-50"
420
+ >
421
+ {repairing ? "Repairing…" : "Clear stale path"}
422
+ </button>
423
+ </div>
424
+ )}
425
+
426
+ {/* The clean/artwork thumbnail now lives in the always-visible card
427
+ head (#440); Details holds the technical controls below. */}
428
+
429
+ {/* Clean image: copy generation prompt + upload the generated file.
430
+ Text/interstitial panels need no clean image (#351), so this whole
431
+ generation/upload handoff is image-cut only. */}
432
+ {!isTextPanel(cut) && (
433
+ <div className="mt-2 space-y-2">
434
+ <button
435
+ onClick={() => {
436
+ navigator.clipboard.writeText(buildCodexTaskPrompt(cut as unknown as LibCut, plotFile));
437
+ setCopied(true);
438
+ setTimeout(() => setCopied(false), 2000);
439
+ }}
440
+ data-testid={`copy-prompt-${cut.id}`}
441
+ className="px-3 py-1.5 text-xs border border-border rounded hover:border-accent hover:bg-accent/5"
442
+ >
443
+ {copied ? "Copied!" : "Copy Codex task"}
444
+ </button>
445
+
446
+ <input
447
+ ref={fileInputRef}
448
+ type="file"
449
+ accept="image/webp,image/jpeg,image/png"
450
+ className="hidden"
451
+ onChange={(e) => {
452
+ const file = e.target.files?.[0];
453
+ if (file) handleUpload(file);
454
+ e.target.value = "";
455
+ }}
456
+ />
457
+ <div className="flex items-center gap-2 flex-wrap">
458
+ <button
459
+ onClick={() => fileInputRef.current?.click()}
460
+ disabled={uploading}
461
+ className="px-3 py-1.5 text-xs border border-border rounded hover:border-accent hover:bg-accent/5 disabled:opacity-50"
462
+ >
463
+ {uploading ? "Uploading..." : cut.cleanImagePath ? "Replace clean image" : "Upload clean image"}
464
+ </button>
465
+ {/* #403: import a Codex-generated PNG straight from its cache, so a
466
+ writer never has to hunt through ~/.codex/generated_images in an
467
+ OS file dialog. Same in-browser PNG→WebP conversion + upload. */}
468
+ <button
469
+ onClick={() => setShowCodexPicker((v) => !v)}
470
+ disabled={uploading}
471
+ data-testid={`import-codex-${cut.id}`}
472
+ className="px-3 py-1.5 text-xs border border-border rounded hover:border-accent hover:bg-accent/5 disabled:opacity-50"
473
+ >
474
+ {showCodexPicker ? "Hide Codex images" : "Import from Codex"}
475
+ </button>
476
+ </div>
477
+ {showCodexPicker && (
478
+ <CodexImportPicker
479
+ authFetch={authFetch}
480
+ cutId={cut.id}
481
+ onImport={async (file) => {
482
+ const ok = await handleUpload(file);
483
+ if (ok) setShowCodexPicker(false);
484
+ }}
485
+ onClose={() => setShowCodexPicker(false)}
486
+ />
487
+ )}
488
+ {!cut.cleanImagePath && (
489
+ <p className="text-xs text-muted" data-testid={`clean-image-handoff-${cut.id}`}>
490
+ Generate this cut in Codex, then import the cached PNG with &ldquo;Import from Codex&rdquo; — or
491
+ upload an image manually. Letter it next.
492
+ </p>
493
+ )}
494
+ {status === "missing" && (
495
+ <div
496
+ className="rounded border border-border bg-surface/60 p-2 space-y-1"
497
+ data-testid={`ask-codex-${cut.id}`}
498
+ >
499
+ <p className="text-[11px] font-medium text-foreground">Generate this cut in Codex</p>
500
+ <p className="text-[10px] text-muted">
501
+ Copy the task below and paste it into Codex. Codex usually saves a PNG to its
502
+ image cache — bring it into this cut with &ldquo;Import from Codex&rdquo; above (the PNG
503
+ becomes a WebP automatically). If Codex instead writes a WebP/JPEG at{" "}
504
+ <span className="font-mono">assets/{plotFile}/cut-{String(cut.id).padStart(2, "0")}-clean.webp</span>,
505
+ it&rsquo;s picked up by &ldquo;Sync clean images&rdquo;.
506
+ </p>
507
+ <button
508
+ onClick={() => {
509
+ navigator.clipboard.writeText(buildCodexTaskPrompt(cut as unknown as LibCut, plotFile));
510
+ setAskCopied(true);
511
+ setTimeout(() => setAskCopied(false), 2000);
512
+ }}
513
+ data-testid={`ask-codex-copy-${cut.id}`}
514
+ className="px-2 py-1 text-[11px] border border-border rounded hover:border-accent hover:bg-accent/5"
515
+ >
516
+ {askCopied ? "Copied!" : "Copy Codex task"}
517
+ </button>
518
+ </div>
519
+ )}
520
+ {status === "missing" && detectedLocalClean && (
521
+ <button
522
+ onClick={onSyncClean}
523
+ disabled={syncing}
524
+ data-testid={`found-local-clean-${cut.id}`}
525
+ className="px-3 py-1.5 text-xs border border-green-700/40 text-green-700 rounded hover:bg-green-700/5 disabled:opacity-50"
526
+ >
527
+ {syncing ? "Syncing..." : "Found local clean image — sync to cut plan"}
528
+ </button>
529
+ )}
530
+ {uploadError && (
531
+ <p className="text-xs text-error mt-1">{uploadError}</p>
532
+ )}
533
+ </div>
534
+ )}
535
+
536
+ {/* Open editor — image cuts, narration cuts, and text panels (#351) */}
537
+ {(cut.cleanImagePath || cut.narration || cut.dialogue.length > 0 || isTextPanel(cut)) && (
538
+ <button
539
+ onClick={onOpenEditor}
540
+ data-testid={`open-editor-${cut.id}`}
541
+ className="px-3 py-1.5 text-xs border border-accent/30 text-accent rounded hover:bg-accent/5"
542
+ >
543
+ Open editor
544
+ </button>
545
+ )}
546
+
547
+ {/* Cut metadata */}
548
+ {cut.characters.length > 0 && (
549
+ <p className="text-xs text-muted">Characters: {cut.characters.join(", ")}</p>
550
+ )}
551
+ {cut.dialogue.length > 0 && (
552
+ <div className="text-xs text-muted">
553
+ {cut.dialogue.map((d, i) => (
554
+ <p key={i}><span className="font-medium">{d.speaker}:</span> {d.text}</p>
555
+ ))}
556
+ </div>
557
+ )}
558
+ {cut.narration && (
559
+ <p className="text-xs text-muted italic">{cut.narration}</p>
560
+ )}
561
+ </div>
562
+ )}
563
+ </div>
564
+ );
565
+ }
566
+
567
+ export function CutListPanel({ storyName, fileName, authFetch, language, uploadRetry, onCutsChanged, focusRequest, onFocusHandled }: CutListPanelProps) {
568
+ const [cutsFile, setCutsFile] = useState<CutsFile | null>(null);
569
+ // Latest onCutsChanged in a ref so loadCuts can notify the parent without
570
+ // taking the callback as a dependency (which would churn loadCuts/effects).
571
+ const onCutsChangedRef = useRef(onCutsChanged);
572
+ onCutsChangedRef.current = onCutsChanged;
573
+ const onFocusHandledRef = useRef(onFocusHandled);
574
+ onFocusHandledRef.current = onFocusHandled;
575
+ const [loading, setLoading] = useState(true);
576
+ const [error, setError] = useState<string | null>(null);
577
+ const [expandedCut, setExpandedCut] = useState<number | null>(null);
578
+ const [editingCutId, setEditingCutId] = useState<number | null>(null);
579
+ const [generating, setGenerating] = useState(false);
580
+ const [genWarnings, setGenWarnings] = useState<string[]>([]);
581
+ const [uploading, setUploading] = useState(false);
582
+ const [uploadProgress, setUploadProgress] = useState("");
583
+ // Episode publish markdown + on-chain state, for the guided Finish panel (#414):
584
+ // distinguishes uploaded-but-not-prepared from a prepared/ready-to-publish or
585
+ // already-published episode (which cuts.json alone cannot tell apart).
586
+ const [episodeState, setEpisodeState] = useState<{ markdownReady: boolean; published: boolean }>({
587
+ markdownReady: false,
588
+ published: false,
589
+ });
590
+ const [syncing, setSyncing] = useState(false);
591
+ const [repairing, setRepairing] = useState(false);
592
+ const [converting, setConverting] = useState(false);
593
+ const [convertResult, setConvertResult] = useState<string | null>(null);
594
+ const [syncResult, setSyncResult] = useState<string | null>(null);
595
+ const [detected, setDetected] = useState<Set<number>>(new Set());
596
+ // cutId → precise stale-path messages (#302), from detect-clean-images.
597
+ const [staleByCut, setStaleByCut] = useState<Map<number, string[]>>(new Map());
598
+ // True only after /detect-clean-images has SUCCESSFULLY verified the recorded
599
+ // paths against disk (#311). Gates the "clean-assets-ready" banner so it never
600
+ // claims completion from unverified cut-plan fields while detection is pending
601
+ // or after it failed.
602
+ const [detectConfirmed, setDetectConfirmed] = useState(false);
603
+ // Read-only per-cut asset diagnostics validated against disk (#427): the real
604
+ // state (planned/missing/clean-ready/final-ready/uploaded) + precise issues.
605
+ const [assetDiagnostics, setAssetDiagnostics] = useState<CutAssetDiagnostic[] | null>(null);
606
+ const [rescanning, setRescanning] = useState(false);
607
+ // #371: cut whose row should be scrolled into view after a Preview→Edit deep
608
+ // link. Applied once its row has rendered (see the scroll effect below).
609
+ const [scrollTargetCutId, setScrollTargetCutId] = useState<number | null>(null);
610
+ // Live DOM refs for cut rows, keyed by cut id, used to scroll a focused cut
611
+ // into view. A ref map avoids re-rendering on registration.
612
+ const rowRefs = useRef<Map<number, HTMLDivElement>>(new Map());
613
+ // Guards against re-applying the same focus request twice within one mount.
614
+ const appliedFocusSeq = useRef<number | null>(null);
615
+
616
+ const plotFile = fileName.replace(/\.md$/, "");
617
+
618
+ // Apply a Preview / Cut Inspector deep-link (#371): open the lettering editor
619
+ // for the cut, or expand + scroll its row when there is nothing to letter yet.
620
+ // The chosen expandedCut/editingCutId state persists until the rows render
621
+ // (cuts load asynchronously), so this does not need the cut plan loaded first.
622
+ useEffect(() => {
623
+ if (!focusRequest) return;
624
+ if (appliedFocusSeq.current === focusRequest.seq) return;
625
+ appliedFocusSeq.current = focusRequest.seq;
626
+ if (focusRequest.openEditor) {
627
+ setEditingCutId(focusRequest.cutId);
628
+ } else {
629
+ setExpandedCut(focusRequest.cutId);
630
+ setScrollTargetCutId(focusRequest.cutId);
631
+ }
632
+ onFocusHandledRef.current?.();
633
+ }, [focusRequest]);
634
+
635
+ // Scroll a deep-linked, expanded cut into view once its row is on screen. Runs
636
+ // when the target is set and again after the cut plan loads (rows mount). Best
637
+ // effort: `scrollIntoView` is a no-op/undefined under jsdom.
638
+ useEffect(() => {
639
+ if (scrollTargetCutId == null) return;
640
+ const el = rowRefs.current.get(scrollTargetCutId);
641
+ if (!el) return;
642
+ el.scrollIntoView?.({ behavior: "smooth", block: "center" });
643
+ setScrollTargetCutId(null);
644
+ }, [scrollTargetCutId, cutsFile]);
645
+
646
+ const loadCuts = useCallback(async () => {
647
+ try {
648
+ const res = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}`);
649
+ if (res.status === 404) {
650
+ setCutsFile(null);
651
+ return;
652
+ }
653
+ if (!res.ok) {
654
+ const data = await res.json();
655
+ setError(data.error || "Failed to load cuts");
656
+ return;
657
+ }
658
+ const parsed = await res.json();
659
+ setCutsFile(parsed);
660
+ setError(null);
661
+ // Read the episode's publish markdown + on-chain status so the Finish panel
662
+ // can show "Episode sequence prepared" / "Ready to publish" / "Published"
663
+ // distinctly, not just upload progress (#414). Best-effort — a missing file
664
+ // or error simply leaves those steps as not-yet-prepared.
665
+ try {
666
+ const fileRes = await authFetch(`/api/stories/${storyName}/${fileName}`);
667
+ if (fileRes.ok) {
668
+ const fd = await fileRes.json();
669
+ const content: string = typeof fd?.content === "string" ? fd.content : "";
670
+ const cuts = Array.isArray(parsed?.cuts) ? parsed.cuts : [];
671
+ const markdownReady = content.length > 0 && checkMarkdownReadiness(content, cuts).ready;
672
+ const published = fd?.status === "published" || fd?.status === "published-not-indexed";
673
+ setEpisodeState({ markdownReady, published });
674
+ } else {
675
+ setEpisodeState({ markdownReady: false, published: false });
676
+ }
677
+ } catch {
678
+ setEpisodeState({ markdownReady: false, published: false });
679
+ }
680
+ // Tell the parent the cut plan changed so its readiness/Episode-steps view
681
+ // refreshes in lockstep (e.g. after a lettering export, #343).
682
+ onCutsChangedRef.current?.();
683
+ } catch {
684
+ setError("Failed to load cuts");
685
+ } finally {
686
+ setLoading(false);
687
+ }
688
+ }, [authFetch, storyName, plotFile, fileName]);
689
+
690
+ // Server-confirmed detection of local clean files for cuts whose cleanImagePath
691
+ // is still null. Best-effort: failures leave the detected set unchanged.
692
+ const loadDetect = useCallback(async () => {
693
+ // Until this detection resolves successfully, the recorded clean paths are
694
+ // unverified — don't let the done banner claim completion (#311).
695
+ setDetectConfirmed(false);
696
+ try {
697
+ const res = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}/detect-clean-images`);
698
+ if (!res.ok) return;
699
+ const data = await res.json();
700
+ setDetected(new Set<number>(Array.isArray(data.detected) ? data.detected : []));
701
+ const staleMap = new Map<number, string[]>();
702
+ const staleList: unknown = data.stale;
703
+ if (Array.isArray(staleList)) {
704
+ for (const s of staleList) {
705
+ if (typeof s?.cutId !== "number" || typeof s?.message !== "string") continue;
706
+ const arr = staleMap.get(s.cutId) ?? [];
707
+ arr.push(s.message);
708
+ staleMap.set(s.cutId, arr);
709
+ }
710
+ }
711
+ setStaleByCut(staleMap);
712
+ setDetectConfirmed(true);
713
+ } catch {
714
+ /* ignore — affordance simply will not show */
715
+ }
716
+ }, [authFetch, storyName, plotFile]);
717
+
718
+ // Read-only per-cut asset state validated against disk (#427). Best-effort.
719
+ // Clear the prior plan's diagnostics in EVERY exit path (start, non-OK, catch)
720
+ // so a stale missing-path banner can never persist under a different cut plan
721
+ // when the new request fails/404s on a story/file switch (@re1).
722
+ const loadDiagnostics = useCallback(async () => {
723
+ setAssetDiagnostics(null);
724
+ try {
725
+ const res = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}/asset-diagnostics`);
726
+ if (!res.ok) return; // stays cleared
727
+ const data = await res.json();
728
+ setAssetDiagnostics(Array.isArray(data.diagnostics) ? data.diagnostics : null);
729
+ } catch { /* stays cleared — diagnostics are optional */ }
730
+ }, [authFetch, storyName, plotFile]);
731
+
732
+ // "Refresh assets / Check generated images" (#427): a read-only rescan that
733
+ // re-reads the cut plan, re-detects local clean files, and re-classifies each
734
+ // cut's asset state against disk — so a writer can notice agent-generated images
735
+ // without restarting. Never mutates, uploads, or publishes (unlike Sync).
736
+ const refreshAssets = useCallback(async () => {
737
+ setRescanning(true);
738
+ try {
739
+ await Promise.all([loadCuts(), loadDetect(), loadDiagnostics()]);
740
+ } finally {
741
+ setRescanning(false);
742
+ }
743
+ }, [loadCuts, loadDetect, loadDiagnostics]);
744
+
745
+ const syncCleanImages = useCallback(async () => {
746
+ setSyncing(true);
747
+ setSyncResult(null);
748
+ setGenWarnings([]);
749
+ try {
750
+ const res = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}/sync-clean-images`, { method: "POST" });
751
+ const data = await res.json().catch(() => ({}));
752
+ if (!res.ok) {
753
+ setSyncResult(data.error || "Sync failed");
754
+ } else {
755
+ const syncedCount = Array.isArray(data.synced) ? data.synced.length : 0;
756
+ const clearedCount = Array.isArray(data.cleared) ? data.cleared.length : 0;
757
+ const rejected = Array.isArray(data.rejected) ? data.rejected : [];
758
+ if (rejected.length > 0) {
759
+ setGenWarnings(rejected.map((r: { cutId: number; reason: string }) => `Cut ${r.cutId}: ${r.reason}`));
760
+ }
761
+ const parts: string[] = [];
762
+ if (syncedCount > 0) parts.push(`Synced ${syncedCount}`);
763
+ if (clearedCount > 0) parts.push(`Cleared ${clearedCount} stale path${clearedCount === 1 ? "" : "s"}`);
764
+ setSyncResult(parts.length > 0 ? parts.join(", ") : "No new clean images");
765
+ await loadCuts();
766
+ await loadDetect();
767
+ await loadDiagnostics();
768
+ }
769
+ } catch {
770
+ setSyncResult("Sync failed");
771
+ }
772
+ setSyncing(false);
773
+ }, [authFetch, storyName, plotFile, loadCuts, loadDetect, loadDiagnostics]);
774
+
775
+ // Convert one cut's PNG clean image to a publishable WebP/JPEG (#441): fetch
776
+ // the PNG asset, convert + compress it in the browser (same path as a manual
777
+ // upload), and persist via the existing upload-clean route, which records the
778
+ // new cleanImagePath. Returns true on success. Publish stays strict — the route
779
+ // only accepts WebP/JPEG ≤1MB, so conversion is the safe bridge from a draft PNG.
780
+ const convertCut = useCallback(async (cutId: number, pngPath: string): Promise<boolean> => {
781
+ try {
782
+ const res = await authFetch(assetUrl(storyName, pngPath));
783
+ if (!res.ok) return false;
784
+ const blob = await res.blob();
785
+ const compliant = await importImageToCompliantBlob(new File([blob], "clean.png", { type: blob.type || "image/png" }));
786
+ const ext = compliant.type === "image/jpeg" ? "jpg" : "webp";
787
+ const formData = new FormData();
788
+ formData.append("file", new File([compliant], `clean.${ext}`, { type: compliant.type }));
789
+ const up = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}/upload-clean/${cutId}`, { method: "POST", body: formData });
790
+ return up.ok;
791
+ } catch {
792
+ return false;
793
+ }
794
+ }, [authFetch, storyName, plotFile]);
795
+
796
+ // "Convert all artwork" (#441): batch-convert every cut flagged needs-conversion.
797
+ const convertAll = useCallback(async (jobs: { cutId: number; pngPath: string }[]) => {
798
+ setConverting(true);
799
+ setConvertResult(null);
800
+ let done = 0;
801
+ const failed: number[] = [];
802
+ for (const job of jobs) {
803
+ // Sequential on purpose: avoid hammering browser canvas conversion + the
804
+ // upload-clean write all at once for a 10-cut episode.
805
+ const ok = await convertCut(job.cutId, job.pngPath);
806
+ if (ok) done++; else failed.push(job.cutId);
807
+ }
808
+ await refreshAssets();
809
+ setConverting(false);
810
+ setConvertResult(
811
+ failed.length === 0
812
+ ? `Converted ${done} image${done === 1 ? "" : "s"} to WebP`
813
+ : `Converted ${done}; ${failed.length} failed (Cut ${failed.join(", ")}) — try Convert image on each`,
814
+ );
815
+ }, [convertCut, refreshAssets]);
816
+
817
+ // Guided "Finish episode" orchestration (#414): upload every exported final
818
+ // image (paced under the rate limit, #413/#288), then prepare the publish
819
+ // markdown — in order, resumable (already-uploaded cuts are skipped by the
820
+ // `!uploadedCid` filter). Surfaced as the primary "Finish episode" action and
821
+ // reused by the lower-level "Upload & Prepare for Publish" control.
822
+ const finishEpisode = useCallback(async () => {
823
+ if (!cutsFile) return;
824
+ setUploading(true);
825
+ setUploadProgress("");
826
+ setGenWarnings([]);
827
+ const toUpload = cutsFile.cuts.filter((ct) => ct.finalImagePath && !ct.uploadedCid);
828
+ const errors: string[] = [];
829
+ // Proactively pace uploads under PlotLink's 5/min limit so a 7–10 cut episode
830
+ // completes without manual waiting, instead of firing all at once and thrashing
831
+ // on reactive 429 backoff (#413). Reuses the same injectable sleep as the retry
832
+ // path so tests stay deterministic.
833
+ const throttle = createUploadThrottle({
834
+ sleep: uploadRetry?.sleep,
835
+ onWaiting: ({ waitMs }) =>
836
+ setUploadProgress(
837
+ `Upload limit reached — waiting ${Math.round(waitMs / 1000)}s before continuing…`,
838
+ ),
839
+ });
840
+ for (let i = 0; i < toUpload.length; i++) {
841
+ const ct = toUpload[i];
842
+ setUploadProgress(`Uploading cut ${ct.id} (${i + 1}/${toUpload.length})...`);
843
+ try {
844
+ const assetRel = ct.finalImagePath!.startsWith("assets/") ? ct.finalImagePath!.slice(7) : ct.finalImagePath!;
845
+ const imgRes = await authFetch(`/api/stories/${storyName}/asset/${assetRel}`);
846
+ if (!imgRes.ok) { errors.push(`Cut ${ct.id}: failed to fetch asset`); continue; }
847
+ const blob = await imgRes.blob();
848
+ const fd = new FormData();
849
+ fd.append("file", blob, `cut-${ct.id}.${blob.type === "image/webp" ? "webp" : "jpg"}`);
850
+ // Proactively wait if we've already used the 5/min budget (#413), then retry
851
+ // with backoff while the PlotLink endpoint rate-limits us anyway (5
852
+ // uploads/min), instead of failing the whole batch (#288). Already-uploaded
853
+ // cuts are skipped by the `!uploadedCid` filter above, so a retry never
854
+ // re-uploads a recorded cut.
855
+ await throttle();
856
+ const upload = await withRateLimitRetry(
857
+ async () => {
858
+ const res = await authFetch("/api/publish/upload-plot-image", { method: "POST", body: fd });
859
+ if (res.ok) {
860
+ const { cid, url } = await res.json();
861
+ return { ok: true as const, status: res.status, cid, url };
862
+ }
863
+ const e = await res.json().catch(() => ({}));
864
+ return { ok: false as const, status: res.status, errorMessage: (e as { error?: string }).error };
865
+ },
866
+ {
867
+ ...uploadRetry,
868
+ onWaiting: ({ attempt, maxRetries, waitMs }) =>
869
+ setUploadProgress(
870
+ `Cut ${ct.id} rate-limited — waiting ${Math.round(waitMs / 1000)}s before retry ${attempt}/${maxRetries}...`,
871
+ ),
872
+ },
873
+ );
874
+ if (!upload.ok) { errors.push(`Cut ${ct.id}: upload failed — ${upload.errorMessage || "unknown"}`); continue; }
875
+ const { cid, url } = upload;
876
+ const setRes = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}/set-uploaded/${ct.id}`, {
877
+ method: "POST",
878
+ headers: { "Content-Type": "application/json" },
879
+ body: JSON.stringify({ cid, url }),
880
+ });
881
+ if (!setRes.ok) { errors.push(`Cut ${ct.id}: failed to record upload`); }
882
+ } catch (err) {
883
+ errors.push(`Cut ${ct.id}: ${err instanceof Error ? err.message : "failed"}`);
884
+ }
885
+ }
886
+ if (errors.length > 0) {
887
+ setGenWarnings(errors);
888
+ setUploading(false);
889
+ setUploadProgress("");
890
+ loadCuts();
891
+ return;
892
+ }
893
+ setUploadProgress("Preparing episode for publishing…");
894
+ const mdRes = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}/generate-markdown`, { method: "POST" });
895
+ if (mdRes.ok) {
896
+ const data = await mdRes.json();
897
+ if (data.warnings?.length > 0) setGenWarnings(data.warnings);
898
+ }
899
+ setUploading(false);
900
+ setUploadProgress("");
901
+ loadCuts();
902
+ }, [cutsFile, authFetch, storyName, plotFile, uploadRetry, loadCuts]);
903
+
904
+ // Clear stale recorded clean/final paths back to null (#302). Unlike sync,
905
+ // this repairs a stale finalImagePath too; valid paths and uploaded URLs are
906
+ // preserved server-side.
907
+ const repairStalePaths = useCallback(async () => {
908
+ setRepairing(true);
909
+ setSyncResult(null);
910
+ try {
911
+ const res = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}/repair-asset-paths`, { method: "POST" });
912
+ const data = await res.json().catch(() => ({}));
913
+ if (!res.ok) {
914
+ setSyncResult(data.error || "Repair failed");
915
+ } else {
916
+ const clearedCount = Array.isArray(data.cleared) ? data.cleared.length : 0;
917
+ setSyncResult(clearedCount > 0 ? `Cleared ${clearedCount} stale path${clearedCount === 1 ? "" : "s"}` : "No stale paths to clear");
918
+ await loadCuts();
919
+ await loadDetect();
920
+ }
921
+ } catch {
922
+ setSyncResult("Repair failed");
923
+ }
924
+ setRepairing(false);
925
+ }, [authFetch, storyName, plotFile, loadCuts, loadDetect]);
926
+
927
+ // Append a text/interstitial panel to the cut plan (#352) — a one-click way to
928
+ // add a narration/title card between image cuts without hand-editing cuts.json.
929
+ const [addingPanel, setAddingPanel] = useState(false);
930
+ const addTextPanel = useCallback(async () => {
931
+ if (!cutsFile) return;
932
+ setAddingPanel(true);
933
+ try {
934
+ const nextId = cutsFile.cuts.reduce((m, c) => Math.max(m, c.id), 0) + 1;
935
+ const panel = {
936
+ id: nextId, shotType: "wide", description: "Text panel", characters: [],
937
+ dialogue: [], narration: "", sfx: "",
938
+ cleanImagePath: null, finalImagePath: null, exportedAt: null,
939
+ uploadedCid: null, uploadedUrl: null, overlays: [],
940
+ kind: "text" as const, background: "#101820", aspectRatio: "4:5",
941
+ };
942
+ const updated = { ...cutsFile, cuts: [...cutsFile.cuts, panel] };
943
+ const res = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}`, {
944
+ method: "PUT",
945
+ headers: { "Content-Type": "application/json" },
946
+ body: JSON.stringify(updated),
947
+ });
948
+ if (res.ok) {
949
+ setExpandedCut(nextId);
950
+ await loadCuts();
951
+ } else {
952
+ const data = await res.json().catch(() => ({}));
953
+ setSyncResult(data.error || "Could not add text panel");
954
+ }
955
+ } catch {
956
+ setSyncResult("Could not add text panel");
957
+ }
958
+ setAddingPanel(false);
959
+ }, [cutsFile, authFetch, storyName, plotFile, loadCuts]);
960
+
961
+ useEffect(() => {
962
+ loadCuts();
963
+ loadDetect();
964
+ loadDiagnostics();
965
+ }, [loadCuts, loadDetect, loadDiagnostics]);
966
+
967
+ if (loading) {
968
+ return <div className="p-4 text-sm text-muted">Loading cuts...</div>;
969
+ }
970
+
971
+ if (error) {
972
+ return (
973
+ <div className="p-4 space-y-2" data-testid="cuts-error">
974
+ <p className="text-sm text-error font-medium">Invalid cuts file</p>
975
+ <p className="text-xs text-error">{error}</p>
976
+ <p className="text-xs text-muted">
977
+ {plotFile}.cuts.json must follow the OWS v1 schema. Ask Claude to regenerate it using the v1 cuts schema from the cartoon writing instructions.
978
+ </p>
979
+ <button onClick={loadCuts} className="text-xs text-accent hover:text-accent-dim">Retry</button>
980
+ </div>
981
+ );
982
+ }
983
+
984
+ if (!cutsFile || cutsFile.cuts.length === 0) {
985
+ return (
986
+ <div className="p-4 text-center space-y-1">
987
+ <p className="text-sm text-muted">No cuts yet</p>
988
+ <p className="text-xs text-muted">Ask Claude to create a cut plan for this episode.</p>
989
+ </div>
990
+ );
991
+ }
992
+
993
+ const editingCut = editingCutId !== null ? cutsFile.cuts.find((c) => c.id === editingCutId) : null;
994
+
995
+ if (editingCut) {
996
+ return (
997
+ <LetteringEditor
998
+ storyName={storyName}
999
+ cut={editingCut}
1000
+ plotFile={plotFile}
1001
+ language={language}
1002
+ authFetch={authFetch}
1003
+ onSave={async (overlays: Overlay[]) => {
1004
+ const updated = { ...cutsFile, cuts: cutsFile.cuts.map((c) => c.id === editingCutId ? { ...c, overlays } : c) };
1005
+ const res = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}`, {
1006
+ method: "PUT",
1007
+ headers: { "Content-Type": "application/json" },
1008
+ body: JSON.stringify(updated),
1009
+ });
1010
+ if (!res.ok) {
1011
+ const data = await res.json().catch(() => ({}));
1012
+ throw new Error(data.error || "Failed to save overlays");
1013
+ }
1014
+ }}
1015
+ onExported={() => loadCuts()}
1016
+ onClose={() => { setEditingCutId(null); loadCuts(); }}
1017
+ />
1018
+ );
1019
+ }
1020
+
1021
+ const stats = cutsFile.cuts.reduce(
1022
+ (acc, cut) => {
1023
+ const s = getCutStatus(cut);
1024
+ acc[s]++;
1025
+ return acc;
1026
+ },
1027
+ { missing: 0, clean: 0, lettered: 0, uploaded: 0, text: 0 } as Record<CutStatus, number>,
1028
+ );
1029
+ // Text/interstitial panels need no clean image (#351), so the clean-assets
1030
+ // banner/claims reason about IMAGE cuts only — never the total cut count.
1031
+ const imageCutCount = cutsFile.cuts.filter((c) => !isTextPanel(c)).length;
1032
+ // #381: final images lettered by an older bubble renderer (separate-tail seam)
1033
+ // must be re-exported before publish. Only tailed speech bubbles are affected.
1034
+ const staleTailIds = cutsFile.cuts.filter((c) => isStaleTailedExport(c)).map((c) => c.id);
1035
+
1036
+ // Guided "Finish episode" state (#414). The checklist's publish step reflects the
1037
+ // real on-chain status; markdownReady distinguishes uploaded-but-not-prepared from
1038
+ // a prepared/ready-to-publish episode. canFinish = something the Finish action can
1039
+ // still do: a final to upload, or uploads done but the sequence not yet prepared.
1040
+ const finishChecklist = cartoonChecklist({ cuts: cutsFile.cuts, published: episodeState.published });
1041
+ const uploadStepDone = finishChecklist.steps.find((s) => s.key === "upload")?.status === "done";
1042
+ const canFinish =
1043
+ cutsFile.cuts.some((ct) => ct.finalImagePath && !ct.uploadedCid) ||
1044
+ (uploadStepDone && !episodeState.markdownReady);
1045
+
1046
+ // PNG clean images awaiting conversion (#441): a friendly, batch-able step, not
1047
+ // a red unsupported-extension dump. Built from the disk-validated diagnostics.
1048
+ const conversionJobs = (assetDiagnostics ?? [])
1049
+ .filter((d) => d.state === "needs-conversion" && d.convertiblePng)
1050
+ .map((d) => ({ cutId: d.cutId, pngPath: d.convertiblePng as string }));
1051
+ const conversionByCut = new Map(conversionJobs.map((j) => [j.cutId, j.pngPath]));
1052
+ const conversionIssues = (assetDiagnostics ?? [])
1053
+ .filter((d) => d.state === "needs-conversion" && d.issue)
1054
+ .map((d) => d.issue as string);
1055
+
1056
+ // Creator-facing episode header + progress summary (#440). Counts the human
1057
+ // milestones, not internal fields: artwork found (any clean image incl. a draft
1058
+ // PNG), converted (publishable WebP/JPEG), lettered (bubbles placed/exported),
1059
+ // uploaded. PNG-only cuts read as "artwork found" but not yet "converted".
1060
+ const episodeLabel = fileName === "genesis.md"
1061
+ ? "Genesis / Episode 1"
1062
+ : `Episode ${parseInt(plotFile.match(/\d+/)?.[0] ?? "0", 10) + 1}`;
1063
+ const episodeTitle = typeof (cutsFile as { title?: unknown }).title === "string" ? (cutsFile as { title?: string }).title : null;
1064
+ const imageCuts = cutsFile.cuts.filter((c) => !isTextPanel(c));
1065
+ const boardSummary = {
1066
+ cuts: cutsFile.cuts.length,
1067
+ artwork: imageCuts.filter((c) => c.cleanImagePath || conversionByCut.has(c.id)).length,
1068
+ converted: imageCuts.filter((c) => c.cleanImagePath && /\.(webp|jpe?g)$/i.test(c.cleanImagePath)).length,
1069
+ lettered: cutsFile.cuts.filter((c) => (c.overlays?.length ?? 0) > 0 || !!c.finalImagePath).length,
1070
+ uploaded: cutsFile.cuts.filter((c) => c.uploadedCid || c.uploadedUrl).length,
1071
+ };
1072
+
1073
+ return (
1074
+ <div className="h-full min-h-[22rem] flex flex-col overflow-hidden" data-testid="cut-list-panel">
1075
+ {/* Episode header + creator-facing progress summary (#440). */}
1076
+ <div className="px-3 py-2 border-b border-border flex-shrink-0" data-testid="cut-board-header">
1077
+ <div className="flex items-center gap-2 text-xs">
1078
+ <span className="font-serif text-foreground truncate">{episodeLabel}</span>
1079
+ {episodeTitle && <span className="text-muted truncate">· {episodeTitle}</span>}
1080
+ </div>
1081
+ <div className="mt-0.5 text-[10px] text-muted" data-testid="cut-board-summary">
1082
+ {boardSummary.cuts} cuts · {boardSummary.artwork} artwork found · {boardSummary.converted} converted · {boardSummary.lettered} lettered · {boardSummary.uploaded} uploaded
1083
+ </div>
1084
+ </div>
1085
+ {/* Lower-level / manual controls, collapsed by default so the board stays
1086
+ focused on per-cut actions (#440). The guided Finish flow + per-cut
1087
+ primary actions are the main path; these stay for power users. */}
1088
+ <details className="border-b border-border flex-shrink-0" data-testid="cut-advanced">
1089
+ <summary className="px-3 py-1.5 text-[10px] text-muted cursor-pointer hover:text-foreground">Technical details</summary>
1090
+ <div className="px-3 py-2 flex flex-wrap items-center gap-2 text-[10px]">
1091
+ <span className="font-mono text-muted">{cutsFile.cuts.length} cuts</span>
1092
+ {stats.missing > 0 && <span className="text-muted">{stats.missing} missing</span>}
1093
+ {stats.clean > 0 && <span className="text-green-700">{stats.clean} clean</span>}
1094
+ {stats.lettered > 0 && <span className="text-amber-700">{stats.lettered} lettered</span>}
1095
+ {stats.uploaded > 0 && <span className="text-green-700">{stats.uploaded} uploaded</span>}
1096
+ {stats.text > 0 && <span className="text-accent">{stats.text} text {stats.text === 1 ? "panel" : "panels"}</span>}
1097
+ <button
1098
+ onClick={async () => {
1099
+ setGenerating(true);
1100
+ setGenWarnings([]);
1101
+ try {
1102
+ const res = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}/generate-markdown`, { method: "POST" });
1103
+ if (res.ok) {
1104
+ const data = await res.json();
1105
+ setGenWarnings(data.warnings || []);
1106
+ }
1107
+ } catch { /* ignore */ }
1108
+ setGenerating(false);
1109
+ }}
1110
+ disabled={generating}
1111
+ className="ml-auto px-2 py-0.5 border border-accent/30 text-accent rounded hover:bg-accent/5 disabled:opacity-50"
1112
+ data-testid="generate-markdown-btn"
1113
+ title="Build the publish-ready episode from the uploaded cut images"
1114
+ >
1115
+ {generating ? "Preparing…" : "Prepare episode for publish"}
1116
+ </button>
1117
+ <button
1118
+ onClick={addTextPanel}
1119
+ disabled={addingPanel}
1120
+ className="px-2 py-0.5 border border-accent/30 text-accent rounded hover:bg-accent/5 disabled:opacity-50"
1121
+ data-testid="add-text-panel-btn"
1122
+ title="Insert a narration/title card between art panels — a solid card exported as a final image panel, no drawing needed"
1123
+ >
1124
+ {addingPanel ? "Adding…" : "Add narration/text panel"}
1125
+ </button>
1126
+ <button
1127
+ onClick={refreshAssets}
1128
+ disabled={rescanning}
1129
+ className="px-2 py-0.5 border border-border text-muted rounded hover:border-accent hover:text-accent disabled:opacity-50"
1130
+ data-testid="refresh-assets-btn"
1131
+ title="Re-check the story folder for agent-generated images and report each cut's asset state — read only, nothing is uploaded or published"
1132
+ >
1133
+ {rescanning ? "Checking…" : "Refresh assets"}
1134
+ </button>
1135
+ <button
1136
+ onClick={syncCleanImages}
1137
+ disabled={syncing}
1138
+ className="px-2 py-0.5 border border-accent/30 text-accent rounded hover:bg-accent/5 disabled:opacity-50"
1139
+ data-testid="sync-clean-btn"
1140
+ >
1141
+ {syncing ? "Syncing..." : "Sync clean images"}
1142
+ </button>
1143
+ <button
1144
+ onClick={finishEpisode}
1145
+ disabled={uploading || !cutsFile?.cuts.some((ct) => ct.finalImagePath && !ct.uploadedCid)}
1146
+ className="px-2 py-0.5 border border-accent/30 text-accent rounded hover:bg-accent/5 disabled:opacity-50"
1147
+ data-testid="upload-generate-btn"
1148
+ title="Upload each cut's final lettered image, then prepare the episode for publishing"
1149
+ >
1150
+ {uploadProgress || "Upload & Prepare for Publish"}
1151
+ </button>
1152
+ </div>
1153
+ </details>
1154
+ {/* Plain-language workflow + text-panel explainer (#360) so a non-technical
1155
+ writer understands the order of operations and what a text panel is. */}
1156
+ <div
1157
+ className="px-3 py-2 border-b border-border bg-surface/40 flex-shrink-0"
1158
+ data-testid="cartoon-workflow-help"
1159
+ >
1160
+ <div className="flex flex-wrap items-center gap-1.5 text-[10px] text-muted">
1161
+ <span className="rounded-full border border-border bg-background px-2 py-0.5 text-foreground">1. Letter</span>
1162
+ <span aria-hidden>→</span>
1163
+ <span className="rounded-full border border-border bg-background px-2 py-0.5 text-foreground">2. Export</span>
1164
+ <span aria-hidden>→</span>
1165
+ <span className="rounded-full border border-border bg-background px-2 py-0.5 text-foreground">3. Upload</span>
1166
+ <span aria-hidden>→</span>
1167
+ <span className="rounded-full border border-border bg-background px-2 py-0.5 text-foreground">4. Prepare episode for publish</span>
1168
+ </div>
1169
+ <div className="mt-1 text-[10px] text-muted">
1170
+ Use <span className="text-accent">Add narration/text panel</span> for a narration or title card. It becomes a solid card exported as a final image.
1171
+ </div>
1172
+ </div>
1173
+ {/* Stale bubble-renderer warning (#381): a final image lettered before the
1174
+ current seamless-tail renderer may show the old separate-tail seam.
1175
+ Mark those cuts so the writer re-exports (open lettering → Export) and
1176
+ re-uploads them before publishing. */}
1177
+ {staleTailIds.length > 0 && (
1178
+ <div
1179
+ className="px-3 py-1.5 border-b border-amber-500/40 bg-amber-500/10 text-[10px] text-amber-700 flex-shrink-0"
1180
+ data-testid="stale-bubble-export-warning"
1181
+ >
1182
+ {staleTailIds.length === 1 ? "Cut" : "Cuts"} {staleTailIds.join(", ")} {staleTailIds.length === 1 ? "was" : "were"} lettered with an older speech-bubble style whose tail can show a visible seam. Re-export {staleTailIds.length === 1 ? "it" : "them"} (open lettering → Export) and re-upload before publishing so the bubble tails are seamless.
1183
+ </div>
1184
+ )}
1185
+ {/* Clean-asset generation done-state (#311): when every cut has a present,
1186
+ valid clean image, surface a clear "done" signal so the operator knows
1187
+ Codex generation is complete even if the terminal session is still
1188
+ connected — no more guessing whether it is still Working. */}
1189
+ {detectConfirmed && imageCutCount > 0 && stats.missing === 0 && staleByCut.size === 0 && (
1190
+ <div className="px-3 py-1 border-b border-border bg-green-600/10 text-[10px] text-green-700 flex items-center gap-1 flex-shrink-0" data-testid="clean-assets-ready">
1191
+ <span aria-hidden>✓</span>
1192
+ <span>
1193
+ All {imageCutCount} clean image{imageCutCount === 1 ? "" : "s"} present — clean-asset generation is complete. Ready for lettering in OWS.
1194
+ </span>
1195
+ </div>
1196
+ )}
1197
+ {syncResult && (
1198
+ <div className="px-3 py-1 border-b border-border text-[10px] text-muted flex-shrink-0" data-testid="sync-result">
1199
+ {syncResult}
1200
+ </div>
1201
+ )}
1202
+ {/* Convert artwork step (#441, spec §8): PNG clean images are a normal
1203
+ drafting intermediate, surfaced as a friendly batch conversion rather
1204
+ than red "Unsupported extension" errors. The raw reasons stay available
1205
+ under a collapsed "Technical details" disclosure. */}
1206
+ {conversionJobs.length > 0 && (
1207
+ <div className="px-3 py-2 border-b border-amber-500/40 bg-amber-500/10 text-[11px] flex-shrink-0" data-testid="convert-artwork">
1208
+ <div className="flex items-center gap-2 flex-wrap">
1209
+ <span className="font-medium text-amber-700" data-testid="convert-artwork-count">
1210
+ {conversionJobs.length} PNG image{conversionJobs.length === 1 ? "" : "s"} found
1211
+ </span>
1212
+ <button
1213
+ onClick={() => convertAll(conversionJobs)}
1214
+ disabled={converting}
1215
+ data-testid="convert-all-btn"
1216
+ 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"
1217
+ >
1218
+ {converting ? "Converting…" : "Convert all to WebP"}
1219
+ </button>
1220
+ </div>
1221
+ <p className="mt-1 text-[10px] text-muted">
1222
+ PNG artwork is fine while drafting. Convert it before lettering/export so PlotLink can publish it safely.
1223
+ </p>
1224
+ {convertResult && <p className="mt-1 text-[10px] text-muted" data-testid="convert-result">{convertResult}</p>}
1225
+ {conversionIssues.length > 0 && (
1226
+ <details className="mt-1" data-testid="convert-technical-details">
1227
+ <summary className="text-[10px] text-muted cursor-pointer">Technical details</summary>
1228
+ <ul className="mt-1 ml-3 list-disc text-[10px] text-muted">
1229
+ {conversionIssues.map((m, i) => <li key={i}>{m}</li>)}
1230
+ </ul>
1231
+ </details>
1232
+ )}
1233
+ </div>
1234
+ )}
1235
+ {/* Read-only per-cut asset state validated against disk (#427): a compact
1236
+ state tally + a precise per-cut reason when a recorded path is broken,
1237
+ so "files exist but aren't shown" / a typoed path is a clear diagnostic
1238
+ rather than a generic publish warning. */}
1239
+ {assetDiagnostics && assetDiagnostics.length > 0 && (() => {
1240
+ const s = summarizeAssetDiagnostics(assetDiagnostics);
1241
+ const missing = assetDiagnostics.filter((d) => d.state === "missing");
1242
+ return (
1243
+ <div className="px-3 py-1.5 border-b border-border bg-surface/40 text-[10px] flex-shrink-0" data-testid="asset-diagnostics">
1244
+ <span className="text-muted" data-testid="asset-diag-summary">
1245
+ Assets: {s.uploaded} uploaded · {s.finalReady} final · {s.cleanReady} clean · {s.planned} planned{s.needsConversion > 0 ? ` · ${s.needsConversion} needs conversion` : ""}{s.missing > 0 ? ` · ${s.missing} missing` : ""}
1246
+ </span>
1247
+ {missing.length > 0 && (
1248
+ <ul className="mt-1 ml-3 list-disc text-error" data-testid="asset-diag-issues">
1249
+ {missing.map((d) => <li key={d.cutId}>{d.issue}</li>)}
1250
+ </ul>
1251
+ )}
1252
+ </div>
1253
+ );
1254
+ })()}
1255
+ {/* Guided Finish-episode flow (#414): writer-language step status, one primary
1256
+ "Finish episode" action that uploads finals then prepares the publish
1257
+ markdown in order, and any blockers grouped by the step that fixes them —
1258
+ replacing the old flat amber warning list. The lower-level controls in the
1259
+ header above stay available for manual recovery. */}
1260
+ <FinishEpisodePanel
1261
+ checklist={finishChecklist}
1262
+ issues={genWarnings}
1263
+ onFinish={finishEpisode}
1264
+ finishing={uploading}
1265
+ progressText={uploadProgress}
1266
+ canFinish={canFinish}
1267
+ markdownReady={episodeState.markdownReady}
1268
+ published={episodeState.published}
1269
+ />
1270
+
1271
+ {/* Cut list */}
1272
+ <div className="flex-1 min-h-56 overflow-y-auto p-3 space-y-2" data-testid="cut-list-scroll">
1273
+ {cutsFile.cuts.map((cut) => (
1274
+ <CutRow
1275
+ key={cut.id}
1276
+ cut={cut}
1277
+ storyName={storyName}
1278
+ plotFile={plotFile}
1279
+ expanded={expandedCut === cut.id}
1280
+ onToggle={() => setExpandedCut(expandedCut === cut.id ? null : cut.id)}
1281
+ authFetch={authFetch}
1282
+ onUpdated={() => { loadCuts(); loadDetect(); loadDiagnostics(); }}
1283
+ onOpenEditor={() => setEditingCutId(cut.id)}
1284
+ detectedLocalClean={detected.has(cut.id)}
1285
+ onSyncClean={syncCleanImages}
1286
+ syncing={syncing}
1287
+ staleMessages={staleByCut.get(cut.id) ?? []}
1288
+ onRepairStale={repairStalePaths}
1289
+ repairing={repairing}
1290
+ conversionPng={conversionByCut.get(cut.id) ?? null}
1291
+ onConvert={convertCut}
1292
+ converting={converting}
1293
+ rowRef={(el) => { if (el) rowRefs.current.set(cut.id, el); else rowRefs.current.delete(cut.id); }}
1294
+ />
1295
+ ))}
1296
+ </div>
1297
+ </div>
1298
+ );
1299
+ }