plotlink-ows 1.0.33 → 1.2.95

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 (152) hide show
  1. package/README.md +4 -0
  2. package/app/lib/active-wallet.ts +260 -0
  3. package/app/lib/agent-command.ts +85 -0
  4. package/app/lib/agent-readiness.ts +133 -0
  5. package/app/lib/apply-schema.ts +55 -0
  6. package/app/lib/bubble-text.ts +160 -0
  7. package/app/lib/cartoon-coach.ts +198 -0
  8. package/app/lib/cartoon-markdown.ts +83 -0
  9. package/app/lib/cartoon-prompt.ts +122 -0
  10. package/app/lib/cartoon-readiness.ts +813 -0
  11. package/app/lib/clean-image-sync.ts +245 -0
  12. package/app/lib/codex-images.ts +152 -0
  13. package/app/lib/cut-asset-diagnostics.ts +120 -0
  14. package/app/lib/cuts.ts +302 -0
  15. package/app/lib/fonts.ts +109 -0
  16. package/app/lib/generate-claude-md.ts +8 -1
  17. package/app/lib/generate-story-instructions.ts +731 -0
  18. package/app/lib/image-asset-validate.ts +123 -0
  19. package/app/lib/lettering-status.ts +133 -0
  20. package/app/lib/overlays.ts +637 -0
  21. package/app/lib/paths.ts +10 -0
  22. package/app/lib/public-title.ts +65 -0
  23. package/app/lib/publish.ts +16 -2
  24. package/app/lib/story-progress.ts +242 -0
  25. package/app/lib/terminal-protocol.ts +16 -0
  26. package/app/lib/terminal-redact.ts +50 -0
  27. package/app/prisma/schema.sql +25 -0
  28. package/app/routes/agent.ts +42 -0
  29. package/app/routes/codex-images.ts +67 -0
  30. package/app/routes/dashboard.ts +6 -4
  31. package/app/routes/publish.ts +259 -45
  32. package/app/routes/settings.ts +92 -37
  33. package/app/routes/stories.ts +961 -5
  34. package/app/routes/terminal.ts +383 -31
  35. package/app/routes/wallet.ts +58 -30
  36. package/app/server.ts +47 -12
  37. package/app/vite.config.ts +6 -0
  38. package/app/web/components/CartoonNextAction.tsx +145 -0
  39. package/app/web/components/CartoonPreview.tsx +267 -0
  40. package/app/web/components/CartoonPublishPage.tsx +407 -0
  41. package/app/web/components/CartoonPublishPreview.tsx +121 -0
  42. package/app/web/components/CartoonStepGuide.tsx +90 -0
  43. package/app/web/components/CartoonWorkflowNav.tsx +68 -0
  44. package/app/web/components/CodexImportPicker.tsx +230 -0
  45. package/app/web/components/CutListPanel.tsx +1337 -0
  46. package/app/web/components/Dashboard.tsx +15 -6
  47. package/app/web/components/EpisodesPage.tsx +80 -0
  48. package/app/web/components/FinishEpisodePanel.tsx +151 -0
  49. package/app/web/components/Layout.tsx +7 -4
  50. package/app/web/components/LetteringEditor.tsx +1182 -0
  51. package/app/web/components/PreviewPanel.tsx +952 -78
  52. package/app/web/components/Settings.tsx +63 -0
  53. package/app/web/components/StoriesPage.tsx +745 -33
  54. package/app/web/components/StoryBrowser.tsx +22 -14
  55. package/app/web/components/StoryInfoPage.tsx +266 -0
  56. package/app/web/components/StoryProgressPanel.tsx +446 -0
  57. package/app/web/components/TerminalPanel.tsx +233 -11
  58. package/app/web/components/WalletCard.tsx +110 -8
  59. package/app/web/components/WorkflowCoach.tsx +156 -0
  60. package/app/web/components/asset-image.tsx +114 -0
  61. package/app/web/components/asset-test-utils.ts +44 -0
  62. package/app/web/components/export-cut.ts +320 -0
  63. package/app/web/dist/assets/export-cut-che5mMWc.js +1 -0
  64. package/app/web/dist/assets/index-CcfChGEK.css +32 -0
  65. package/app/web/dist/assets/index-Dc2TQ3Ij.js +143 -0
  66. package/app/web/dist/index.html +2 -2
  67. package/app/web/lib/cartoon-publish-summary.ts +43 -0
  68. package/app/web/lib/codex-import.ts +94 -0
  69. package/app/web/lib/image-compress.ts +53 -0
  70. package/app/web/lib/import-image.ts +58 -0
  71. package/app/web/lib/publish-helpers.ts +385 -0
  72. package/app/web/lib/upload-retry.ts +130 -0
  73. package/app/web/lib/verify-public-title.ts +105 -0
  74. package/app/web/styles.css +9 -0
  75. package/bin/plotlink-ows.js +53 -16
  76. package/bin/startup-plan.cjs +58 -0
  77. package/lib/genres.ts +92 -0
  78. package/package.json +60 -20
  79. package/scripts/gen-schema-sql.mjs +49 -0
  80. package/scripts/package-hygiene.mjs +116 -0
  81. package/scripts/preflight.mjs +173 -0
  82. package/scripts/start-smoke.mjs +128 -0
  83. package/app/node_modules/.prisma/local-client/client.d.ts +0 -1
  84. package/app/node_modules/.prisma/local-client/client.js +0 -5
  85. package/app/node_modules/.prisma/local-client/default.d.ts +0 -1
  86. package/app/node_modules/.prisma/local-client/default.js +0 -5
  87. package/app/node_modules/.prisma/local-client/edge.d.ts +0 -1
  88. package/app/node_modules/.prisma/local-client/edge.js +0 -184
  89. package/app/node_modules/.prisma/local-client/index-browser.js +0 -173
  90. package/app/node_modules/.prisma/local-client/index.d.ts +0 -3304
  91. package/app/node_modules/.prisma/local-client/index.js +0 -207
  92. package/app/node_modules/.prisma/local-client/libquery_engine-darwin-arm64.dylib.node +0 -0
  93. package/app/node_modules/.prisma/local-client/package.json +0 -183
  94. package/app/node_modules/.prisma/local-client/query_engine_bg.js +0 -2
  95. package/app/node_modules/.prisma/local-client/query_engine_bg.wasm +0 -0
  96. package/app/node_modules/.prisma/local-client/runtime/edge-esm.js +0 -35
  97. package/app/node_modules/.prisma/local-client/runtime/edge.js +0 -35
  98. package/app/node_modules/.prisma/local-client/runtime/index-browser.d.ts +0 -370
  99. package/app/node_modules/.prisma/local-client/runtime/index-browser.js +0 -17
  100. package/app/node_modules/.prisma/local-client/runtime/library.d.ts +0 -3982
  101. package/app/node_modules/.prisma/local-client/runtime/library.js +0 -147
  102. package/app/node_modules/.prisma/local-client/runtime/react-native.js +0 -84
  103. package/app/node_modules/.prisma/local-client/runtime/wasm-compiler-edge.js +0 -85
  104. package/app/node_modules/.prisma/local-client/runtime/wasm-engine-edge.js +0 -38
  105. package/app/node_modules/.prisma/local-client/schema.prisma +0 -21
  106. package/app/node_modules/.prisma/local-client/wasm-edge-light-loader.mjs +0 -5
  107. package/app/node_modules/.prisma/local-client/wasm-worker-loader.mjs +0 -5
  108. package/app/node_modules/.prisma/local-client/wasm.d.ts +0 -1
  109. package/app/node_modules/.prisma/local-client/wasm.js +0 -191
  110. package/app/web/dist/assets/index-B-2Ft7Yv.css +0 -32
  111. package/app/web/dist/assets/index-DxATSk7X.js +0 -134
  112. package/packages/cli/node_modules/commander/LICENSE +0 -22
  113. package/packages/cli/node_modules/commander/Readme.md +0 -1149
  114. package/packages/cli/node_modules/commander/esm.mjs +0 -16
  115. package/packages/cli/node_modules/commander/index.js +0 -24
  116. package/packages/cli/node_modules/commander/lib/argument.js +0 -149
  117. package/packages/cli/node_modules/commander/lib/command.js +0 -2662
  118. package/packages/cli/node_modules/commander/lib/error.js +0 -39
  119. package/packages/cli/node_modules/commander/lib/help.js +0 -709
  120. package/packages/cli/node_modules/commander/lib/option.js +0 -367
  121. package/packages/cli/node_modules/commander/lib/suggestSimilar.js +0 -101
  122. package/packages/cli/node_modules/commander/package-support.json +0 -16
  123. package/packages/cli/node_modules/commander/package.json +0 -82
  124. package/packages/cli/node_modules/commander/typings/esm.d.mts +0 -3
  125. package/packages/cli/node_modules/commander/typings/index.d.ts +0 -1045
  126. package/packages/cli/node_modules/resolve-from/index.d.ts +0 -31
  127. package/packages/cli/node_modules/resolve-from/index.js +0 -47
  128. package/packages/cli/node_modules/resolve-from/license +0 -9
  129. package/packages/cli/node_modules/resolve-from/package.json +0 -36
  130. package/packages/cli/node_modules/resolve-from/readme.md +0 -72
  131. package/packages/cli/node_modules/tsup/LICENSE +0 -21
  132. package/packages/cli/node_modules/tsup/README.md +0 -75
  133. package/packages/cli/node_modules/tsup/assets/cjs_shims.js +0 -13
  134. package/packages/cli/node_modules/tsup/assets/esm_shims.js +0 -9
  135. package/packages/cli/node_modules/tsup/assets/package.json +0 -3
  136. package/packages/cli/node_modules/tsup/dist/chunk-DI5BO6XE.js +0 -153
  137. package/packages/cli/node_modules/tsup/dist/chunk-JZ25TPTY.js +0 -42
  138. package/packages/cli/node_modules/tsup/dist/chunk-PEEXUWMS.js +0 -6
  139. package/packages/cli/node_modules/tsup/dist/chunk-TWFEYLU4.js +0 -352
  140. package/packages/cli/node_modules/tsup/dist/chunk-VGC3FXLU.js +0 -203
  141. package/packages/cli/node_modules/tsup/dist/cli-default.js +0 -12
  142. package/packages/cli/node_modules/tsup/dist/cli-main.js +0 -8
  143. package/packages/cli/node_modules/tsup/dist/cli-node.js +0 -14
  144. package/packages/cli/node_modules/tsup/dist/index.d.ts +0 -511
  145. package/packages/cli/node_modules/tsup/dist/index.js +0 -1711
  146. package/packages/cli/node_modules/tsup/dist/rollup.js +0 -6949
  147. package/packages/cli/node_modules/tsup/package.json +0 -99
  148. package/packages/cli/node_modules/tsup/schema.json +0 -362
  149. package/public/screenshot-1.png +0 -0
  150. package/public/screenshot-2.png +0 -0
  151. package/public/screenshot-3.png +0 -0
  152. package/scripts/e2e-verify.ts +0 -1100
