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