plotlink-ows 1.0.33 → 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.
- package/README.md +4 -0
- package/app/lib/agent-command.ts +85 -0
- package/app/lib/agent-readiness.ts +133 -0
- package/app/lib/apply-schema.ts +55 -0
- package/app/lib/bubble-text.ts +160 -0
- package/app/lib/cartoon-coach.ts +198 -0
- package/app/lib/cartoon-markdown.ts +83 -0
- package/app/lib/cartoon-prompt.ts +122 -0
- package/app/lib/cartoon-readiness.ts +811 -0
- package/app/lib/clean-image-sync.ts +245 -0
- package/app/lib/codex-images.ts +152 -0
- package/app/lib/cut-asset-diagnostics.ts +120 -0
- package/app/lib/cuts.ts +302 -0
- package/app/lib/fonts.ts +109 -0
- package/app/lib/generate-claude-md.ts +8 -1
- package/app/lib/generate-story-instructions.ts +731 -0
- package/app/lib/image-asset-validate.ts +123 -0
- package/app/lib/lettering-status.ts +133 -0
- package/app/lib/overlays.ts +637 -0
- package/app/lib/paths.ts +10 -0
- package/app/lib/public-title.ts +65 -0
- package/app/lib/publish.ts +16 -2
- package/app/lib/story-progress.ts +243 -0
- package/app/lib/terminal-protocol.ts +16 -0
- package/app/lib/terminal-redact.ts +50 -0
- package/app/prisma/schema.sql +25 -0
- package/app/routes/agent.ts +42 -0
- package/app/routes/codex-images.ts +67 -0
- package/app/routes/publish.ts +203 -22
- package/app/routes/stories.ts +961 -5
- package/app/routes/terminal.ts +383 -31
- package/app/server.ts +47 -12
- package/app/vite.config.ts +6 -0
- package/app/web/components/CartoonPreview.tsx +267 -0
- package/app/web/components/CartoonPublishPage.tsx +407 -0
- package/app/web/components/CartoonPublishPreview.tsx +121 -0
- package/app/web/components/CartoonStepGuide.tsx +90 -0
- package/app/web/components/CartoonWorkflowNav.tsx +68 -0
- package/app/web/components/CodexImportPicker.tsx +230 -0
- package/app/web/components/CutListPanel.tsx +1299 -0
- package/app/web/components/EpisodesPage.tsx +80 -0
- package/app/web/components/FinishEpisodePanel.tsx +151 -0
- package/app/web/components/Layout.tsx +7 -4
- package/app/web/components/LetteringEditor.tsx +1141 -0
- package/app/web/components/PreviewPanel.tsx +951 -78
- package/app/web/components/Settings.tsx +63 -0
- package/app/web/components/StoriesPage.tsx +710 -33
- package/app/web/components/StoryBrowser.tsx +22 -14
- package/app/web/components/StoryInfoPage.tsx +266 -0
- package/app/web/components/StoryProgressPanel.tsx +516 -0
- package/app/web/components/TerminalPanel.tsx +233 -11
- package/app/web/components/WorkflowCoach.tsx +128 -0
- package/app/web/components/asset-image.tsx +114 -0
- package/app/web/components/asset-test-utils.ts +44 -0
- package/app/web/components/export-cut.ts +320 -0
- package/app/web/dist/assets/export-cut-nKQ_n2-J.js +1 -0
- package/app/web/dist/assets/index-BAZGwVwj.js +143 -0
- package/app/web/dist/assets/index-DoXH2OlP.css +32 -0
- package/app/web/dist/index.html +2 -2
- package/app/web/lib/cartoon-publish-summary.ts +43 -0
- package/app/web/lib/codex-import.ts +94 -0
- package/app/web/lib/image-compress.ts +53 -0
- package/app/web/lib/import-image.ts +58 -0
- package/app/web/lib/publish-helpers.ts +385 -0
- package/app/web/lib/upload-retry.ts +130 -0
- package/app/web/lib/verify-public-title.ts +105 -0
- package/app/web/styles.css +9 -0
- package/bin/plotlink-ows.js +53 -16
- package/bin/startup-plan.cjs +58 -0
- package/lib/genres.ts +92 -0
- package/package.json +60 -20
- package/scripts/gen-schema-sql.mjs +49 -0
- package/scripts/package-hygiene.mjs +116 -0
- package/scripts/preflight.mjs +173 -0
- package/scripts/start-smoke.mjs +128 -0
- package/app/node_modules/.prisma/local-client/client.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/client.js +0 -5
- package/app/node_modules/.prisma/local-client/default.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/default.js +0 -5
- package/app/node_modules/.prisma/local-client/edge.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/edge.js +0 -184
- package/app/node_modules/.prisma/local-client/index-browser.js +0 -173
- package/app/node_modules/.prisma/local-client/index.d.ts +0 -3304
- package/app/node_modules/.prisma/local-client/index.js +0 -207
- package/app/node_modules/.prisma/local-client/libquery_engine-darwin-arm64.dylib.node +0 -0
- package/app/node_modules/.prisma/local-client/package.json +0 -183
- package/app/node_modules/.prisma/local-client/query_engine_bg.js +0 -2
- package/app/node_modules/.prisma/local-client/query_engine_bg.wasm +0 -0
- package/app/node_modules/.prisma/local-client/runtime/edge-esm.js +0 -35
- package/app/node_modules/.prisma/local-client/runtime/edge.js +0 -35
- package/app/node_modules/.prisma/local-client/runtime/index-browser.d.ts +0 -370
- package/app/node_modules/.prisma/local-client/runtime/index-browser.js +0 -17
- package/app/node_modules/.prisma/local-client/runtime/library.d.ts +0 -3982
- package/app/node_modules/.prisma/local-client/runtime/library.js +0 -147
- package/app/node_modules/.prisma/local-client/runtime/react-native.js +0 -84
- package/app/node_modules/.prisma/local-client/runtime/wasm-compiler-edge.js +0 -85
- package/app/node_modules/.prisma/local-client/runtime/wasm-engine-edge.js +0 -38
- package/app/node_modules/.prisma/local-client/schema.prisma +0 -21
- package/app/node_modules/.prisma/local-client/wasm-edge-light-loader.mjs +0 -5
- package/app/node_modules/.prisma/local-client/wasm-worker-loader.mjs +0 -5
- package/app/node_modules/.prisma/local-client/wasm.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/wasm.js +0 -191
- package/app/web/dist/assets/index-B-2Ft7Yv.css +0 -32
- package/app/web/dist/assets/index-DxATSk7X.js +0 -134
- package/packages/cli/node_modules/commander/LICENSE +0 -22
- package/packages/cli/node_modules/commander/Readme.md +0 -1149
- package/packages/cli/node_modules/commander/esm.mjs +0 -16
- package/packages/cli/node_modules/commander/index.js +0 -24
- package/packages/cli/node_modules/commander/lib/argument.js +0 -149
- package/packages/cli/node_modules/commander/lib/command.js +0 -2662
- package/packages/cli/node_modules/commander/lib/error.js +0 -39
- package/packages/cli/node_modules/commander/lib/help.js +0 -709
- package/packages/cli/node_modules/commander/lib/option.js +0 -367
- package/packages/cli/node_modules/commander/lib/suggestSimilar.js +0 -101
- package/packages/cli/node_modules/commander/package-support.json +0 -16
- package/packages/cli/node_modules/commander/package.json +0 -82
- package/packages/cli/node_modules/commander/typings/esm.d.mts +0 -3
- package/packages/cli/node_modules/commander/typings/index.d.ts +0 -1045
- package/packages/cli/node_modules/resolve-from/index.d.ts +0 -31
- package/packages/cli/node_modules/resolve-from/index.js +0 -47
- package/packages/cli/node_modules/resolve-from/license +0 -9
- package/packages/cli/node_modules/resolve-from/package.json +0 -36
- package/packages/cli/node_modules/resolve-from/readme.md +0 -72
- package/packages/cli/node_modules/tsup/LICENSE +0 -21
- package/packages/cli/node_modules/tsup/README.md +0 -75
- package/packages/cli/node_modules/tsup/assets/cjs_shims.js +0 -13
- package/packages/cli/node_modules/tsup/assets/esm_shims.js +0 -9
- package/packages/cli/node_modules/tsup/assets/package.json +0 -3
- package/packages/cli/node_modules/tsup/dist/chunk-DI5BO6XE.js +0 -153
- package/packages/cli/node_modules/tsup/dist/chunk-JZ25TPTY.js +0 -42
- package/packages/cli/node_modules/tsup/dist/chunk-PEEXUWMS.js +0 -6
- package/packages/cli/node_modules/tsup/dist/chunk-TWFEYLU4.js +0 -352
- package/packages/cli/node_modules/tsup/dist/chunk-VGC3FXLU.js +0 -203
- package/packages/cli/node_modules/tsup/dist/cli-default.js +0 -12
- package/packages/cli/node_modules/tsup/dist/cli-main.js +0 -8
- package/packages/cli/node_modules/tsup/dist/cli-node.js +0 -14
- package/packages/cli/node_modules/tsup/dist/index.d.ts +0 -511
- package/packages/cli/node_modules/tsup/dist/index.js +0 -1711
- package/packages/cli/node_modules/tsup/dist/rollup.js +0 -6949
- package/packages/cli/node_modules/tsup/package.json +0 -99
- package/packages/cli/node_modules/tsup/schema.json +0 -362
- package/public/screenshot-1.png +0 -0
- package/public/screenshot-2.png +0 -0
- package/public/screenshot-3.png +0 -0
- 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 “Import from Codex” — 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 “Import from Codex” 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’s picked up by “Sync clean images”.
|
|
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
|
+
}
|