@@ -0,0 +1,1337 @@
1
+ import { Fragment, useState, useEffect, useCallback, useRef } from "react";
2
+ import { LetteringEditor } from "./LetteringEditor";
3
+ import { AssetImage, assetUrl } from "./asset-image";
4
+ import { buildCodexTaskPrompt } 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 letteringReviewState(cut: Cut): { label: string; detail: string; tone: BoardTone } {
145
+ if (cut.uploadedCid || cut.uploadedUrl) {
146
+ return { label: "Complete", detail: "Final image uploaded", tone: "green" };
147
+ }
148
+ if (cut.finalImagePath || cut.exportedAt) {
149
+ return { label: "Exported", detail: "Ready to upload", tone: "green" };
150
+ }
151
+ if ((cut.overlays?.length ?? 0) > 0) {
152
+ return { label: "Draft saved", detail: `${cut.overlays.length} overlay${cut.overlays.length === 1 ? "" : "s"} placed`, tone: "amber" };
153
+ }
154
+ if (isTextPanel(cut)) {
155
+ return { label: "Between-scene card", detail: "Open to add narration or title text", tone: "accent" };
156
+ }
157
+ if (cut.cleanImagePath) {
158
+ return { label: "Unlettered", detail: "Clean art ready for bubble placement", tone: "muted" };
159
+ }
160
+ return { label: "Needs artwork", detail: "Add or sync clean art first", tone: "muted" };
161
+ }
162
+
163
+ function CutRow({
164
+ cut,
165
+ storyName,
166
+ plotFile,
167
+ expanded,
168
+ onToggle,
169
+ authFetch,
170
+ onUpdated,
171
+ onOpenEditor,
172
+ detectedLocalClean,
173
+ onSyncClean,
174
+ syncing,
175
+ staleMessages,
176
+ onRepairStale,
177
+ repairing,
178
+ conversionPng,
179
+ onConvert,
180
+ converting,
181
+ rowRef,
182
+ }: {
183
+ cut: Cut;
184
+ storyName: string;
185
+ plotFile: string;
186
+ expanded: boolean;
187
+ onToggle: () => void;
188
+ authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
189
+ onUpdated: () => void;
190
+ onOpenEditor: () => void;
191
+ detectedLocalClean: boolean;
192
+ onSyncClean: () => void;
193
+ syncing: boolean;
194
+ staleMessages: string[];
195
+ onRepairStale: () => void;
196
+ repairing: boolean;
197
+ /** When set, this cut has a PNG clean image at this path awaiting conversion (#441). */
198
+ conversionPng: string | null;
199
+ onConvert: (cutId: number, pngPath: string) => Promise<boolean>;
200
+ converting: boolean;
201
+ rowRef?: (el: HTMLDivElement | null) => void;
202
+ }) {
203
+ const fileInputRef = useRef<HTMLInputElement>(null);
204
+ const [uploading, setUploading] = useState(false);
205
+ const [uploadError, setUploadError] = useState<string | null>(null);
206
+ const [copied, setCopied] = useState(false);
207
+ const [askCopied, setAskCopied] = useState(false);
208
+ // #403: show the Codex generated-image cache picker so a writer imports a
209
+ // generated PNG into this cut without hunting through a hidden folder.
210
+ const [showCodexPicker, setShowCodexPicker] = useState(false);
211
+ const [convertingThis, setConvertingThis] = useState(false);
212
+ const status = getCutStatus(cut);
213
+ // A recorded cleanImagePath/finalImagePath whose file is missing/invalid (#302):
214
+ // surface it precisely rather than letting the field-based status claim the cut
215
+ // is image-ready.
216
+ const hasStale = staleMessages.length > 0;
217
+ // A PNG clean image awaiting conversion (#441) is a normal step, not an error —
218
+ // it takes precedence over the stale/missing framing for this cut.
219
+ const needsConversion = !!conversionPng;
220
+
221
+ const handleConvertThis = useCallback(async () => {
222
+ if (!conversionPng) return;
223
+ setConvertingThis(true);
224
+ await onConvert(cut.id, conversionPng);
225
+ setConvertingThis(false);
226
+ onUpdated();
227
+ }, [conversionPng, onConvert, cut.id, onUpdated]);
228
+
229
+ // Returns true on a successful upload so callers (e.g. the Codex import picker)
230
+ // 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;
247
+ }
248
+ }
249
+
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");
260
+ return false;
261
+ }
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]);
271
+
272
+ // Creator-facing board status + the single primary action for this cut (#440).
273
+ const board = boardStatus(cut, needsConversion, hasStale);
274
+ // A viewable thumbnail: the recorded clean image (the asset route serves PNG
275
+ // too, so a draft PNG previews) or the unrecorded convertible PNG.
276
+ const thumbPath = cut.cleanImagePath ?? conversionPng ?? null;
277
+ // A cut sitting at the lettering step (#442): clean art is ready, nothing is
278
+ // exported/uploaded yet, and it isn't blocked on convert/stale. These get the
279
+ // first-class Manual/AI-draft lettering choice instead of a single button.
280
+ const bubblesPlaced = cut.overlays?.length ?? 0;
281
+ const atLetteringStage =
282
+ !isTextPanel(cut) && !!cut.cleanImagePath && !cut.finalImagePath &&
283
+ !cut.uploadedCid && !cut.uploadedUrl && !hasStale && !needsConversion;
284
+
285
+ 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
291
+ const reviewState = letteringReviewState(cut);
292
+
293
+ return (
294
+ <div
295
+ ref={rowRef}
296
+ data-cut-row={cut.id}
297
+ className={`border rounded ${expanded ? "border-accent/30" : "border-border"}`}
298
+ >
299
+ {/* Card head — always visible: status, identity, thumbnail, one primary
300
+ action, plus an "Open details" toggle for the technical controls (#440). */}
301
+ <div className="px-3 py-2 space-y-2" data-testid={`cut-card-${cut.id}`}>
302
+ <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}`}>
307
+ {board.label}
308
+ </span>
309
+ </div>
310
+ {thumbPath ? (
311
+ <AssetImage
312
+ storyName={storyName}
313
+ assetPath={thumbPath}
314
+ authFetch={authFetch}
315
+ alt={`Cut ${cut.id} artwork`}
316
+ className="w-full max-h-[32rem] object-contain rounded border border-border bg-white"
317
+ />
318
+ ) : (
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"}
321
+ </div>
322
+ )}
323
+ <div
324
+ className={`rounded border border-border/70 bg-surface/50 px-2 py-1.5 text-[11px] ${BOARD_TONE_TEXT[reviewState.tone]}`}
325
+ data-testid={`lettering-review-state-${cut.id}`}
326
+ >
327
+ <span className="font-semibold">{reviewState.label}</span>
328
+ <span className="text-muted"> · {reviewState.detail}</span>
329
+ </div>
330
+ <button
331
+ onClick={onToggle}
332
+ data-testid={`cut-desc-${cut.id}`}
333
+ className="block w-full text-left text-[11px] text-muted hover:text-foreground"
334
+ >
335
+ {cut.description || "No description"}
336
+ </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
+ </div>
365
+
366
+ {expanded && (
367
+ <div className="px-3 pb-3 space-y-3 border-t border-border">
368
+ {/* Stale recorded asset path (#302): the cut records a clean/final image
369
+ path but the file is missing/invalid. Show the precise reason and a
370
+ repair action that clears the stale clean AND final fields back to
371
+ null (valid paths and uploaded URLs are preserved). */}
372
+ {/* PNG clean image awaiting conversion (#441): offer the conversion
373
+ rather than the stale-path "Clear" repair (which would discard the
374
+ draft art). The raw unsupported-extension reason stays hidden in the
375
+ Convert artwork banner's technical details. */}
376
+ {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}`}>
378
+ <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.
380
+ </p>
381
+ <button
382
+ onClick={handleConvertThis}
383
+ disabled={convertingThis || converting}
384
+ data-testid={`convert-cut-${cut.id}`}
385
+ className="px-2 py-1 text-[11px] border border-amber-500/50 text-amber-800 rounded hover:bg-amber-500/20 disabled:opacity-50"
386
+ >
387
+ {convertingThis ? "Converting…" : "Convert image"}
388
+ </button>
389
+ </div>
390
+ )}
391
+ {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}`}>
393
+ {staleMessages.map((m, i) => (
394
+ <p key={i} className="text-[11px] text-error">{m}</p>
395
+ ))}
396
+ <button
397
+ onClick={onRepairStale}
398
+ disabled={repairing}
399
+ data-testid={`repair-stale-${cut.id}`}
400
+ className="px-2 py-1 text-[11px] border border-error/40 text-error rounded hover:bg-error/10 disabled:opacity-50"
401
+ >
402
+ {repairing ? "Repairing…" : "Clear stale path"}
403
+ </button>
404
+ </div>
405
+ )}
406
+
407
+ {/* The clean/artwork thumbnail now lives in the always-visible card
408
+ head (#440); Details holds the technical controls below. */}
409
+
410
+ {/* Clean image: copy generation prompt + upload the generated file.
411
+ Text/interstitial panels need no clean image (#351), so this whole
412
+ generation/upload handoff is image-cut only. */}
413
+ {!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. */}
449
+ <button
450
+ onClick={() => setShowCodexPicker((v) => !v)}
451
+ disabled={uploading}
452
+ data-testid={`import-codex-${cut.id}`}
453
+ className="px-3 py-1.5 text-xs border border-border rounded hover:border-accent hover:bg-accent/5 disabled:opacity-50"
454
+ >
455
+ {showCodexPicker ? "Hide Codex images" : "Import from Codex"}
456
+ </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);
465
+ }}
466
+ onClose={() => setShowCodexPicker(false)}
467
+ />
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>
488
+ <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"
496
+ >
497
+ {askCopied ? "Copied!" : "Copy Codex task"}
498
+ </button>
499
+ </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>
515
+ )}
516
+
517
+ {/* Open editor — image cuts, narration cuts, and text panels (#351) */}
518
+ {(cut.cleanImagePath || cut.narration || cut.dialogue.length > 0 || isTextPanel(cut)) && (
519
+ <button
520
+ onClick={onOpenEditor}
521
+ data-testid={`open-editor-${cut.id}`}
522
+ className="px-3 py-1.5 text-xs border border-accent/30 text-accent rounded hover:bg-accent/5"
523
+ >
524
+ Open editor
525
+ </button>
526
+ )}
527
+
528
+ {/* Cut metadata */}
529
+ {cut.characters.length > 0 && (
530
+ <p className="text-xs text-muted">Characters: {cut.characters.join(", ")}</p>
531
+ )}
532
+ {cut.dialogue.length > 0 && (
533
+ <div className="text-xs text-muted">
534
+ {cut.dialogue.map((d, i) => (
535
+ <p key={i}><span className="font-medium">{d.speaker}:</span> {d.text}</p>
536
+ ))}
537
+ </div>
538
+ )}
539
+ {cut.narration && (
540
+ <p className="text-xs text-muted italic">{cut.narration}</p>
541
+ )}
542
+ </div>
543
+ )}
544
+ </div>
545
+ );
546
+ }
547
+
548
+ export function CutListPanel({ storyName, fileName, authFetch, language, uploadRetry, onCutsChanged, focusRequest, onFocusHandled }: CutListPanelProps) {
549
+ const [cutsFile, setCutsFile] = useState<CutsFile | null>(null);
550
+ // Latest onCutsChanged in a ref so loadCuts can notify the parent without
551
+ // taking the callback as a dependency (which would churn loadCuts/effects).
552
+ const onCutsChangedRef = useRef(onCutsChanged);
553
+ onCutsChangedRef.current = onCutsChanged;
554
+ const onFocusHandledRef = useRef(onFocusHandled);
555
+ onFocusHandledRef.current = onFocusHandled;
556
+ const [loading, setLoading] = useState(true);
557
+ const [error, setError] = useState<string | null>(null);
558
+ const [expandedCut, setExpandedCut] = useState<number | null>(null);
559
+ const [editingCutId, setEditingCutId] = useState<number | null>(null);
560
+ const [generating, setGenerating] = useState(false);
561
+ const [genWarnings, setGenWarnings] = useState<string[]>([]);
562
+ const [uploading, setUploading] = useState(false);
563
+ const [uploadProgress, setUploadProgress] = useState("");
564
+ // Episode publish markdown + on-chain state, for the guided Finish panel (#414):
565
+ // distinguishes uploaded-but-not-prepared from a prepared/ready-to-publish or
566
+ // already-published episode (which cuts.json alone cannot tell apart).
567
+ const [episodeState, setEpisodeState] = useState<{ markdownReady: boolean; published: boolean }>({
568
+ markdownReady: false,
569
+ published: false,
570
+ });
571
+ const [syncing, setSyncing] = useState(false);
572
+ const [repairing, setRepairing] = useState(false);
573
+ const [converting, setConverting] = useState(false);
574
+ const [convertResult, setConvertResult] = useState<string | null>(null);
575
+ const [syncResult, setSyncResult] = useState<string | null>(null);
576
+ const [detected, setDetected] = useState<Set<number>>(new Set());
577
+ // cutId → precise stale-path messages (#302), from detect-clean-images.
578
+ const [staleByCut, setStaleByCut] = useState<Map<number, string[]>>(new Map());
579
+ // True only after /detect-clean-images has SUCCESSFULLY verified the recorded
580
+ // paths against disk (#311). Gates the "clean-assets-ready" banner so it never
581
+ // claims completion from unverified cut-plan fields while detection is pending
582
+ // or after it failed.
583
+ const [detectConfirmed, setDetectConfirmed] = useState(false);
584
+ // Read-only per-cut asset diagnostics validated against disk (#427): the real
585
+ // state (planned/missing/clean-ready/final-ready/uploaded) + precise issues.
586
+ const [assetDiagnostics, setAssetDiagnostics] = useState<CutAssetDiagnostic[] | null>(null);
587
+ const [rescanning, setRescanning] = useState(false);
588
+ // #371: cut whose row should be scrolled into view after a Preview→Edit deep
589
+ // link. Applied once its row has rendered (see the scroll effect below).
590
+ const [scrollTargetCutId, setScrollTargetCutId] = useState<number | null>(null);
591
+ // Live DOM refs for cut rows, keyed by cut id, used to scroll a focused cut
592
+ // into view. A ref map avoids re-rendering on registration.
593
+ const rowRefs = useRef<Map<number, HTMLDivElement>>(new Map());
594
+ // Guards against re-applying the same focus request twice within one mount.
595
+ const appliedFocusSeq = useRef<number | null>(null);
596
+
597
+ const plotFile = fileName.replace(/\.md$/, "");
598
+
599
+ // Apply a Preview / Cut Inspector deep-link (#371): open the lettering editor
600
+ // for the cut, or expand + scroll its row when there is nothing to letter yet.
601
+ // The chosen expandedCut/editingCutId state persists until the rows render
602
+ // (cuts load asynchronously), so this does not need the cut plan loaded first.
603
+ useEffect(() => {
604
+ if (!focusRequest) return;
605
+ if (appliedFocusSeq.current === focusRequest.seq) return;
606
+ appliedFocusSeq.current = focusRequest.seq;
607
+ if (focusRequest.openEditor) {
608
+ setEditingCutId(focusRequest.cutId);
609
+ } else {
610
+ setExpandedCut(focusRequest.cutId);
611
+ setScrollTargetCutId(focusRequest.cutId);
612
+ }
613
+ onFocusHandledRef.current?.();
614
+ }, [focusRequest]);
615
+
616
+ // Scroll a deep-linked, expanded cut into view once its row is on screen. Runs
617
+ // when the target is set and again after the cut plan loads (rows mount). Best
618
+ // effort: `scrollIntoView` is a no-op/undefined under jsdom.
619
+ useEffect(() => {
620
+ if (scrollTargetCutId == null) return;
621
+ const el = rowRefs.current.get(scrollTargetCutId);
622
+ if (!el) return;
623
+ el.scrollIntoView?.({ behavior: "smooth", block: "center" });
624
+ setScrollTargetCutId(null);
625
+ }, [scrollTargetCutId, cutsFile]);
626
+
627
+ const loadCuts = useCallback(async () => {
628
+ try {
629
+ const res = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}`);
630
+ if (res.status === 404) {
631
+ setCutsFile(null);
632
+ return;
633
+ }
634
+ if (!res.ok) {
635
+ const data = await res.json();
636
+ setError(data.error || "Failed to load cuts");
637
+ return;
638
+ }
639
+ const parsed = await res.json();
640
+ setCutsFile(parsed);
641
+ setError(null);
642
+ // Read the episode's publish markdown + on-chain status so the Finish panel
643
+ // can show "Episode sequence prepared" / "Ready to publish" / "Published"
644
+ // distinctly, not just upload progress (#414). Best-effort — a missing file
645
+ // or error simply leaves those steps as not-yet-prepared.
646
+ try {
647
+ const fileRes = await authFetch(`/api/stories/${storyName}/${fileName}`);
648
+ if (fileRes.ok) {
649
+ const fd = await fileRes.json();
650
+ const content: string = typeof fd?.content === "string" ? fd.content : "";
651
+ 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";
654
+ setEpisodeState({ markdownReady, published });
655
+ } else {
656
+ setEpisodeState({ markdownReady: false, published: false });
657
+ }
658
+ } catch {
659
+ setEpisodeState({ markdownReady: false, published: false });
660
+ }
661
+ // Tell the parent the cut plan changed so its readiness/Episode-steps view
662
+ // refreshes in lockstep (e.g. after a lettering export, #343).
663
+ onCutsChangedRef.current?.();
664
+ } catch {
665
+ setError("Failed to load cuts");
666
+ } finally {
667
+ setLoading(false);
668
+ }
669
+ }, [authFetch, storyName, plotFile, fileName]);
670
+
671
+ // Server-confirmed detection of local clean files for cuts whose cleanImagePath
672
+ // is still null. Best-effort: failures leave the detected set unchanged.
673
+ const loadDetect = useCallback(async () => {
674
+ // Until this detection resolves successfully, the recorded clean paths are
675
+ // unverified — don't let the done banner claim completion (#311).
676
+ setDetectConfirmed(false);
677
+ try {
678
+ const res = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}/detect-clean-images`);
679
+ if (!res.ok) return;
680
+ const data = await res.json();
681
+ setDetected(new Set<number>(Array.isArray(data.detected) ? data.detected : []));
682
+ const staleMap = new Map<number, string[]>();
683
+ const staleList: unknown = data.stale;
684
+ if (Array.isArray(staleList)) {
685
+ for (const s of staleList) {
686
+ if (typeof s?.cutId !== "number" || typeof s?.message !== "string") continue;
687
+ const arr = staleMap.get(s.cutId) ?? [];
688
+ arr.push(s.message);
689
+ staleMap.set(s.cutId, arr);
690
+ }
691
+ }
692
+ setStaleByCut(staleMap);
693
+ setDetectConfirmed(true);
694
+ } catch {
695
+ /* ignore — affordance simply will not show */
696
+ }
697
+ }, [authFetch, storyName, plotFile]);
698
+
699
+ // Read-only per-cut asset state validated against disk (#427). Best-effort.
700
+ // Clear the prior plan's diagnostics in EVERY exit path (start, non-OK, catch)
701
+ // so a stale missing-path banner can never persist under a different cut plan
702
+ // when the new request fails/404s on a story/file switch (@re1).
703
+ const loadDiagnostics = useCallback(async () => {
704
+ setAssetDiagnostics(null);
705
+ try {
706
+ const res = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}/asset-diagnostics`);
707
+ if (!res.ok) return; // stays cleared
708
+ const data = await res.json();
709
+ setAssetDiagnostics(Array.isArray(data.diagnostics) ? data.diagnostics : null);
710
+ } catch { /* stays cleared — diagnostics are optional */ }
711
+ }, [authFetch, storyName, plotFile]);
712
+
713
+ // "Refresh assets / Check generated images" (#427): a read-only rescan that
714
+ // re-reads the cut plan, re-detects local clean files, and re-classifies each
715
+ // cut's asset state against disk — so a writer can notice agent-generated images
716
+ // without restarting. Never mutates, uploads, or publishes (unlike Sync).
717
+ const refreshAssets = useCallback(async () => {
718
+ setRescanning(true);
719
+ try {
720
+ await Promise.all([loadCuts(), loadDetect(), loadDiagnostics()]);
721
+ } finally {
722
+ setRescanning(false);
723
+ }
724
+ }, [loadCuts, loadDetect, loadDiagnostics]);
725
+
726
+ const syncCleanImages = useCallback(async () => {
727
+ setSyncing(true);
728
+ setSyncResult(null);
729
+ setGenWarnings([]);
730
+ try {
731
+ const res = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}/sync-clean-images`, { method: "POST" });
732
+ const data = await res.json().catch(() => ({}));
733
+ if (!res.ok) {
734
+ setSyncResult(data.error || "Sync failed");
735
+ } else {
736
+ const syncedCount = Array.isArray(data.synced) ? data.synced.length : 0;
737
+ const clearedCount = Array.isArray(data.cleared) ? data.cleared.length : 0;
738
+ const rejected = Array.isArray(data.rejected) ? data.rejected : [];
739
+ if (rejected.length > 0) {
740
+ setGenWarnings(rejected.map((r: { cutId: number; reason: string }) => `Cut ${r.cutId}: ${r.reason}`));
741
+ }
742
+ const parts: string[] = [];
743
+ 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");
746
+ await loadCuts();
747
+ await loadDetect();
748
+ await loadDiagnostics();
749
+ }
750
+ } catch {
751
+ setSyncResult("Sync failed");
752
+ }
753
+ setSyncing(false);
754
+ }, [authFetch, storyName, plotFile, loadCuts, loadDetect, loadDiagnostics]);
755
+
756
+ // Convert one cut's PNG clean image to a publishable WebP/JPEG (#441): fetch
757
+ // the PNG asset, convert + compress it in the browser (same path as a manual
758
+ // upload), and persist via the existing upload-clean route, which records the
759
+ // new cleanImagePath. Returns true on success. Publish stays strict — the route
760
+ // 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]);
776
+
777
+ // "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]);
797
+
798
+ // Guided "Finish episode" orchestration (#414): upload every exported final
799
+ // image (paced under the rate limit, #413/#288), then prepare the publish
800
+ // markdown — in order, resumable (already-uploaded cuts are skipped by the
801
+ // `!uploadedCid` filter). Surfaced as the primary "Finish episode" action and
802
+ // reused by the lower-level "Upload & Prepare for Publish" control.
803
+ const finishEpisode = useCallback(async () => {
804
+ if (!cutsFile) return;
805
+ setUploading(true);
806
+ setUploadProgress("");
807
+ setGenWarnings([]);
808
+ const toUpload = cutsFile.cuts.filter((ct) => ct.finalImagePath && !ct.uploadedCid);
809
+ const errors: string[] = [];
810
+ // Proactively pace uploads under PlotLink's 5/min limit so a 7–10 cut episode
811
+ // completes without manual waiting, instead of firing all at once and thrashing
812
+ // on reactive 429 backoff (#413). Reuses the same injectable sleep as the retry
813
+ // path so tests stay deterministic.
814
+ const throttle = createUploadThrottle({
815
+ sleep: uploadRetry?.sleep,
816
+ onWaiting: ({ waitMs }) =>
817
+ setUploadProgress(
818
+ `Upload limit reached — waiting ${Math.round(waitMs / 1000)}s before continuing…`,
819
+ ),
820
+ });
821
+ for (let i = 0; i < toUpload.length; i++) {
822
+ const ct = toUpload[i];
823
+ setUploadProgress(`Uploading cut ${ct.id} (${i + 1}/${toUpload.length})...`);
824
+ 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; }
828
+ const blob = await imgRes.blob();
829
+ const fd = new FormData();
830
+ fd.append("file", blob, `cut-${ct.id}.${blob.type === "image/webp" ? "webp" : "jpg"}`);
831
+ // Proactively wait if we've already used the 5/min budget (#413), then retry
832
+ // with backoff while the PlotLink endpoint rate-limits us anyway (5
833
+ // uploads/min), instead of failing the whole batch (#288). Already-uploaded
834
+ // cuts are skipped by the `!uploadedCid` filter above, so a retry never
835
+ // re-uploads a recorded cut.
836
+ await throttle();
837
+ const upload = await withRateLimitRetry(
838
+ async () => {
839
+ const res = await authFetch("/api/publish/upload-plot-image", { method: "POST", body: fd });
840
+ if (res.ok) {
841
+ const { cid, url } = await res.json();
842
+ return { ok: true as const, status: res.status, cid, url };
843
+ }
844
+ const e = await res.json().catch(() => ({}));
845
+ return { ok: false as const, status: res.status, errorMessage: (e as { error?: string }).error };
846
+ },
847
+ {
848
+ ...uploadRetry,
849
+ onWaiting: ({ attempt, maxRetries, waitMs }) =>
850
+ setUploadProgress(
851
+ `Cut ${ct.id} rate-limited — waiting ${Math.round(waitMs / 1000)}s before retry ${attempt}/${maxRetries}...`,
852
+ ),
853
+ },
854
+ );
855
+ if (!upload.ok) { errors.push(`Cut ${ct.id}: upload failed — ${upload.errorMessage || "unknown"}`); continue; }
856
+ 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`); }
863
+ } catch (err) {
864
+ errors.push(`Cut ${ct.id}: ${err instanceof Error ? err.message : "failed"}`);
865
+ }
866
+ }
867
+ if (errors.length > 0) {
868
+ setGenWarnings(errors);
869
+ setUploading(false);
870
+ setUploadProgress("");
871
+ loadCuts();
872
+ return;
873
+ }
874
+ setUploadProgress("Preparing episode for publishing…");
875
+ const mdRes = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}/generate-markdown`, { method: "POST" });
876
+ if (mdRes.ok) {
877
+ const data = await mdRes.json();
878
+ if (data.warnings?.length > 0) setGenWarnings(data.warnings);
879
+ }
880
+ setUploading(false);
881
+ setUploadProgress("");
882
+ loadCuts();
883
+ }, [cutsFile, authFetch, storyName, plotFile, uploadRetry, loadCuts]);
884
+
885
+ // Clear stale recorded clean/final paths back to null (#302). Unlike sync,
886
+ // this repairs a stale finalImagePath too; valid paths and uploaded URLs are
887
+ // preserved server-side.
888
+ const repairStalePaths = useCallback(async () => {
889
+ setRepairing(true);
890
+ setSyncResult(null);
891
+ try {
892
+ const res = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}/repair-asset-paths`, { method: "POST" });
893
+ const data = await res.json().catch(() => ({}));
894
+ if (!res.ok) {
895
+ setSyncResult(data.error || "Repair failed");
896
+ } 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");
899
+ await loadCuts();
900
+ await loadDetect();
901
+ }
902
+ } catch {
903
+ setSyncResult("Repair failed");
904
+ }
905
+ setRepairing(false);
906
+ }, [authFetch, storyName, plotFile, loadCuts, loadDetect]);
907
+
908
+ // Insert a text/interstitial panel to the cut plan (#352/#488) — a one-click way
909
+ // to add a narration/title card between image cuts without hand-editing cuts.json.
910
+ 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");
938
+ }
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]);
945
+
946
+ useEffect(() => {
947
+ loadCuts();
948
+ loadDetect();
949
+ loadDiagnostics();
950
+ }, [loadCuts, loadDetect, loadDiagnostics]);
951
+
952
+ if (loading) {
953
+ return <div className="p-4 text-sm text-muted">Loading cuts...</div>;
954
+ }
955
+
956
+ if (error) {
957
+ return (
958
+ <div className="p-4 space-y-2" data-testid="cuts-error">
959
+ <p className="text-sm text-error font-medium">Invalid cuts file</p>
960
+ <p className="text-xs text-error">{error}</p>
961
+ <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.
963
+ </p>
964
+ <button onClick={loadCuts} className="text-xs text-accent hover:text-accent-dim">Retry</button>
965
+ </div>
966
+ );
967
+ }
968
+
969
+ if (!cutsFile || cutsFile.cuts.length === 0) {
970
+ return (
971
+ <div className="p-4 text-center space-y-1">
972
+ <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>
974
+ </div>
975
+ );
976
+ }
977
+
978
+ const editingCut = editingCutId !== null ? cutsFile.cuts.find((c) => c.id === editingCutId) : null;
979
+
980
+ if (editingCut) {
981
+ return (
982
+ <LetteringEditor
983
+ storyName={storyName}
984
+ cut={editingCut}
985
+ plotFile={plotFile}
986
+ language={language}
987
+ authFetch={authFetch}
988
+ targetLabel={isTextPanel(editingCut) ? `Between-scene card ${editingCut.id}` : `Cut ${String(editingCut.id).padStart(2, "0")}`}
989
+ 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");
1000
+ }
1001
+ }}
1002
+ onExported={() => loadCuts()}
1003
+ onClose={() => { setEditingCutId(null); loadCuts(); }}
1004
+ />
1005
+ );
1006
+ }
1007
+
1008
+ const stats = cutsFile.cuts.reduce(
1009
+ (acc, cut) => {
1010
+ const s = getCutStatus(cut);
1011
+ acc[s]++;
1012
+ return acc;
1013
+ },
1014
+ { missing: 0, clean: 0, lettered: 0, uploaded: 0, text: 0 } as Record<CutStatus, number>,
1015
+ );
1016
+ // Text/interstitial panels need no clean image (#351), so the clean-assets
1017
+ // banner/claims reason about IMAGE cuts only — never the total cut count.
1018
+ const imageCutCount = cutsFile.cuts.filter((c) => !isTextPanel(c)).length;
1019
+ // #381: final images lettered by an older bubble renderer (separate-tail seam)
1020
+ // 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);
1022
+
1023
+ // Guided "Finish episode" state (#414). The checklist's publish step reflects the
1024
+ // real on-chain status; markdownReady distinguishes uploaded-but-not-prepared from
1025
+ // a prepared/ready-to-publish episode. canFinish = something the Finish action can
1026
+ // 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";
1029
+ const canFinish =
1030
+ cutsFile.cuts.some((ct) => ct.finalImagePath && !ct.uploadedCid) ||
1031
+ (uploadStepDone && !episodeState.markdownReady);
1032
+
1033
+ // PNG clean images awaiting conversion (#441): a friendly, batch-able step, not
1034
+ // a red unsupported-extension dump. Built from the disk-validated diagnostics.
1035
+ const conversionJobs = (assetDiagnostics ?? [])
1036
+ .filter((d) => d.state === "needs-conversion" && d.convertiblePng)
1037
+ .map((d) => ({ cutId: d.cutId, pngPath: d.convertiblePng as string }));
1038
+ const conversionByCut = new Map(conversionJobs.map((j) => [j.cutId, j.pngPath]));
1039
+ const conversionIssues = (assetDiagnostics ?? [])
1040
+ .filter((d) => d.state === "needs-conversion" && d.issue)
1041
+ .map((d) => d.issue as string);
1042
+
1043
+ // Creator-facing episode header + progress summary (#440). Counts the human
1044
+ // milestones, not internal fields: artwork found (any clean image incl. a draft
1045
+ // PNG), converted (publishable WebP/JPEG), lettered (bubbles placed/exported),
1046
+ // 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;
1051
+ const imageCuts = cutsFile.cuts.filter((c) => !isTextPanel(c));
1052
+ const boardSummary = {
1053
+ 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,
1058
+ };
1059
+
1060
+ return (
1061
+ <div className="h-full min-h-[22rem] flex flex-col overflow-hidden" data-testid="cut-list-panel">
1062
+ {/* 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
+ <div
1144
+ className="px-3 py-2 border-b border-border bg-surface/40 flex-shrink-0"
1145
+ data-testid="cartoon-workflow-help"
1146
+ >
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.
1158
+ </div>
1159
+ </div>
1160
+ {/* Stale bubble-renderer warning (#381): a final image lettered before the
1161
+ current seamless-tail renderer may show the old separate-tail seam.
1162
+ Mark those cuts so the writer re-exports (open lettering → Export) and
1163
+ re-uploads them before publishing. */}
1164
+ {staleTailIds.length > 0 && (
1165
+ <div
1166
+ 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
+ data-testid="stale-bubble-export-warning"
1168
+ >
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.
1170
+ </div>
1171
+ )}
1172
+ {/* Clean-asset generation done-state (#311): when every cut has a present,
1173
+ valid clean image, surface a clear "done" signal so the operator knows
1174
+ Codex generation is complete even if the terminal session is still
1175
+ 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
1198
+ </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
+ </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
+ )}
1220
+ </div>
1221
+ )}
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
+ {/* Full cut review (#488): all clean cuts are shown vertically first, with
1259
+ 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">
1261
+ {cutsFile.cuts.map((cut, index) => (
1262
+ <Fragment key={cut.id}>
1263
+ <BetweenSceneSlot
1264
+ index={index}
1265
+ beforeLabel={index === 0 ? "Episode opening" : `After cut ${cutsFile.cuts[index - 1]?.id}`}
1266
+ afterLabel={`Before cut ${cut.id}`}
1267
+ disabled={addingPanel}
1268
+ onAdd={() => addTextPanelAt(index)}
1269
+ />
1270
+ <CutRow
1271
+ cut={cut}
1272
+ storyName={storyName}
1273
+ plotFile={plotFile}
1274
+ expanded={expandedCut === cut.id}
1275
+ onToggle={() => setExpandedCut(expandedCut === cut.id ? null : cut.id)}
1276
+ authFetch={authFetch}
1277
+ onUpdated={() => { loadCuts(); loadDetect(); loadDiagnostics(); }}
1278
+ onOpenEditor={() => setEditingCutId(cut.id)}
1279
+ detectedLocalClean={detected.has(cut.id)}
1280
+ onSyncClean={syncCleanImages}
1281
+ syncing={syncing}
1282
+ staleMessages={staleByCut.get(cut.id) ?? []}
1283
+ onRepairStale={repairStalePaths}
1284
+ repairing={repairing}
1285
+ conversionPng={conversionByCut.get(cut.id) ?? null}
1286
+ onConvert={convertCut}
1287
+ converting={converting}
1288
+ rowRef={(el) => { if (el) rowRefs.current.set(cut.id, el); else rowRefs.current.delete(cut.id); }}
1289
+ />
1290
+ </Fragment>
1291
+ ))}
1292
+ <BetweenSceneSlot
1293
+ index={cutsFile.cuts.length}
1294
+ beforeLabel={`After cut ${cutsFile.cuts[cutsFile.cuts.length - 1]?.id}`}
1295
+ afterLabel="Episode ending"
1296
+ disabled={addingPanel}
1297
+ onAdd={() => addTextPanelAt(cutsFile.cuts.length)}
1298
+ />
1299
+ </div>
1300
+ </div>
1301
+ );
1302
+ }
1303
+
1304
+ function BetweenSceneSlot({
1305
+ index,
1306
+ beforeLabel,
1307
+ afterLabel,
1308
+ disabled,
1309
+ onAdd,
1310
+ }: {
1311
+ index: number;
1312
+ beforeLabel: string;
1313
+ afterLabel: string;
1314
+ disabled: boolean;
1315
+ onAdd: () => void;
1316
+ }) {
1317
+ return (
1318
+ <div
1319
+ className="rounded border border-dashed border-border bg-surface/35 px-3 py-2 text-[11px] text-muted flex items-center gap-3"
1320
+ data-testid={`between-scene-slot-${index}`}
1321
+ >
1322
+ <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>
1325
+ </span>
1326
+ <button
1327
+ type="button"
1328
+ onClick={onAdd}
1329
+ disabled={disabled}
1330
+ className="flex-shrink-0 rounded border border-accent/40 px-2.5 py-1 text-[11px] font-medium text-accent hover:bg-accent/5 disabled:opacity-50"
1331
+ data-testid={`add-between-scene-${index}`}
1332
+ >
1333
+ Add card
1334
+ </button>
1335
+ </div>
1336
+ );
1337
+ }