plotlink-ows 1.2.94 → 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/active-wallet.ts +260 -0
- package/app/lib/cartoon-coach.ts +1 -1
- package/app/lib/cartoon-readiness.ts +12 -10
- package/app/lib/cuts.ts +135 -18
- package/app/lib/lettering-status.ts +64 -6
- package/app/lib/story-progress.ts +2 -3
- package/app/routes/dashboard.ts +6 -4
- package/app/routes/publish.ts +56 -23
- package/app/routes/settings.ts +92 -37
- package/app/routes/wallet.ts +58 -30
- package/app/web/components/CartoonNextAction.tsx +145 -0
- package/app/web/components/CartoonPublishPage.tsx +1 -1
- package/app/web/components/CutListPanel.tsx +1198 -488
- package/app/web/components/Dashboard.tsx +15 -6
- package/app/web/components/FinishEpisodePanel.tsx +57 -46
- package/app/web/components/LetteringEditor.tsx +867 -366
- package/app/web/components/PreviewPanel.tsx +1459 -844
- package/app/web/components/StoriesPage.tsx +985 -475
- package/app/web/components/StoryProgressPanel.tsx +32 -102
- package/app/web/components/WalletCard.tsx +110 -8
- package/app/web/components/WorkflowCoach.tsx +63 -35
- package/app/web/dist/assets/{export-cut-nKQ_n2-J.js → export-cut-BqZI0-Rv.js} +1 -1
- package/app/web/dist/assets/index-C43toXVm.js +141 -0
- package/app/web/dist/assets/index-CcfChGEK.css +32 -0
- package/app/web/dist/index.html +2 -2
- package/package.json +1 -1
- package/app/web/dist/assets/index-BAZGwVwj.js +0 -143
- package/app/web/dist/assets/index-DoXH2OlP.css +0 -32
|
@@ -1,15 +1,34 @@
|
|
|
1
|
-
import { useState, useEffect, useCallback, useRef } from "react";
|
|
1
|
+
import { Fragment, useState, useEffect, useCallback, useRef } from "react";
|
|
2
2
|
import { LetteringEditor } from "./LetteringEditor";
|
|
3
3
|
import { AssetImage, assetUrl } from "./asset-image";
|
|
4
|
-
import { buildCodexTaskPrompt
|
|
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,6 +208,59 @@ function boardStatus(cut: Cut, needsConversion: boolean, hasStale: boolean): Boa
|
|
|
141
208
|
return { key: "needs-image", label: "Needs image", tone: "muted" };
|
|
142
209
|
}
|
|
143
210
|
|
|
211
|
+
function letteringReviewState(cut: Cut): {
|
|
212
|
+
label: string;
|
|
213
|
+
detail: string;
|
|
214
|
+
tone: BoardTone;
|
|
215
|
+
} {
|
|
216
|
+
if (cut.uploadedCid || cut.uploadedUrl) {
|
|
217
|
+
return { label: "Complete", detail: "Final image uploaded", tone: "green" };
|
|
218
|
+
}
|
|
219
|
+
if (cut.finalImagePath || cut.exportedAt) {
|
|
220
|
+
return { label: "Exported", detail: "Ready to upload", tone: "green" };
|
|
221
|
+
}
|
|
222
|
+
if ((cut.overlays?.length ?? 0) > 0) {
|
|
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
|
+
};
|
|
242
|
+
}
|
|
243
|
+
if (isTextPanel(cut)) {
|
|
244
|
+
return {
|
|
245
|
+
label: "Between-scene card",
|
|
246
|
+
detail: "Open to add narration or title text",
|
|
247
|
+
tone: "accent",
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
if (cut.cleanImagePath) {
|
|
251
|
+
return {
|
|
252
|
+
label: "Unlettered",
|
|
253
|
+
detail: "Clean art ready for bubble placement",
|
|
254
|
+
tone: "muted",
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
label: "Needs artwork",
|
|
259
|
+
detail: "Add or sync clean art first",
|
|
260
|
+
tone: "muted",
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
144
264
|
function CutRow({
|
|
145
265
|
cut,
|
|
146
266
|
storyName,
|
|
@@ -153,6 +273,8 @@ function CutRow({
|
|
|
153
273
|
detectedLocalClean,
|
|
154
274
|
onSyncClean,
|
|
155
275
|
syncing,
|
|
276
|
+
onAiDraft,
|
|
277
|
+
aiDrafting,
|
|
156
278
|
staleMessages,
|
|
157
279
|
onRepairStale,
|
|
158
280
|
repairing,
|
|
@@ -172,6 +294,8 @@ function CutRow({
|
|
|
172
294
|
detectedLocalClean: boolean;
|
|
173
295
|
onSyncClean: () => void;
|
|
174
296
|
syncing: boolean;
|
|
297
|
+
onAiDraft: () => void;
|
|
298
|
+
aiDrafting: boolean;
|
|
175
299
|
staleMessages: string[];
|
|
176
300
|
onRepairStale: () => void;
|
|
177
301
|
repairing: boolean;
|
|
@@ -190,10 +314,6 @@ function CutRow({
|
|
|
190
314
|
// generated PNG into this cut without hunting through a hidden folder.
|
|
191
315
|
const [showCodexPicker, setShowCodexPicker] = useState(false);
|
|
192
316
|
const [convertingThis, setConvertingThis] = useState(false);
|
|
193
|
-
// Lettering is a first-class step (#442): an intentional Manual vs AI-draft
|
|
194
|
-
// choice per cut, surfaced on the card (not hidden under Edit).
|
|
195
|
-
const [letteringMode, setLetteringMode] = useState<"manual" | "ai">("manual");
|
|
196
|
-
const [letteringCopied, setLetteringCopied] = useState(false);
|
|
197
317
|
const status = getCutStatus(cut);
|
|
198
318
|
// A recorded cleanImagePath/finalImagePath whose file is missing/invalid (#302):
|
|
199
319
|
// surface it precisely rather than letting the field-based status claim the cut
|
|
@@ -213,46 +333,54 @@ function CutRow({
|
|
|
213
333
|
|
|
214
334
|
// Returns true on a successful upload so callers (e.g. the Codex import picker)
|
|
215
335
|
// can close themselves only when the clean image was actually recorded.
|
|
216
|
-
const handleUpload = useCallback(
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
+
}
|
|
232
356
|
}
|
|
233
|
-
}
|
|
234
357
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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");
|
|
245
377
|
return false;
|
|
378
|
+
} finally {
|
|
379
|
+
setUploading(false);
|
|
246
380
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
setUploadError("Upload failed");
|
|
251
|
-
return false;
|
|
252
|
-
} finally {
|
|
253
|
-
setUploading(false);
|
|
254
|
-
}
|
|
255
|
-
}, [authFetch, storyName, plotFile, cut.id, onUpdated]);
|
|
381
|
+
},
|
|
382
|
+
[authFetch, storyName, plotFile, cut.id, onUpdated],
|
|
383
|
+
);
|
|
256
384
|
|
|
257
385
|
// Creator-facing board status + the single primary action for this cut (#440).
|
|
258
386
|
const board = boardStatus(cut, needsConversion, hasStale);
|
|
@@ -264,21 +392,50 @@ function CutRow({
|
|
|
264
392
|
// first-class Manual/AI-draft lettering choice instead of a single button.
|
|
265
393
|
const bubblesPlaced = cut.overlays?.length ?? 0;
|
|
266
394
|
const atLetteringStage =
|
|
267
|
-
!isTextPanel(cut) &&
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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";
|
|
275
411
|
|
|
276
412
|
const primary: { label: string; onClick: () => void; testid: string } | null =
|
|
277
|
-
board.key === "convert"
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
|
438
|
+
const reviewState = letteringReviewState(cut);
|
|
282
439
|
|
|
283
440
|
return (
|
|
284
441
|
<div
|
|
@@ -290,10 +447,19 @@ function CutRow({
|
|
|
290
447
|
action, plus an "Open details" toggle for the technical controls (#440). */}
|
|
291
448
|
<div className="px-3 py-2 space-y-2" data-testid={`cut-card-${cut.id}`}>
|
|
292
449
|
<div className="flex items-center gap-2 text-sm">
|
|
293
|
-
<span
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
<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
|
+
>
|
|
297
463
|
{board.label}
|
|
298
464
|
</span>
|
|
299
465
|
</div>
|
|
@@ -303,13 +469,25 @@ function CutRow({
|
|
|
303
469
|
assetPath={thumbPath}
|
|
304
470
|
authFetch={authFetch}
|
|
305
471
|
alt={`Cut ${cut.id} artwork`}
|
|
306
|
-
className="w-full max-h-
|
|
472
|
+
className="w-full max-h-[32rem] object-contain rounded border border-border bg-white"
|
|
307
473
|
/>
|
|
308
474
|
) : (
|
|
309
|
-
<div
|
|
310
|
-
|
|
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"}
|
|
311
482
|
</div>
|
|
312
483
|
)}
|
|
484
|
+
<div
|
|
485
|
+
className={`rounded border border-border/70 bg-surface/50 px-2 py-1.5 text-[11px] ${BOARD_TONE_TEXT[reviewState.tone]}`}
|
|
486
|
+
data-testid={`lettering-review-state-${cut.id}`}
|
|
487
|
+
>
|
|
488
|
+
<span className="font-semibold">{reviewState.label}</span>
|
|
489
|
+
<span className="text-muted"> · {reviewState.detail}</span>
|
|
490
|
+
</div>
|
|
313
491
|
<button
|
|
314
492
|
onClick={onToggle}
|
|
315
493
|
data-testid={`cut-desc-${cut.id}`}
|
|
@@ -317,61 +495,49 @@ function CutRow({
|
|
|
317
495
|
>
|
|
318
496
|
{cut.description || "No description"}
|
|
319
497
|
</button>
|
|
320
|
-
{/* Lettering is a first-class, visible step (#442): an intentional
|
|
321
|
-
Manual vs AI-draft choice on the card, then the matching CTA. */}
|
|
322
|
-
{atLetteringStage && (
|
|
323
|
-
<div className="space-y-1" data-testid={`lettering-${cut.id}`}>
|
|
324
|
-
<div className="text-[10px] font-medium text-muted uppercase tracking-wider">Lettering</div>
|
|
325
|
-
<label className="flex items-center gap-1.5 text-[11px] text-foreground">
|
|
326
|
-
<input
|
|
327
|
-
type="radio" name={`lettering-mode-${cut.id}`} checked={letteringMode === "manual"}
|
|
328
|
-
onChange={() => setLetteringMode("manual")} data-testid={`lettering-mode-manual-${cut.id}`}
|
|
329
|
-
/>
|
|
330
|
-
Manual — I place bubbles myself
|
|
331
|
-
</label>
|
|
332
|
-
<label className="flex items-center gap-1.5 text-[11px] text-foreground">
|
|
333
|
-
<input
|
|
334
|
-
type="radio" name={`lettering-mode-${cut.id}`} checked={letteringMode === "ai"}
|
|
335
|
-
onChange={() => setLetteringMode("ai")} data-testid={`lettering-mode-ai-${cut.id}`}
|
|
336
|
-
/>
|
|
337
|
-
AI draft — ask the agent to place initial bubbles
|
|
338
|
-
</label>
|
|
339
|
-
{letteringMode === "ai" && (
|
|
340
|
-
<p className="text-[10px] text-muted">
|
|
341
|
-
Paste it to your agent, then review the draft bubbles here and export the final cut.
|
|
342
|
-
</p>
|
|
343
|
-
)}
|
|
344
|
-
</div>
|
|
345
|
-
)}
|
|
346
498
|
<div className="flex items-center gap-2 flex-wrap">
|
|
347
499
|
{atLetteringStage ? (
|
|
348
|
-
|
|
500
|
+
<>
|
|
349
501
|
<button
|
|
350
502
|
onClick={onOpenEditor}
|
|
351
503
|
data-testid={`add-bubbles-${cut.id}`}
|
|
352
504
|
className="px-2.5 py-1 text-[11px] font-medium rounded bg-accent text-white hover:bg-accent-dim"
|
|
353
505
|
>
|
|
354
|
-
{bubblesPlaced > 0 ? "Review lettering" : "
|
|
506
|
+
{bubblesPlaced > 0 ? "Review lettering" : "Open focused editor"}
|
|
355
507
|
</button>
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
+
</>
|
|
365
519
|
) : primary ? (
|
|
366
520
|
<button
|
|
367
521
|
onClick={primary.onClick}
|
|
368
|
-
disabled={
|
|
522
|
+
disabled={
|
|
523
|
+
board.key === "convert" && (convertingThis || converting)
|
|
524
|
+
}
|
|
369
525
|
data-testid={primary.testid}
|
|
370
526
|
className="px-2.5 py-1 text-[11px] font-medium rounded bg-accent text-white hover:bg-accent-dim disabled:opacity-50"
|
|
371
527
|
>
|
|
372
528
|
{primary.label}
|
|
373
529
|
</button>
|
|
374
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
|
+
)}
|
|
375
541
|
<button
|
|
376
542
|
onClick={onToggle}
|
|
377
543
|
data-testid={`cut-details-${cut.id}`}
|
|
@@ -393,9 +559,13 @@ function CutRow({
|
|
|
393
559
|
draft art). The raw unsupported-extension reason stays hidden in the
|
|
394
560
|
Convert artwork banner's technical details. */}
|
|
395
561
|
{needsConversion && (
|
|
396
|
-
<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
|
+
>
|
|
397
566
|
<p className="text-[11px] text-amber-800">
|
|
398
|
-
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.
|
|
399
569
|
</p>
|
|
400
570
|
<button
|
|
401
571
|
onClick={handleConvertThis}
|
|
@@ -408,9 +578,14 @@ function CutRow({
|
|
|
408
578
|
</div>
|
|
409
579
|
)}
|
|
410
580
|
{hasStale && !needsConversion && (
|
|
411
|
-
<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
|
+
>
|
|
412
585
|
{staleMessages.map((m, i) => (
|
|
413
|
-
<p key={i} className="text-[11px] text-error">
|
|
586
|
+
<p key={i} className="text-[11px] text-error">
|
|
587
|
+
{m}
|
|
588
|
+
</p>
|
|
414
589
|
))}
|
|
415
590
|
<button
|
|
416
591
|
onClick={onRepairStale}
|
|
@@ -430,111 +605,137 @@ function CutRow({
|
|
|
430
605
|
Text/interstitial panels need no clean image (#351), so this whole
|
|
431
606
|
generation/upload handoff is image-cut only. */}
|
|
432
607
|
{!isTextPanel(cut) && (
|
|
433
|
-
|
|
434
|
-
<button
|
|
435
|
-
onClick={() => {
|
|
436
|
-
navigator.clipboard.writeText(buildCodexTaskPrompt(cut as unknown as LibCut, plotFile));
|
|
437
|
-
setCopied(true);
|
|
438
|
-
setTimeout(() => setCopied(false), 2000);
|
|
439
|
-
}}
|
|
440
|
-
data-testid={`copy-prompt-${cut.id}`}
|
|
441
|
-
className="px-3 py-1.5 text-xs border border-border rounded hover:border-accent hover:bg-accent/5"
|
|
442
|
-
>
|
|
443
|
-
{copied ? "Copied!" : "Copy Codex task"}
|
|
444
|
-
</button>
|
|
445
|
-
|
|
446
|
-
<input
|
|
447
|
-
ref={fileInputRef}
|
|
448
|
-
type="file"
|
|
449
|
-
accept="image/webp,image/jpeg,image/png"
|
|
450
|
-
className="hidden"
|
|
451
|
-
onChange={(e) => {
|
|
452
|
-
const file = e.target.files?.[0];
|
|
453
|
-
if (file) handleUpload(file);
|
|
454
|
-
e.target.value = "";
|
|
455
|
-
}}
|
|
456
|
-
/>
|
|
457
|
-
<div className="flex items-center gap-2 flex-wrap">
|
|
608
|
+
<div className="mt-2 space-y-2">
|
|
458
609
|
<button
|
|
459
|
-
onClick={() =>
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
<button
|
|
469
|
-
onClick={() => setShowCodexPicker((v) => !v)}
|
|
470
|
-
disabled={uploading}
|
|
471
|
-
data-testid={`import-codex-${cut.id}`}
|
|
472
|
-
className="px-3 py-1.5 text-xs border border-border rounded hover:border-accent hover:bg-accent/5 disabled:opacity-50"
|
|
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"
|
|
473
619
|
>
|
|
474
|
-
{
|
|
620
|
+
{copied ? "Copied!" : "Copy Codex task"}
|
|
475
621
|
</button>
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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 = "";
|
|
484
632
|
}}
|
|
485
|
-
onClose={() => setShowCodexPicker(false)}
|
|
486
633
|
/>
|
|
487
|
-
|
|
488
|
-
{!cut.cleanImagePath && (
|
|
489
|
-
<p className="text-xs text-muted" data-testid={`clean-image-handoff-${cut.id}`}>
|
|
490
|
-
Generate this cut in Codex, then import the cached PNG with “Import from Codex” — or
|
|
491
|
-
upload an image manually. Letter it next.
|
|
492
|
-
</p>
|
|
493
|
-
)}
|
|
494
|
-
{status === "missing" && (
|
|
495
|
-
<div
|
|
496
|
-
className="rounded border border-border bg-surface/60 p-2 space-y-1"
|
|
497
|
-
data-testid={`ask-codex-${cut.id}`}
|
|
498
|
-
>
|
|
499
|
-
<p className="text-[11px] font-medium text-foreground">Generate this cut in Codex</p>
|
|
500
|
-
<p className="text-[10px] text-muted">
|
|
501
|
-
Copy the task below and paste it into Codex. Codex usually saves a PNG to its
|
|
502
|
-
image cache — bring it into this cut with “Import from Codex” above (the PNG
|
|
503
|
-
becomes a WebP automatically). If Codex instead writes a WebP/JPEG at{" "}
|
|
504
|
-
<span className="font-mono">assets/{plotFile}/cut-{String(cut.id).padStart(2, "0")}-clean.webp</span>,
|
|
505
|
-
it’s picked up by “Sync clean images”.
|
|
506
|
-
</p>
|
|
634
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
507
635
|
<button
|
|
508
|
-
onClick={() =>
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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"
|
|
515
654
|
>
|
|
516
|
-
{
|
|
655
|
+
{showCodexPicker ? "Hide Codex images" : "Import from Codex"}
|
|
517
656
|
</button>
|
|
518
657
|
</div>
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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>
|
|
534
732
|
)}
|
|
535
733
|
|
|
536
734
|
{/* Open editor — image cuts, narration cuts, and text panels (#351) */}
|
|
537
|
-
{(cut.cleanImagePath ||
|
|
735
|
+
{(cut.cleanImagePath ||
|
|
736
|
+
cut.narration ||
|
|
737
|
+
cut.dialogue.length > 0 ||
|
|
738
|
+
isTextPanel(cut)) && (
|
|
538
739
|
<button
|
|
539
740
|
onClick={onOpenEditor}
|
|
540
741
|
data-testid={`open-editor-${cut.id}`}
|
|
@@ -546,12 +747,16 @@ function CutRow({
|
|
|
546
747
|
|
|
547
748
|
{/* Cut metadata */}
|
|
548
749
|
{cut.characters.length > 0 && (
|
|
549
|
-
<p className="text-xs text-muted">
|
|
750
|
+
<p className="text-xs text-muted">
|
|
751
|
+
Characters: {cut.characters.join(", ")}
|
|
752
|
+
</p>
|
|
550
753
|
)}
|
|
551
754
|
{cut.dialogue.length > 0 && (
|
|
552
755
|
<div className="text-xs text-muted">
|
|
553
756
|
{cut.dialogue.map((d, i) => (
|
|
554
|
-
<p key={i}
|
|
757
|
+
<p key={i}>
|
|
758
|
+
<span className="font-medium">{d.speaker}:</span> {d.text}
|
|
759
|
+
</p>
|
|
555
760
|
))}
|
|
556
761
|
</div>
|
|
557
762
|
)}
|
|
@@ -564,7 +769,19 @@ function CutRow({
|
|
|
564
769
|
);
|
|
565
770
|
}
|
|
566
771
|
|
|
567
|
-
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) {
|
|
568
785
|
const [cutsFile, setCutsFile] = useState<CutsFile | null>(null);
|
|
569
786
|
// Latest onCutsChanged in a ref so loadCuts can notify the parent without
|
|
570
787
|
// taking the callback as a dependency (which would churn loadCuts/effects).
|
|
@@ -583,7 +800,10 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
|
|
|
583
800
|
// Episode publish markdown + on-chain state, for the guided Finish panel (#414):
|
|
584
801
|
// distinguishes uploaded-but-not-prepared from a prepared/ready-to-publish or
|
|
585
802
|
// already-published episode (which cuts.json alone cannot tell apart).
|
|
586
|
-
const [episodeState, setEpisodeState] = useState<{
|
|
803
|
+
const [episodeState, setEpisodeState] = useState<{
|
|
804
|
+
markdownReady: boolean;
|
|
805
|
+
published: boolean;
|
|
806
|
+
}>({
|
|
587
807
|
markdownReady: false,
|
|
588
808
|
published: false,
|
|
589
809
|
});
|
|
@@ -592,9 +812,13 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
|
|
|
592
812
|
const [converting, setConverting] = useState(false);
|
|
593
813
|
const [convertResult, setConvertResult] = useState<string | null>(null);
|
|
594
814
|
const [syncResult, setSyncResult] = useState<string | null>(null);
|
|
815
|
+
const [aiDraftingCutId, setAiDraftingCutId] = useState<number | null>(null);
|
|
816
|
+
const [aiDraftingAll, setAiDraftingAll] = useState(false);
|
|
595
817
|
const [detected, setDetected] = useState<Set<number>>(new Set());
|
|
596
818
|
// cutId → precise stale-path messages (#302), from detect-clean-images.
|
|
597
|
-
const [staleByCut, setStaleByCut] = useState<Map<number, string[]>>(
|
|
819
|
+
const [staleByCut, setStaleByCut] = useState<Map<number, string[]>>(
|
|
820
|
+
new Map(),
|
|
821
|
+
);
|
|
598
822
|
// True only after /detect-clean-images has SUCCESSFULLY verified the recorded
|
|
599
823
|
// paths against disk (#311). Gates the "clean-assets-ready" banner so it never
|
|
600
824
|
// claims completion from unverified cut-plan fields while detection is pending
|
|
@@ -602,11 +826,15 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
|
|
|
602
826
|
const [detectConfirmed, setDetectConfirmed] = useState(false);
|
|
603
827
|
// Read-only per-cut asset diagnostics validated against disk (#427): the real
|
|
604
828
|
// state (planned/missing/clean-ready/final-ready/uploaded) + precise issues.
|
|
605
|
-
const [assetDiagnostics, setAssetDiagnostics] = useState<
|
|
829
|
+
const [assetDiagnostics, setAssetDiagnostics] = useState<
|
|
830
|
+
CutAssetDiagnostic[] | null
|
|
831
|
+
>(null);
|
|
606
832
|
const [rescanning, setRescanning] = useState(false);
|
|
607
833
|
// #371: cut whose row should be scrolled into view after a Preview→Edit deep
|
|
608
834
|
// link. Applied once its row has rendered (see the scroll effect below).
|
|
609
|
-
const [scrollTargetCutId, setScrollTargetCutId] = useState<number | null>(
|
|
835
|
+
const [scrollTargetCutId, setScrollTargetCutId] = useState<number | null>(
|
|
836
|
+
null,
|
|
837
|
+
);
|
|
610
838
|
// Live DOM refs for cut rows, keyed by cut id, used to scroll a focused cut
|
|
611
839
|
// into view. A ref map avoids re-rendering on registration.
|
|
612
840
|
const rowRefs = useRef<Map<number, HTMLDivElement>>(new Map());
|
|
@@ -632,6 +860,11 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
|
|
|
632
860
|
onFocusHandledRef.current?.();
|
|
633
861
|
}, [focusRequest]);
|
|
634
862
|
|
|
863
|
+
useEffect(() => {
|
|
864
|
+
onFocusedLetteringModeChange?.(editingCutId !== null);
|
|
865
|
+
return () => onFocusedLetteringModeChange?.(false);
|
|
866
|
+
}, [editingCutId, onFocusedLetteringModeChange]);
|
|
867
|
+
|
|
635
868
|
// Scroll a deep-linked, expanded cut into view once its row is on screen. Runs
|
|
636
869
|
// when the target is set and again after the cut plan loads (rows mount). Best
|
|
637
870
|
// effort: `scrollIntoView` is a no-op/undefined under jsdom.
|
|
@@ -663,13 +896,19 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
|
|
|
663
896
|
// distinctly, not just upload progress (#414). Best-effort — a missing file
|
|
664
897
|
// or error simply leaves those steps as not-yet-prepared.
|
|
665
898
|
try {
|
|
666
|
-
const fileRes = await authFetch(
|
|
899
|
+
const fileRes = await authFetch(
|
|
900
|
+
`/api/stories/${storyName}/${fileName}`,
|
|
901
|
+
);
|
|
667
902
|
if (fileRes.ok) {
|
|
668
903
|
const fd = await fileRes.json();
|
|
669
|
-
const content: string =
|
|
904
|
+
const content: string =
|
|
905
|
+
typeof fd?.content === "string" ? fd.content : "";
|
|
670
906
|
const cuts = Array.isArray(parsed?.cuts) ? parsed.cuts : [];
|
|
671
|
-
const markdownReady =
|
|
672
|
-
|
|
907
|
+
const markdownReady =
|
|
908
|
+
content.length > 0 && checkMarkdownReadiness(content, cuts).ready;
|
|
909
|
+
const published =
|
|
910
|
+
fd?.status === "published" ||
|
|
911
|
+
fd?.status === "published-not-indexed";
|
|
673
912
|
setEpisodeState({ markdownReady, published });
|
|
674
913
|
} else {
|
|
675
914
|
setEpisodeState({ markdownReady: false, published: false });
|
|
@@ -687,6 +926,22 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
|
|
|
687
926
|
}
|
|
688
927
|
}, [authFetch, storyName, plotFile, fileName]);
|
|
689
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
|
+
|
|
690
945
|
// Server-confirmed detection of local clean files for cuts whose cleanImagePath
|
|
691
946
|
// is still null. Best-effort: failures leave the detected set unchanged.
|
|
692
947
|
const loadDetect = useCallback(async () => {
|
|
@@ -694,15 +949,20 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
|
|
|
694
949
|
// unverified — don't let the done banner claim completion (#311).
|
|
695
950
|
setDetectConfirmed(false);
|
|
696
951
|
try {
|
|
697
|
-
const res = await authFetch(
|
|
952
|
+
const res = await authFetch(
|
|
953
|
+
`/api/stories/${storyName}/cuts/${plotFile}/detect-clean-images`,
|
|
954
|
+
);
|
|
698
955
|
if (!res.ok) return;
|
|
699
956
|
const data = await res.json();
|
|
700
|
-
setDetected(
|
|
957
|
+
setDetected(
|
|
958
|
+
new Set<number>(Array.isArray(data.detected) ? data.detected : []),
|
|
959
|
+
);
|
|
701
960
|
const staleMap = new Map<number, string[]>();
|
|
702
961
|
const staleList: unknown = data.stale;
|
|
703
962
|
if (Array.isArray(staleList)) {
|
|
704
963
|
for (const s of staleList) {
|
|
705
|
-
if (typeof s?.cutId !== "number" || typeof s?.message !== "string")
|
|
964
|
+
if (typeof s?.cutId !== "number" || typeof s?.message !== "string")
|
|
965
|
+
continue;
|
|
706
966
|
const arr = staleMap.get(s.cutId) ?? [];
|
|
707
967
|
arr.push(s.message);
|
|
708
968
|
staleMap.set(s.cutId, arr);
|
|
@@ -722,11 +982,17 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
|
|
|
722
982
|
const loadDiagnostics = useCallback(async () => {
|
|
723
983
|
setAssetDiagnostics(null);
|
|
724
984
|
try {
|
|
725
|
-
const res = await authFetch(
|
|
985
|
+
const res = await authFetch(
|
|
986
|
+
`/api/stories/${storyName}/cuts/${plotFile}/asset-diagnostics`,
|
|
987
|
+
);
|
|
726
988
|
if (!res.ok) return; // stays cleared
|
|
727
989
|
const data = await res.json();
|
|
728
|
-
setAssetDiagnostics(
|
|
729
|
-
|
|
990
|
+
setAssetDiagnostics(
|
|
991
|
+
Array.isArray(data.diagnostics) ? data.diagnostics : null,
|
|
992
|
+
);
|
|
993
|
+
} catch {
|
|
994
|
+
/* stays cleared — diagnostics are optional */
|
|
995
|
+
}
|
|
730
996
|
}, [authFetch, storyName, plotFile]);
|
|
731
997
|
|
|
732
998
|
// "Refresh assets / Check generated images" (#427): a read-only rescan that
|
|
@@ -742,26 +1008,125 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
|
|
|
742
1008
|
}
|
|
743
1009
|
}, [loadCuts, loadDetect, loadDiagnostics]);
|
|
744
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
|
+
|
|
745
1095
|
const syncCleanImages = useCallback(async () => {
|
|
746
1096
|
setSyncing(true);
|
|
747
1097
|
setSyncResult(null);
|
|
748
1098
|
setGenWarnings([]);
|
|
749
1099
|
try {
|
|
750
|
-
const res = await authFetch(
|
|
1100
|
+
const res = await authFetch(
|
|
1101
|
+
`/api/stories/${storyName}/cuts/${plotFile}/sync-clean-images`,
|
|
1102
|
+
{ method: "POST" },
|
|
1103
|
+
);
|
|
751
1104
|
const data = await res.json().catch(() => ({}));
|
|
752
1105
|
if (!res.ok) {
|
|
753
1106
|
setSyncResult(data.error || "Sync failed");
|
|
754
1107
|
} else {
|
|
755
1108
|
const syncedCount = Array.isArray(data.synced) ? data.synced.length : 0;
|
|
756
|
-
const clearedCount = Array.isArray(data.cleared)
|
|
1109
|
+
const clearedCount = Array.isArray(data.cleared)
|
|
1110
|
+
? data.cleared.length
|
|
1111
|
+
: 0;
|
|
757
1112
|
const rejected = Array.isArray(data.rejected) ? data.rejected : [];
|
|
758
1113
|
if (rejected.length > 0) {
|
|
759
|
-
setGenWarnings(
|
|
1114
|
+
setGenWarnings(
|
|
1115
|
+
rejected.map(
|
|
1116
|
+
(r: { cutId: number; reason: string }) =>
|
|
1117
|
+
`Cut ${r.cutId}: ${r.reason}`,
|
|
1118
|
+
),
|
|
1119
|
+
);
|
|
760
1120
|
}
|
|
761
1121
|
const parts: string[] = [];
|
|
762
1122
|
if (syncedCount > 0) parts.push(`Synced ${syncedCount}`);
|
|
763
|
-
if (clearedCount > 0)
|
|
764
|
-
|
|
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
|
+
);
|
|
765
1130
|
await loadCuts();
|
|
766
1131
|
await loadDetect();
|
|
767
1132
|
await loadDiagnostics();
|
|
@@ -777,42 +1142,57 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
|
|
|
777
1142
|
// upload), and persist via the existing upload-clean route, which records the
|
|
778
1143
|
// new cleanImagePath. Returns true on success. Publish stays strict — the route
|
|
779
1144
|
// only accepts WebP/JPEG ≤1MB, so conversion is the safe bridge from a draft PNG.
|
|
780
|
-
const convertCut = useCallback(
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
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
|
+
);
|
|
795
1171
|
|
|
796
1172
|
// "Convert all artwork" (#441): batch-convert every cut flagged needs-conversion.
|
|
797
|
-
const convertAll = useCallback(
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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
|
+
);
|
|
816
1196
|
|
|
817
1197
|
// Guided "Finish episode" orchestration (#414): upload every exported final
|
|
818
1198
|
// image (paced under the rate limit, #413/#288), then prepare the publish
|
|
@@ -824,7 +1204,9 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
|
|
|
824
1204
|
setUploading(true);
|
|
825
1205
|
setUploadProgress("");
|
|
826
1206
|
setGenWarnings([]);
|
|
827
|
-
const toUpload = cutsFile.cuts.filter(
|
|
1207
|
+
const toUpload = cutsFile.cuts.filter(
|
|
1208
|
+
(ct) => ct.finalImagePath && !ct.uploadedCid,
|
|
1209
|
+
);
|
|
828
1210
|
const errors: string[] = [];
|
|
829
1211
|
// Proactively pace uploads under PlotLink's 5/min limit so a 7–10 cut episode
|
|
830
1212
|
// completes without manual waiting, instead of firing all at once and thrashing
|
|
@@ -839,14 +1221,27 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
|
|
|
839
1221
|
});
|
|
840
1222
|
for (let i = 0; i < toUpload.length; i++) {
|
|
841
1223
|
const ct = toUpload[i];
|
|
842
|
-
setUploadProgress(
|
|
1224
|
+
setUploadProgress(
|
|
1225
|
+
`Uploading cut ${ct.id} (${i + 1}/${toUpload.length})...`,
|
|
1226
|
+
);
|
|
843
1227
|
try {
|
|
844
|
-
const assetRel = ct.finalImagePath!.startsWith("assets/")
|
|
845
|
-
|
|
846
|
-
|
|
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
|
+
}
|
|
847
1238
|
const blob = await imgRes.blob();
|
|
848
1239
|
const fd = new FormData();
|
|
849
|
-
fd.append(
|
|
1240
|
+
fd.append(
|
|
1241
|
+
"file",
|
|
1242
|
+
blob,
|
|
1243
|
+
`cut-${ct.id}.${blob.type === "image/webp" ? "webp" : "jpg"}`,
|
|
1244
|
+
);
|
|
850
1245
|
// Proactively wait if we've already used the 5/min budget (#413), then retry
|
|
851
1246
|
// with backoff while the PlotLink endpoint rate-limits us anyway (5
|
|
852
1247
|
// uploads/min), instead of failing the whole batch (#288). Already-uploaded
|
|
@@ -855,13 +1250,20 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
|
|
|
855
1250
|
await throttle();
|
|
856
1251
|
const upload = await withRateLimitRetry(
|
|
857
1252
|
async () => {
|
|
858
|
-
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
|
+
});
|
|
859
1257
|
if (res.ok) {
|
|
860
1258
|
const { cid, url } = await res.json();
|
|
861
1259
|
return { ok: true as const, status: res.status, cid, url };
|
|
862
1260
|
}
|
|
863
1261
|
const e = await res.json().catch(() => ({}));
|
|
864
|
-
return {
|
|
1262
|
+
return {
|
|
1263
|
+
ok: false as const,
|
|
1264
|
+
status: res.status,
|
|
1265
|
+
errorMessage: (e as { error?: string }).error,
|
|
1266
|
+
};
|
|
865
1267
|
},
|
|
866
1268
|
{
|
|
867
1269
|
...uploadRetry,
|
|
@@ -871,16 +1273,28 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
|
|
|
871
1273
|
),
|
|
872
1274
|
},
|
|
873
1275
|
);
|
|
874
|
-
if (!upload.ok) {
|
|
1276
|
+
if (!upload.ok) {
|
|
1277
|
+
errors.push(
|
|
1278
|
+
`Cut ${ct.id}: upload failed — ${upload.errorMessage || "unknown"}`,
|
|
1279
|
+
);
|
|
1280
|
+
continue;
|
|
1281
|
+
}
|
|
875
1282
|
const { cid, url } = upload;
|
|
876
|
-
const setRes = await authFetch(
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
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
|
+
}
|
|
882
1294
|
} catch (err) {
|
|
883
|
-
errors.push(
|
|
1295
|
+
errors.push(
|
|
1296
|
+
`Cut ${ct.id}: ${err instanceof Error ? err.message : "failed"}`,
|
|
1297
|
+
);
|
|
884
1298
|
}
|
|
885
1299
|
}
|
|
886
1300
|
if (errors.length > 0) {
|
|
@@ -891,7 +1305,10 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
|
|
|
891
1305
|
return;
|
|
892
1306
|
}
|
|
893
1307
|
setUploadProgress("Preparing episode for publishing…");
|
|
894
|
-
const mdRes = await authFetch(
|
|
1308
|
+
const mdRes = await authFetch(
|
|
1309
|
+
`/api/stories/${storyName}/cuts/${plotFile}/generate-markdown`,
|
|
1310
|
+
{ method: "POST" },
|
|
1311
|
+
);
|
|
895
1312
|
if (mdRes.ok) {
|
|
896
1313
|
const data = await mdRes.json();
|
|
897
1314
|
if (data.warnings?.length > 0) setGenWarnings(data.warnings);
|
|
@@ -908,13 +1325,22 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
|
|
|
908
1325
|
setRepairing(true);
|
|
909
1326
|
setSyncResult(null);
|
|
910
1327
|
try {
|
|
911
|
-
const res = await authFetch(
|
|
1328
|
+
const res = await authFetch(
|
|
1329
|
+
`/api/stories/${storyName}/cuts/${plotFile}/repair-asset-paths`,
|
|
1330
|
+
{ method: "POST" },
|
|
1331
|
+
);
|
|
912
1332
|
const data = await res.json().catch(() => ({}));
|
|
913
1333
|
if (!res.ok) {
|
|
914
1334
|
setSyncResult(data.error || "Repair failed");
|
|
915
1335
|
} else {
|
|
916
|
-
const clearedCount = Array.isArray(data.cleared)
|
|
917
|
-
|
|
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
|
+
);
|
|
918
1344
|
await loadCuts();
|
|
919
1345
|
await loadDetect();
|
|
920
1346
|
}
|
|
@@ -924,39 +1350,67 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
|
|
|
924
1350
|
setRepairing(false);
|
|
925
1351
|
}, [authFetch, storyName, plotFile, loadCuts, loadDetect]);
|
|
926
1352
|
|
|
927
|
-
//
|
|
928
|
-
// add a narration/title card between image cuts without hand-editing cuts.json.
|
|
1353
|
+
// Insert a text/interstitial panel to the cut plan (#352/#488) — a one-click way
|
|
1354
|
+
// to add a narration/title card between image cuts without hand-editing cuts.json.
|
|
929
1355
|
const [addingPanel, setAddingPanel] = useState(false);
|
|
930
|
-
const
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
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");
|
|
954
1405
|
}
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
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
|
+
);
|
|
960
1414
|
|
|
961
1415
|
useEffect(() => {
|
|
962
1416
|
loadCuts();
|
|
@@ -974,9 +1428,16 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
|
|
|
974
1428
|
<p className="text-sm text-error font-medium">Invalid cuts file</p>
|
|
975
1429
|
<p className="text-xs text-error">{error}</p>
|
|
976
1430
|
<p className="text-xs text-muted">
|
|
977
|
-
{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.
|
|
978
1434
|
</p>
|
|
979
|
-
<button
|
|
1435
|
+
<button
|
|
1436
|
+
onClick={loadCuts}
|
|
1437
|
+
className="text-xs text-accent hover:text-accent-dim"
|
|
1438
|
+
>
|
|
1439
|
+
Retry
|
|
1440
|
+
</button>
|
|
980
1441
|
</div>
|
|
981
1442
|
);
|
|
982
1443
|
}
|
|
@@ -985,12 +1446,17 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
|
|
|
985
1446
|
return (
|
|
986
1447
|
<div className="p-4 text-center space-y-1">
|
|
987
1448
|
<p className="text-sm text-muted">No cuts yet</p>
|
|
988
|
-
<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>
|
|
989
1452
|
</div>
|
|
990
1453
|
);
|
|
991
1454
|
}
|
|
992
1455
|
|
|
993
|
-
const editingCut =
|
|
1456
|
+
const editingCut =
|
|
1457
|
+
editingCutId !== null
|
|
1458
|
+
? cutsFile.cuts.find((c) => c.id === editingCutId)
|
|
1459
|
+
: null;
|
|
994
1460
|
|
|
995
1461
|
if (editingCut) {
|
|
996
1462
|
return (
|
|
@@ -1000,20 +1466,37 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
|
|
|
1000
1466
|
plotFile={plotFile}
|
|
1001
1467
|
language={language}
|
|
1002
1468
|
authFetch={authFetch}
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1469
|
+
targetLabel={
|
|
1470
|
+
isTextPanel(editingCut)
|
|
1471
|
+
? `Between-scene card ${editingCut.id}`
|
|
1472
|
+
: `Cut ${String(editingCut.id).padStart(2, "0")}`
|
|
1473
|
+
}
|
|
1474
|
+
returnOnSave
|
|
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");
|
|
1013
1493
|
}
|
|
1014
1494
|
}}
|
|
1015
1495
|
onExported={() => loadCuts()}
|
|
1016
|
-
onClose={() => {
|
|
1496
|
+
onClose={() => {
|
|
1497
|
+
setEditingCutId(null);
|
|
1498
|
+
loadCuts();
|
|
1499
|
+
}}
|
|
1017
1500
|
/>
|
|
1018
1501
|
);
|
|
1019
1502
|
}
|
|
@@ -1024,21 +1507,30 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
|
|
|
1024
1507
|
acc[s]++;
|
|
1025
1508
|
return acc;
|
|
1026
1509
|
},
|
|
1027
|
-
{ 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
|
+
>,
|
|
1028
1514
|
);
|
|
1029
1515
|
// Text/interstitial panels need no clean image (#351), so the clean-assets
|
|
1030
1516
|
// banner/claims reason about IMAGE cuts only — never the total cut count.
|
|
1031
1517
|
const imageCutCount = cutsFile.cuts.filter((c) => !isTextPanel(c)).length;
|
|
1032
1518
|
// #381: final images lettered by an older bubble renderer (separate-tail seam)
|
|
1033
1519
|
// must be re-exported before publish. Only tailed speech bubbles are affected.
|
|
1034
|
-
const staleTailIds = cutsFile.cuts
|
|
1520
|
+
const staleTailIds = cutsFile.cuts
|
|
1521
|
+
.filter((c) => isStaleTailedExport(c))
|
|
1522
|
+
.map((c) => c.id);
|
|
1035
1523
|
|
|
1036
1524
|
// Guided "Finish episode" state (#414). The checklist's publish step reflects the
|
|
1037
1525
|
// real on-chain status; markdownReady distinguishes uploaded-but-not-prepared from
|
|
1038
1526
|
// a prepared/ready-to-publish episode. canFinish = something the Finish action can
|
|
1039
1527
|
// still do: a final to upload, or uploads done but the sequence not yet prepared.
|
|
1040
|
-
const finishChecklist = cartoonChecklist({
|
|
1041
|
-
|
|
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";
|
|
1042
1534
|
const canFinish =
|
|
1043
1535
|
cutsFile.cuts.some((ct) => ct.finalImagePath && !ct.uploadedCid) ||
|
|
1044
1536
|
(uploadStepDone && !episodeState.markdownReady);
|
|
@@ -1048,7 +1540,9 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
|
|
|
1048
1540
|
const conversionJobs = (assetDiagnostics ?? [])
|
|
1049
1541
|
.filter((d) => d.state === "needs-conversion" && d.convertiblePng)
|
|
1050
1542
|
.map((d) => ({ cutId: d.cutId, pngPath: d.convertiblePng as string }));
|
|
1051
|
-
const conversionByCut = new Map(
|
|
1543
|
+
const conversionByCut = new Map(
|
|
1544
|
+
conversionJobs.map((j) => [j.cutId, j.pngPath]),
|
|
1545
|
+
);
|
|
1052
1546
|
const conversionIssues = (assetDiagnostics ?? [])
|
|
1053
1547
|
.filter((d) => d.state === "needs-conversion" && d.issue)
|
|
1054
1548
|
.map((d) => d.issue as string);
|
|
@@ -1057,119 +1551,211 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
|
|
|
1057
1551
|
// milestones, not internal fields: artwork found (any clean image incl. a draft
|
|
1058
1552
|
// PNG), converted (publishable WebP/JPEG), lettered (bubbles placed/exported),
|
|
1059
1553
|
// uploaded. PNG-only cuts read as "artwork found" but not yet "converted".
|
|
1060
|
-
const episodeLabel =
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
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;
|
|
1064
1562
|
const imageCuts = cutsFile.cuts.filter((c) => !isTextPanel(c));
|
|
1065
1563
|
const boardSummary = {
|
|
1066
1564
|
cuts: cutsFile.cuts.length,
|
|
1067
|
-
artwork: imageCuts.filter(
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
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,
|
|
1071
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;
|
|
1072
1585
|
|
|
1073
1586
|
return (
|
|
1074
|
-
<div
|
|
1587
|
+
<div
|
|
1588
|
+
className="h-full min-h-[22rem] flex flex-col overflow-hidden"
|
|
1589
|
+
data-testid="cut-list-panel"
|
|
1590
|
+
>
|
|
1075
1591
|
{/* Episode header + creator-facing progress summary (#440). */}
|
|
1076
|
-
<div
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
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
|
+
)}
|
|
1083
1627
|
</div>
|
|
1084
1628
|
</div>
|
|
1085
1629
|
{/* Lower-level / manual controls, collapsed by default so the board stays
|
|
1086
1630
|
focused on per-cut actions (#440). The guided Finish flow + per-cut
|
|
1087
1631
|
primary actions are the main path; these stay for power users. */}
|
|
1088
|
-
<details
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
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 */
|
|
1106
1675
|
}
|
|
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
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
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>
|
|
1153
1724
|
</details>
|
|
1154
1725
|
{/* Plain-language workflow + text-panel explainer (#360) so a non-technical
|
|
1155
1726
|
writer understands the order of operations and what a text panel is. */}
|
|
1156
|
-
<
|
|
1157
|
-
className="px-3 py-
|
|
1727
|
+
<details
|
|
1728
|
+
className="px-3 py-1.5 border-b border-border bg-surface/40 flex-shrink-0"
|
|
1158
1729
|
data-testid="cartoon-workflow-help"
|
|
1159
1730
|
>
|
|
1160
|
-
<
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
<
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
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>
|
|
1171
1757
|
</div>
|
|
1172
|
-
</
|
|
1758
|
+
</details>
|
|
1173
1759
|
{/* Stale bubble-renderer warning (#381): a final image lettered before the
|
|
1174
1760
|
current seamless-tail renderer may show the old separate-tail seam.
|
|
1175
1761
|
Mark those cuts so the writer re-exports (open lettering → Export) and
|
|
@@ -1179,23 +1765,38 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
|
|
|
1179
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"
|
|
1180
1766
|
data-testid="stale-bubble-export-warning"
|
|
1181
1767
|
>
|
|
1182
|
-
{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.
|
|
1183
1773
|
</div>
|
|
1184
1774
|
)}
|
|
1185
1775
|
{/* Clean-asset generation done-state (#311): when every cut has a present,
|
|
1186
1776
|
valid clean image, surface a clear "done" signal so the operator knows
|
|
1187
1777
|
Codex generation is complete even if the terminal session is still
|
|
1188
1778
|
connected — no more guessing whether it is still Working. */}
|
|
1189
|
-
{detectConfirmed &&
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
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
|
+
)}
|
|
1197
1795
|
{syncResult && (
|
|
1198
|
-
<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
|
+
>
|
|
1199
1800
|
{syncResult}
|
|
1200
1801
|
</div>
|
|
1201
1802
|
)}
|
|
@@ -1204,10 +1805,17 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
|
|
|
1204
1805
|
than red "Unsupported extension" errors. The raw reasons stay available
|
|
1205
1806
|
under a collapsed "Technical details" disclosure. */}
|
|
1206
1807
|
{conversionJobs.length > 0 && (
|
|
1207
|
-
<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
|
+
>
|
|
1208
1812
|
<div className="flex items-center gap-2 flex-wrap">
|
|
1209
|
-
<span
|
|
1210
|
-
|
|
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
|
|
1211
1819
|
</span>
|
|
1212
1820
|
<button
|
|
1213
1821
|
onClick={() => convertAll(conversionJobs)}
|
|
@@ -1219,14 +1827,26 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
|
|
|
1219
1827
|
</button>
|
|
1220
1828
|
</div>
|
|
1221
1829
|
<p className="mt-1 text-[10px] text-muted">
|
|
1222
|
-
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.
|
|
1223
1832
|
</p>
|
|
1224
|
-
{convertResult &&
|
|
1833
|
+
{convertResult && (
|
|
1834
|
+
<p
|
|
1835
|
+
className="mt-1 text-[10px] text-muted"
|
|
1836
|
+
data-testid="convert-result"
|
|
1837
|
+
>
|
|
1838
|
+
{convertResult}
|
|
1839
|
+
</p>
|
|
1840
|
+
)}
|
|
1225
1841
|
{conversionIssues.length > 0 && (
|
|
1226
1842
|
<details className="mt-1" data-testid="convert-technical-details">
|
|
1227
|
-
<summary className="text-[10px] text-muted cursor-pointer">
|
|
1843
|
+
<summary className="text-[10px] text-muted cursor-pointer">
|
|
1844
|
+
Technical details
|
|
1845
|
+
</summary>
|
|
1228
1846
|
<ul className="mt-1 ml-3 list-disc text-[10px] text-muted">
|
|
1229
|
-
{conversionIssues.map((m, i) =>
|
|
1847
|
+
{conversionIssues.map((m, i) => (
|
|
1848
|
+
<li key={i}>{m}</li>
|
|
1849
|
+
))}
|
|
1230
1850
|
</ul>
|
|
1231
1851
|
</details>
|
|
1232
1852
|
)}
|
|
@@ -1236,22 +1856,37 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
|
|
|
1236
1856
|
state tally + a precise per-cut reason when a recorded path is broken,
|
|
1237
1857
|
so "files exist but aren't shown" / a typoed path is a clear diagnostic
|
|
1238
1858
|
rather than a generic publish warning. */}
|
|
1239
|
-
{assetDiagnostics &&
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
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
|
+
})()}
|
|
1255
1890
|
{/* Guided Finish-episode flow (#414): writer-language step status, one primary
|
|
1256
1891
|
"Finish episode" action that uploads finals then prepares the publish
|
|
1257
1892
|
markdown in order, and any blockers grouped by the step that fixes them —
|
|
@@ -1268,32 +1903,107 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
|
|
|
1268
1903
|
published={episodeState.published}
|
|
1269
1904
|
/>
|
|
1270
1905
|
|
|
1271
|
-
{/*
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1906
|
+
{/* Full cut review (#488): all clean cuts are shown vertically first, with
|
|
1907
|
+
explicit between-scene slots for narration/title cards. */}
|
|
1908
|
+
<div
|
|
1909
|
+
className="flex-1 min-h-56 overflow-y-auto p-3 space-y-3"
|
|
1910
|
+
data-testid="lettering-review-board"
|
|
1911
|
+
>
|
|
1912
|
+
{cutsFile.cuts.map((cut, index) => (
|
|
1913
|
+
<Fragment key={cut.id}>
|
|
1914
|
+
<BetweenSceneSlot
|
|
1915
|
+
index={index}
|
|
1916
|
+
beforeLabel={
|
|
1917
|
+
index === 0
|
|
1918
|
+
? "Episode opening"
|
|
1919
|
+
: `After cut ${cutsFile.cuts[index - 1]?.id}`
|
|
1920
|
+
}
|
|
1921
|
+
afterLabel={`Before cut ${cut.id}`}
|
|
1922
|
+
disabled={addingPanel}
|
|
1923
|
+
onAdd={() => addTextPanelAt(index)}
|
|
1924
|
+
/>
|
|
1925
|
+
<CutRow
|
|
1926
|
+
cut={cut}
|
|
1927
|
+
storyName={storyName}
|
|
1928
|
+
plotFile={plotFile}
|
|
1929
|
+
expanded={expandedCut === cut.id}
|
|
1930
|
+
onToggle={() =>
|
|
1931
|
+
setExpandedCut(expandedCut === cut.id ? null : cut.id)
|
|
1932
|
+
}
|
|
1933
|
+
authFetch={authFetch}
|
|
1934
|
+
onUpdated={() => {
|
|
1935
|
+
loadCuts();
|
|
1936
|
+
loadDetect();
|
|
1937
|
+
loadDiagnostics();
|
|
1938
|
+
}}
|
|
1939
|
+
onOpenEditor={() => setEditingCutId(cut.id)}
|
|
1940
|
+
detectedLocalClean={detected.has(cut.id)}
|
|
1941
|
+
onSyncClean={syncCleanImages}
|
|
1942
|
+
syncing={syncing}
|
|
1943
|
+
onAiDraft={() => {
|
|
1944
|
+
void draftCutWithAi(cut.id, { openEditor: true });
|
|
1945
|
+
}}
|
|
1946
|
+
aiDrafting={aiDraftingCutId === cut.id}
|
|
1947
|
+
staleMessages={staleByCut.get(cut.id) ?? []}
|
|
1948
|
+
onRepairStale={repairStalePaths}
|
|
1949
|
+
repairing={repairing}
|
|
1950
|
+
conversionPng={conversionByCut.get(cut.id) ?? null}
|
|
1951
|
+
onConvert={convertCut}
|
|
1952
|
+
converting={converting}
|
|
1953
|
+
rowRef={(el) => {
|
|
1954
|
+
if (el) rowRefs.current.set(cut.id, el);
|
|
1955
|
+
else rowRefs.current.delete(cut.id);
|
|
1956
|
+
}}
|
|
1957
|
+
/>
|
|
1958
|
+
</Fragment>
|
|
1295
1959
|
))}
|
|
1960
|
+
<BetweenSceneSlot
|
|
1961
|
+
index={cutsFile.cuts.length}
|
|
1962
|
+
beforeLabel={`After cut ${cutsFile.cuts[cutsFile.cuts.length - 1]?.id}`}
|
|
1963
|
+
afterLabel="Episode ending"
|
|
1964
|
+
disabled={addingPanel}
|
|
1965
|
+
onAdd={() => addTextPanelAt(cutsFile.cuts.length)}
|
|
1966
|
+
/>
|
|
1296
1967
|
</div>
|
|
1297
1968
|
</div>
|
|
1298
1969
|
);
|
|
1299
1970
|
}
|
|
1971
|
+
|
|
1972
|
+
function BetweenSceneSlot({
|
|
1973
|
+
index,
|
|
1974
|
+
beforeLabel,
|
|
1975
|
+
afterLabel,
|
|
1976
|
+
disabled,
|
|
1977
|
+
onAdd,
|
|
1978
|
+
}: {
|
|
1979
|
+
index: number;
|
|
1980
|
+
beforeLabel: string;
|
|
1981
|
+
afterLabel: string;
|
|
1982
|
+
disabled: boolean;
|
|
1983
|
+
onAdd: () => void;
|
|
1984
|
+
}) {
|
|
1985
|
+
return (
|
|
1986
|
+
<div
|
|
1987
|
+
className="rounded border border-dashed border-border bg-surface/35 px-3 py-2 text-[11px] text-muted flex items-center gap-3"
|
|
1988
|
+
data-testid={`between-scene-slot-${index}`}
|
|
1989
|
+
>
|
|
1990
|
+
<span className="min-w-0 flex-1">
|
|
1991
|
+
<span className="font-medium text-foreground">
|
|
1992
|
+
Between-scene lettering
|
|
1993
|
+
</span>
|
|
1994
|
+
<span className="block truncate">
|
|
1995
|
+
{beforeLabel} · {afterLabel}
|
|
1996
|
+
</span>
|
|
1997
|
+
</span>
|
|
1998
|
+
<button
|
|
1999
|
+
type="button"
|
|
2000
|
+
onClick={onAdd}
|
|
2001
|
+
disabled={disabled}
|
|
2002
|
+
className="flex-shrink-0 rounded border border-accent/40 px-2.5 py-1 text-[11px] font-medium text-accent hover:bg-accent/5 disabled:opacity-50"
|
|
2003
|
+
data-testid={`add-between-scene-${index}`}
|
|
2004
|
+
>
|
|
2005
|
+
Add card
|
|
2006
|
+
</button>
|
|
2007
|
+
</div>
|
|
2008
|
+
);
|
|
2009
|
+
}
|