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