plotlink-ows 1.2.96 → 1.2.98
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/lib/cartoon-production-status.ts +77 -0
- package/app/web/components/CartoonNextAction.tsx +154 -36
- package/app/web/components/CartoonProductionStatus.tsx +142 -0
- package/app/web/components/CartoonPublishPage.tsx +34 -25
- package/app/web/components/CartoonWorkflowNav.tsx +3 -7
- package/app/web/components/CutListPanel.tsx +372 -243
- package/app/web/components/CutOverlayPreview.tsx +314 -0
- package/app/web/components/EpisodesPage.tsx +34 -4
- package/app/web/components/FinishEpisodePanel.tsx +21 -108
- package/app/web/components/LetteringEditor.tsx +246 -131
- package/app/web/components/PreviewPanel.tsx +200 -214
- package/app/web/components/StoriesPage.tsx +105 -91
- package/app/web/components/StoryBrowser.tsx +53 -19
- package/app/web/components/StoryInfoPage.tsx +31 -14
- package/app/web/components/StoryProgressPanel.tsx +31 -28
- package/app/web/dist/assets/{export-cut-BqZI0-Rv.js → export-cut-DVpOZ5AO.js} +1 -1
- package/app/web/dist/assets/index-CoG6WKyb.js +141 -0
- package/app/web/dist/assets/index-H5_FM885.css +32 -0
- package/app/web/dist/index.html +2 -2
- package/package.json +1 -1
- package/app/web/dist/assets/index-C43toXVm.js +0 -141
- package/app/web/dist/assets/index-CcfChGEK.css +0 -32
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Fragment, useState, useEffect, useCallback, useRef } from "react";
|
|
2
2
|
import { LetteringEditor } from "./LetteringEditor";
|
|
3
|
-
import {
|
|
3
|
+
import { assetUrl } from "./asset-image";
|
|
4
|
+
import { CutOverlayPreview } from "./CutOverlayPreview";
|
|
4
5
|
import { buildCodexTaskPrompt } from "@app-lib/cartoon-prompt";
|
|
5
6
|
import type { Cut as LibCut } from "@app-lib/cuts";
|
|
6
7
|
import { isTextPanel, isStaleTailedExport } from "@app-lib/cuts";
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
} from "../lib/import-image";
|
|
16
17
|
import { CodexImportPicker } from "./CodexImportPicker";
|
|
17
18
|
import { FinishEpisodePanel } from "./FinishEpisodePanel";
|
|
19
|
+
import { buildCartoonProductionStatus } from "@app-lib/cartoon-production-status";
|
|
18
20
|
import {
|
|
19
21
|
cartoonChecklist,
|
|
20
22
|
checkMarkdownReadiness,
|
|
@@ -109,6 +111,12 @@ interface CutListPanelProps {
|
|
|
109
111
|
workspaceVisible?: boolean;
|
|
110
112
|
/** Restore/fold the wider app work area while staying in the editor. */
|
|
111
113
|
onWorkspaceVisibleChange?: (visible: boolean) => void;
|
|
114
|
+
/** Episode-centric cartoon screen mode: preview board or direct lettering edit. */
|
|
115
|
+
mode?: "preview" | "edit";
|
|
116
|
+
/** Called when the focused editor closes back to the episode cut board. */
|
|
117
|
+
onExitFocusedEditor?: () => void;
|
|
118
|
+
/** Let the parent episode header own title/status chrome; render tools at the scroll end. */
|
|
119
|
+
compactEpisodeChrome?: boolean;
|
|
112
120
|
}
|
|
113
121
|
|
|
114
122
|
type CutStatus = "missing" | "clean" | "lettered" | "uploaded" | "text";
|
|
@@ -265,6 +273,7 @@ function CutRow({
|
|
|
265
273
|
cut,
|
|
266
274
|
storyName,
|
|
267
275
|
plotFile,
|
|
276
|
+
language,
|
|
268
277
|
expanded,
|
|
269
278
|
onToggle,
|
|
270
279
|
authFetch,
|
|
@@ -282,10 +291,12 @@ function CutRow({
|
|
|
282
291
|
onConvert,
|
|
283
292
|
converting,
|
|
284
293
|
rowRef,
|
|
294
|
+
featured = false,
|
|
285
295
|
}: {
|
|
286
296
|
cut: Cut;
|
|
287
297
|
storyName: string;
|
|
288
298
|
plotFile: string;
|
|
299
|
+
language?: string;
|
|
289
300
|
expanded: boolean;
|
|
290
301
|
onToggle: () => void;
|
|
291
302
|
authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
|
|
@@ -304,6 +315,7 @@ function CutRow({
|
|
|
304
315
|
onConvert: (cutId: number, pngPath: string) => Promise<boolean>;
|
|
305
316
|
converting: boolean;
|
|
306
317
|
rowRef?: (el: HTMLDivElement | null) => void;
|
|
318
|
+
featured?: boolean;
|
|
307
319
|
}) {
|
|
308
320
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
309
321
|
const [uploading, setUploading] = useState(false);
|
|
@@ -407,7 +419,9 @@ function CutRow({
|
|
|
407
419
|
!cut.uploadedUrl &&
|
|
408
420
|
canDraftLettering(cut);
|
|
409
421
|
const aiDraftLabel =
|
|
410
|
-
(cut.overlays?.length ?? 0) > 0
|
|
422
|
+
(cut.overlays?.length ?? 0) > 0
|
|
423
|
+
? "Re-draft bubbles with AI"
|
|
424
|
+
: "AI draft bubbles";
|
|
411
425
|
|
|
412
426
|
const primary: { label: string; onClick: () => void; testid: string } | null =
|
|
413
427
|
board.key === "convert"
|
|
@@ -436,6 +450,65 @@ function CutRow({
|
|
|
436
450
|
}
|
|
437
451
|
: null; // exported / uploaded — the next action is the episode-level upload/publish
|
|
438
452
|
const reviewState = letteringReviewState(cut);
|
|
453
|
+
const canOpenPreviewEditor =
|
|
454
|
+
!!cut.cleanImagePath ||
|
|
455
|
+
!!cut.narration ||
|
|
456
|
+
cut.dialogue.length > 0 ||
|
|
457
|
+
isTextPanel(cut);
|
|
458
|
+
const previewClassName = featured
|
|
459
|
+
? "w-full rounded border border-border bg-white min-h-[14rem] md:min-h-[18rem] xl:min-h-[21rem]"
|
|
460
|
+
: "w-full rounded border border-border bg-white min-h-[11rem] md:min-h-[13rem]";
|
|
461
|
+
const actionControls = (
|
|
462
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
463
|
+
{atLetteringStage ? (
|
|
464
|
+
<>
|
|
465
|
+
<button
|
|
466
|
+
onClick={onOpenEditor}
|
|
467
|
+
data-testid={`add-bubbles-${cut.id}`}
|
|
468
|
+
className="px-2.5 py-1 text-[11px] font-medium rounded bg-accent text-white hover:bg-accent-dim"
|
|
469
|
+
>
|
|
470
|
+
{bubblesPlaced > 0 ? "Review lettering" : "Open focused editor"}
|
|
471
|
+
</button>
|
|
472
|
+
{canAiDraft && (
|
|
473
|
+
<button
|
|
474
|
+
onClick={onAiDraft}
|
|
475
|
+
disabled={aiDrafting}
|
|
476
|
+
data-testid={`ai-draft-${cut.id}`}
|
|
477
|
+
className="px-2.5 py-1 text-[11px] rounded border border-accent/40 text-accent hover:bg-accent/5 disabled:opacity-50"
|
|
478
|
+
>
|
|
479
|
+
{aiDrafting ? "Drafting…" : aiDraftLabel}
|
|
480
|
+
</button>
|
|
481
|
+
)}
|
|
482
|
+
</>
|
|
483
|
+
) : primary ? (
|
|
484
|
+
<button
|
|
485
|
+
onClick={primary.onClick}
|
|
486
|
+
disabled={board.key === "convert" && (convertingThis || converting)}
|
|
487
|
+
data-testid={primary.testid}
|
|
488
|
+
className="px-2.5 py-1 text-[11px] font-medium rounded bg-accent text-white hover:bg-accent-dim disabled:opacity-50"
|
|
489
|
+
>
|
|
490
|
+
{primary.label}
|
|
491
|
+
</button>
|
|
492
|
+
) : null}
|
|
493
|
+
{!atLetteringStage && !primary && canAiDraft && (
|
|
494
|
+
<button
|
|
495
|
+
onClick={onAiDraft}
|
|
496
|
+
disabled={aiDrafting}
|
|
497
|
+
data-testid={`ai-draft-${cut.id}`}
|
|
498
|
+
className="px-2.5 py-1 text-[11px] rounded border border-accent/40 text-accent hover:bg-accent/5 disabled:opacity-50"
|
|
499
|
+
>
|
|
500
|
+
{aiDrafting ? "Drafting…" : aiDraftLabel}
|
|
501
|
+
</button>
|
|
502
|
+
)}
|
|
503
|
+
<button
|
|
504
|
+
onClick={onToggle}
|
|
505
|
+
data-testid={`cut-details-${cut.id}`}
|
|
506
|
+
className="px-2.5 py-1 text-[11px] rounded border border-border text-muted hover:border-accent hover:text-accent"
|
|
507
|
+
>
|
|
508
|
+
{expanded ? "Hide details" : "Open details"}
|
|
509
|
+
</button>
|
|
510
|
+
</div>
|
|
511
|
+
);
|
|
439
512
|
|
|
440
513
|
return (
|
|
441
514
|
<div
|
|
@@ -463,17 +536,24 @@ function CutRow({
|
|
|
463
536
|
{board.label}
|
|
464
537
|
</span>
|
|
465
538
|
</div>
|
|
466
|
-
{
|
|
467
|
-
|
|
539
|
+
{actionControls}
|
|
540
|
+
{thumbPath || isTextPanel(cut) ? (
|
|
541
|
+
<CutOverlayPreview
|
|
468
542
|
storyName={storyName}
|
|
469
543
|
assetPath={thumbPath}
|
|
470
544
|
authFetch={authFetch}
|
|
471
545
|
alt={`Cut ${cut.id} artwork`}
|
|
472
|
-
|
|
546
|
+
overlays={cut.overlays}
|
|
547
|
+
language={language}
|
|
548
|
+
background={cut.background}
|
|
549
|
+
aspectRatio={cut.aspectRatio}
|
|
550
|
+
onClick={canOpenPreviewEditor ? onOpenEditor : undefined}
|
|
551
|
+
className={previewClassName}
|
|
552
|
+
testId={`cut-preview-${cut.id}`}
|
|
473
553
|
/>
|
|
474
554
|
) : (
|
|
475
555
|
<div
|
|
476
|
-
className="
|
|
556
|
+
className={`${featured ? "min-h-[14rem] md:min-h-[18rem] xl:min-h-[21rem]" : "min-h-28 md:min-h-40"} w-full rounded border border-dashed border-border bg-surface/40 flex items-center justify-center text-[10px] text-muted`}
|
|
477
557
|
data-testid={`cut-card-noart-${cut.id}`}
|
|
478
558
|
>
|
|
479
559
|
{isTextPanel(cut)
|
|
@@ -495,57 +575,6 @@ function CutRow({
|
|
|
495
575
|
>
|
|
496
576
|
{cut.description || "No description"}
|
|
497
577
|
</button>
|
|
498
|
-
<div className="flex items-center gap-2 flex-wrap">
|
|
499
|
-
{atLetteringStage ? (
|
|
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
|
-
</>
|
|
519
|
-
) : primary ? (
|
|
520
|
-
<button
|
|
521
|
-
onClick={primary.onClick}
|
|
522
|
-
disabled={
|
|
523
|
-
board.key === "convert" && (convertingThis || converting)
|
|
524
|
-
}
|
|
525
|
-
data-testid={primary.testid}
|
|
526
|
-
className="px-2.5 py-1 text-[11px] font-medium rounded bg-accent text-white hover:bg-accent-dim disabled:opacity-50"
|
|
527
|
-
>
|
|
528
|
-
{primary.label}
|
|
529
|
-
</button>
|
|
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
|
-
)}
|
|
541
|
-
<button
|
|
542
|
-
onClick={onToggle}
|
|
543
|
-
data-testid={`cut-details-${cut.id}`}
|
|
544
|
-
className="px-2.5 py-1 text-[11px] rounded border border-border text-muted hover:border-accent hover:text-accent"
|
|
545
|
-
>
|
|
546
|
-
{expanded ? "Hide details" : "Open details"}
|
|
547
|
-
</button>
|
|
548
|
-
</div>
|
|
549
578
|
</div>
|
|
550
579
|
|
|
551
580
|
{expanded && (
|
|
@@ -781,6 +810,9 @@ export function CutListPanel({
|
|
|
781
810
|
onFocusedLetteringModeChange,
|
|
782
811
|
workspaceVisible = false,
|
|
783
812
|
onWorkspaceVisibleChange,
|
|
813
|
+
mode = "preview",
|
|
814
|
+
onExitFocusedEditor,
|
|
815
|
+
compactEpisodeChrome = false,
|
|
784
816
|
}: CutListPanelProps) {
|
|
785
817
|
const [cutsFile, setCutsFile] = useState<CutsFile | null>(null);
|
|
786
818
|
// Latest onCutsChanged in a ref so loadCuts can notify the parent without
|
|
@@ -793,6 +825,7 @@ export function CutListPanel({
|
|
|
793
825
|
const [error, setError] = useState<string | null>(null);
|
|
794
826
|
const [expandedCut, setExpandedCut] = useState<number | null>(null);
|
|
795
827
|
const [editingCutId, setEditingCutId] = useState<number | null>(null);
|
|
828
|
+
const autoOpenedEditRef = useRef(false);
|
|
796
829
|
const [generating, setGenerating] = useState(false);
|
|
797
830
|
const [genWarnings, setGenWarnings] = useState<string[]>([]);
|
|
798
831
|
const [uploading, setUploading] = useState(false);
|
|
@@ -865,6 +898,30 @@ export function CutListPanel({
|
|
|
865
898
|
return () => onFocusedLetteringModeChange?.(false);
|
|
866
899
|
}, [editingCutId, onFocusedLetteringModeChange]);
|
|
867
900
|
|
|
901
|
+
useEffect(() => {
|
|
902
|
+
if (mode !== "edit") {
|
|
903
|
+
autoOpenedEditRef.current = false;
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
if (
|
|
907
|
+
autoOpenedEditRef.current ||
|
|
908
|
+
editingCutId !== null ||
|
|
909
|
+
!cutsFile?.cuts.length
|
|
910
|
+
) {
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
const firstEditable =
|
|
914
|
+
cutsFile.cuts.find(
|
|
915
|
+
(cut) =>
|
|
916
|
+
cut.cleanImagePath ||
|
|
917
|
+
cut.narration ||
|
|
918
|
+
cut.dialogue.length > 0 ||
|
|
919
|
+
isTextPanel(cut),
|
|
920
|
+
) ?? cutsFile.cuts[0];
|
|
921
|
+
autoOpenedEditRef.current = true;
|
|
922
|
+
setEditingCutId(firstEditable.id);
|
|
923
|
+
}, [mode, editingCutId, cutsFile]);
|
|
924
|
+
|
|
868
925
|
// Scroll a deep-linked, expanded cut into view once its row is on screen. Runs
|
|
869
926
|
// when the target is set and again after the cut plan loads (rows mount). Best
|
|
870
927
|
// effort: `scrollIntoView` is a no-op/undefined under jsdom.
|
|
@@ -889,7 +946,14 @@ export function CutListPanel({
|
|
|
889
946
|
return;
|
|
890
947
|
}
|
|
891
948
|
const parsed = await res.json();
|
|
892
|
-
|
|
949
|
+
const normalized: CutsFile = {
|
|
950
|
+
...parsed,
|
|
951
|
+
version: typeof parsed?.version === "number" ? parsed.version : 1,
|
|
952
|
+
plotFile:
|
|
953
|
+
typeof parsed?.plotFile === "string" ? parsed.plotFile : plotFile,
|
|
954
|
+
cuts: Array.isArray(parsed?.cuts) ? parsed.cuts : [],
|
|
955
|
+
};
|
|
956
|
+
setCutsFile(normalized);
|
|
893
957
|
setError(null);
|
|
894
958
|
// Read the episode's publish markdown + on-chain status so the Finish panel
|
|
895
959
|
// can show "Episode sequence prepared" / "Ready to publish" / "Published"
|
|
@@ -903,7 +967,7 @@ export function CutListPanel({
|
|
|
903
967
|
const fd = await fileRes.json();
|
|
904
968
|
const content: string =
|
|
905
969
|
typeof fd?.content === "string" ? fd.content : "";
|
|
906
|
-
const cuts =
|
|
970
|
+
const cuts = normalized.cuts;
|
|
907
971
|
const markdownReady =
|
|
908
972
|
content.length > 0 && checkMarkdownReadiness(content, cuts).ready;
|
|
909
973
|
const published =
|
|
@@ -1457,6 +1521,10 @@ export function CutListPanel({
|
|
|
1457
1521
|
editingCutId !== null
|
|
1458
1522
|
? cutsFile.cuts.find((c) => c.id === editingCutId)
|
|
1459
1523
|
: null;
|
|
1524
|
+
const editingCutIndex =
|
|
1525
|
+
editingCutId !== null
|
|
1526
|
+
? cutsFile.cuts.findIndex((c) => c.id === editingCutId)
|
|
1527
|
+
: -1;
|
|
1460
1528
|
|
|
1461
1529
|
if (editingCut) {
|
|
1462
1530
|
return (
|
|
@@ -1493,8 +1561,19 @@ export function CutListPanel({
|
|
|
1493
1561
|
}
|
|
1494
1562
|
}}
|
|
1495
1563
|
onExported={() => loadCuts()}
|
|
1564
|
+
hasPreviousCut={editingCutIndex > 0}
|
|
1565
|
+
hasNextCut={editingCutIndex >= 0 && editingCutIndex < cutsFile.cuts.length - 1}
|
|
1566
|
+
onPreviousCut={() => {
|
|
1567
|
+
const previous = cutsFile.cuts[editingCutIndex - 1];
|
|
1568
|
+
if (previous) setEditingCutId(previous.id);
|
|
1569
|
+
}}
|
|
1570
|
+
onNextCut={() => {
|
|
1571
|
+
const next = cutsFile.cuts[editingCutIndex + 1];
|
|
1572
|
+
if (next) setEditingCutId(next.id);
|
|
1573
|
+
}}
|
|
1496
1574
|
onClose={() => {
|
|
1497
1575
|
setEditingCutId(null);
|
|
1576
|
+
onExitFocusedEditor?.();
|
|
1498
1577
|
loadCuts();
|
|
1499
1578
|
}}
|
|
1500
1579
|
/>
|
|
@@ -1534,6 +1613,11 @@ export function CutListPanel({
|
|
|
1534
1613
|
const canFinish =
|
|
1535
1614
|
cutsFile.cuts.some((ct) => ct.finalImagePath && !ct.uploadedCid) ||
|
|
1536
1615
|
(uploadStepDone && !episodeState.markdownReady);
|
|
1616
|
+
const workflowLabel = currentWorkflowLabel(
|
|
1617
|
+
finishChecklist,
|
|
1618
|
+
episodeState.markdownReady,
|
|
1619
|
+
episodeState.published,
|
|
1620
|
+
);
|
|
1537
1621
|
|
|
1538
1622
|
// PNG clean images awaiting conversion (#441): a friendly, batch-able step, not
|
|
1539
1623
|
// a red unsupported-extension dump. Built from the disk-validated diagnostics.
|
|
@@ -1582,61 +1666,57 @@ export function CutListPanel({
|
|
|
1582
1666
|
!cut.uploadedCid &&
|
|
1583
1667
|
!cut.uploadedUrl,
|
|
1584
1668
|
).length;
|
|
1669
|
+
const assetSummary = workspaceAssetSummary(assetDiagnostics);
|
|
1585
1670
|
|
|
1586
|
-
|
|
1671
|
+
const compactEndSummary = (
|
|
1587
1672
|
<div
|
|
1588
|
-
className="
|
|
1589
|
-
data-testid="cut-
|
|
1673
|
+
className="rounded border border-border bg-surface/45 px-3 py-2 text-xs text-muted"
|
|
1674
|
+
data-testid="cut-board-end-summary"
|
|
1590
1675
|
>
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
>
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1676
|
+
<span className="font-medium text-foreground">
|
|
1677
|
+
{episodeLabel}
|
|
1678
|
+
{episodeTitle ? ` · ${episodeTitle}` : ""}
|
|
1679
|
+
</span>
|
|
1680
|
+
<span className="ml-2">
|
|
1681
|
+
{boardSummary.cuts} cuts · {boardSummary.converted} clean ·{" "}
|
|
1682
|
+
{boardSummary.lettered} lettered · {boardSummary.uploaded} uploaded
|
|
1683
|
+
</span>
|
|
1684
|
+
</div>
|
|
1685
|
+
);
|
|
1686
|
+
|
|
1687
|
+
const workspaceTools = (
|
|
1688
|
+
<details
|
|
1689
|
+
className="border-b border-border bg-surface/35 flex-shrink-0"
|
|
1690
|
+
data-testid="cut-workspace-tools"
|
|
1691
|
+
>
|
|
1692
|
+
<summary className="list-none cursor-pointer px-3 py-1 hover:bg-surface/50">
|
|
1693
|
+
<div className="flex items-center gap-2 justify-between">
|
|
1694
|
+
<div className="flex min-w-0 flex-wrap items-center gap-1.5 text-[10px]">
|
|
1695
|
+
<span className="rounded-full border border-accent/30 bg-background px-2 py-0.5 font-medium text-accent">
|
|
1696
|
+
Workflow: {workflowLabel}
|
|
1697
|
+
</span>
|
|
1698
|
+
{assetSummary && (
|
|
1699
|
+
<span
|
|
1700
|
+
className="rounded-full border border-border bg-background px-2 py-0.5 text-muted"
|
|
1701
|
+
data-testid="workspace-asset-summary"
|
|
1702
|
+
>
|
|
1703
|
+
Assets: {assetSummary}
|
|
1601
1704
|
</span>
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
>
|
|
1610
|
-
{boardSummary.cuts} cuts · {boardSummary.artwork} artwork found ·{" "}
|
|
1611
|
-
{boardSummary.converted} converted · {boardSummary.lettered}{" "}
|
|
1612
|
-
lettered · {boardSummary.uploaded} uploaded
|
|
1613
|
-
</div>
|
|
1705
|
+
)}
|
|
1706
|
+
{conversionJobs.length > 0 && (
|
|
1707
|
+
<span className="rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-amber-700">
|
|
1708
|
+
Convert {conversionJobs.length} PNG
|
|
1709
|
+
{conversionJobs.length === 1 ? "" : "s"}
|
|
1710
|
+
</span>
|
|
1711
|
+
)}
|
|
1614
1712
|
</div>
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
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
|
-
)}
|
|
1713
|
+
<span className="flex-shrink-0 rounded border border-border bg-background px-2 py-1 text-[10px] text-muted">
|
|
1714
|
+
Workspace tools
|
|
1715
|
+
</span>
|
|
1627
1716
|
</div>
|
|
1628
|
-
</
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
primary actions are the main path; these stay for power users. */}
|
|
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]">
|
|
1717
|
+
</summary>
|
|
1718
|
+
<div className="border-t border-border px-3 py-2 space-y-3">
|
|
1719
|
+
<div className="flex flex-wrap items-center gap-2 text-[10px]">
|
|
1640
1720
|
<span className="font-mono text-muted">
|
|
1641
1721
|
{cutsFile.cuts.length} cuts
|
|
1642
1722
|
</span>
|
|
@@ -1721,41 +1801,155 @@ export function CutListPanel({
|
|
|
1721
1801
|
{uploadProgress || "Upload & Prepare for Publish"}
|
|
1722
1802
|
</button>
|
|
1723
1803
|
</div>
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1804
|
+
<div className="mt-1 text-[10px] text-muted">
|
|
1805
|
+
Use <span className="text-accent">Add narration/text panel</span>{" "}
|
|
1806
|
+
for a narration or title card. It becomes a solid card exported as a
|
|
1807
|
+
final image.
|
|
1808
|
+
</div>
|
|
1809
|
+
{conversionJobs.length > 0 && (
|
|
1810
|
+
<div
|
|
1811
|
+
className="rounded border border-amber-500/40 bg-amber-500/10 p-2 text-[11px]"
|
|
1812
|
+
data-testid="convert-artwork"
|
|
1813
|
+
>
|
|
1814
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
1815
|
+
<span
|
|
1816
|
+
className="font-medium text-amber-700"
|
|
1817
|
+
data-testid="convert-artwork-count"
|
|
1818
|
+
>
|
|
1819
|
+
{conversionJobs.length} PNG image
|
|
1820
|
+
{conversionJobs.length === 1 ? "" : "s"} found
|
|
1821
|
+
</span>
|
|
1822
|
+
<button
|
|
1823
|
+
onClick={() => convertAll(conversionJobs)}
|
|
1824
|
+
disabled={converting}
|
|
1825
|
+
data-testid="convert-all-btn"
|
|
1826
|
+
className="ml-auto px-2 py-0.5 border border-amber-500/50 text-amber-800 rounded hover:bg-amber-500/20 disabled:opacity-50"
|
|
1827
|
+
>
|
|
1828
|
+
{converting ? "Converting…" : "Convert all to WebP"}
|
|
1829
|
+
</button>
|
|
1830
|
+
</div>
|
|
1831
|
+
<p className="mt-1 text-[10px] text-muted">
|
|
1832
|
+
PNG artwork is fine while drafting. Convert it before
|
|
1833
|
+
lettering/export so PlotLink can publish it safely.
|
|
1834
|
+
</p>
|
|
1835
|
+
{convertResult && (
|
|
1836
|
+
<p
|
|
1837
|
+
className="mt-1 text-[10px] text-muted"
|
|
1838
|
+
data-testid="convert-result"
|
|
1839
|
+
>
|
|
1840
|
+
{convertResult}
|
|
1841
|
+
</p>
|
|
1842
|
+
)}
|
|
1843
|
+
{conversionIssues.length > 0 && (
|
|
1844
|
+
<details
|
|
1845
|
+
className="mt-1"
|
|
1846
|
+
data-testid="convert-technical-details"
|
|
1847
|
+
>
|
|
1848
|
+
<summary className="text-[10px] text-muted cursor-pointer">
|
|
1849
|
+
Conversion notes
|
|
1850
|
+
</summary>
|
|
1851
|
+
<ul className="mt-1 ml-3 list-disc text-[10px] text-muted">
|
|
1852
|
+
{conversionIssues.map((m, i) => (
|
|
1853
|
+
<li key={i}>{m}</li>
|
|
1854
|
+
))}
|
|
1855
|
+
</ul>
|
|
1856
|
+
</details>
|
|
1857
|
+
)}
|
|
1858
|
+
</div>
|
|
1859
|
+
)}
|
|
1860
|
+
{assetDiagnostics &&
|
|
1861
|
+
assetDiagnostics.length > 0 &&
|
|
1862
|
+
(() => {
|
|
1863
|
+
const s = summarizeAssetDiagnostics(assetDiagnostics);
|
|
1864
|
+
const missing = assetDiagnostics.filter(
|
|
1865
|
+
(d) => d.state === "missing",
|
|
1866
|
+
);
|
|
1867
|
+
return (
|
|
1868
|
+
<div
|
|
1869
|
+
className="rounded border border-border bg-background p-2 text-[10px]"
|
|
1870
|
+
data-testid="asset-diagnostics"
|
|
1871
|
+
>
|
|
1872
|
+
<span
|
|
1873
|
+
className="text-muted"
|
|
1874
|
+
data-testid="asset-diag-summary"
|
|
1875
|
+
>
|
|
1876
|
+
Assets: {s.uploaded} uploaded · {s.finalReady} final ·{" "}
|
|
1877
|
+
{s.cleanReady} clean · {s.planned} planned
|
|
1878
|
+
{s.needsConversion > 0
|
|
1879
|
+
? ` · ${s.needsConversion} needs conversion`
|
|
1880
|
+
: ""}
|
|
1881
|
+
{s.missing > 0 ? ` · ${s.missing} missing` : ""}
|
|
1882
|
+
</span>
|
|
1883
|
+
{missing.length > 0 && (
|
|
1884
|
+
<ul
|
|
1885
|
+
className="mt-1 ml-3 list-disc text-error"
|
|
1886
|
+
data-testid="asset-diag-issues"
|
|
1887
|
+
>
|
|
1888
|
+
{missing.map((d) => (
|
|
1889
|
+
<li key={d.cutId}>{d.issue}</li>
|
|
1890
|
+
))}
|
|
1891
|
+
</ul>
|
|
1892
|
+
)}
|
|
1893
|
+
</div>
|
|
1894
|
+
);
|
|
1895
|
+
})()}
|
|
1896
|
+
<FinishEpisodePanel
|
|
1897
|
+
checklist={finishChecklist}
|
|
1898
|
+
issues={genWarnings}
|
|
1899
|
+
onFinish={finishEpisode}
|
|
1900
|
+
finishing={uploading}
|
|
1901
|
+
progressText={uploadProgress}
|
|
1902
|
+
canFinish={canFinish}
|
|
1903
|
+
markdownReady={episodeState.markdownReady}
|
|
1904
|
+
published={episodeState.published}
|
|
1905
|
+
/>
|
|
1906
|
+
</div>
|
|
1907
|
+
</details>
|
|
1908
|
+
);
|
|
1909
|
+
|
|
1910
|
+
return (
|
|
1911
|
+
<div
|
|
1912
|
+
className="h-full min-h-[22rem] flex flex-col overflow-hidden"
|
|
1913
|
+
data-testid="cut-list-panel"
|
|
1914
|
+
>
|
|
1915
|
+
{/* Episode header + creator-facing progress summary (#440). */}
|
|
1916
|
+
{!compactEpisodeChrome && (
|
|
1917
|
+
<div
|
|
1918
|
+
className="px-3 py-1 border-b border-border flex-shrink-0"
|
|
1919
|
+
data-testid="cut-board-header"
|
|
1920
|
+
>
|
|
1921
|
+
<div className="flex items-center gap-3 justify-between">
|
|
1922
|
+
<div className="flex items-center gap-2 min-w-0 text-xs">
|
|
1923
|
+
<span className="font-serif text-foreground truncate">
|
|
1924
|
+
{episodeLabel}
|
|
1746
1925
|
</span>
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1926
|
+
{episodeTitle && (
|
|
1927
|
+
<span className="text-muted truncate">· {episodeTitle}</span>
|
|
1928
|
+
)}
|
|
1929
|
+
<span
|
|
1930
|
+
className="text-[10px] text-muted whitespace-nowrap"
|
|
1931
|
+
data-testid="cut-board-summary"
|
|
1932
|
+
>
|
|
1933
|
+
{boardSummary.cuts} cuts · {boardSummary.converted} clean ·{" "}
|
|
1934
|
+
{boardSummary.lettered} lettered · {boardSummary.uploaded} uploaded
|
|
1750
1935
|
</span>
|
|
1751
1936
|
</div>
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1937
|
+
{aiDraftEligibleCount > 0 && (
|
|
1938
|
+
<button
|
|
1939
|
+
onClick={draftAllUnletteredCuts}
|
|
1940
|
+
disabled={aiDraftingAll}
|
|
1941
|
+
data-testid="ai-draft-all-btn"
|
|
1942
|
+
className="px-2.5 py-1 text-[11px] rounded border border-accent/40 text-accent hover:bg-accent/5 disabled:opacity-50"
|
|
1943
|
+
>
|
|
1944
|
+
{aiDraftingAll
|
|
1945
|
+
? "Drafting…"
|
|
1946
|
+
: `AI draft all unlettered (${aiDraftEligibleCount})`}
|
|
1947
|
+
</button>
|
|
1948
|
+
)}
|
|
1757
1949
|
</div>
|
|
1758
|
-
</
|
|
1950
|
+
</div>
|
|
1951
|
+
)}
|
|
1952
|
+
{!compactEpisodeChrome && workspaceTools}
|
|
1759
1953
|
{/* Stale bubble-renderer warning (#381): a final image lettered before the
|
|
1760
1954
|
current seamless-tail renderer may show the old separate-tail seam.
|
|
1761
1955
|
Mark those cuts so the writer re-exports (open lettering → Export) and
|
|
@@ -1777,11 +1971,12 @@ export function CutListPanel({
|
|
|
1777
1971
|
Codex generation is complete even if the terminal session is still
|
|
1778
1972
|
connected — no more guessing whether it is still Working. */}
|
|
1779
1973
|
{detectConfirmed &&
|
|
1974
|
+
!compactEpisodeChrome &&
|
|
1780
1975
|
imageCutCount > 0 &&
|
|
1781
1976
|
stats.missing === 0 &&
|
|
1782
1977
|
staleByCut.size === 0 && (
|
|
1783
1978
|
<div
|
|
1784
|
-
className="px-3 py-
|
|
1979
|
+
className="px-3 py-0.5 border-b border-border bg-green-600/10 text-[10px] text-green-700 flex items-center gap-1 flex-shrink-0"
|
|
1785
1980
|
data-testid="clean-assets-ready"
|
|
1786
1981
|
>
|
|
1787
1982
|
<span aria-hidden>✓</span>
|
|
@@ -1800,109 +1995,6 @@ export function CutListPanel({
|
|
|
1800
1995
|
{syncResult}
|
|
1801
1996
|
</div>
|
|
1802
1997
|
)}
|
|
1803
|
-
{/* Convert artwork step (#441, spec §8): PNG clean images are a normal
|
|
1804
|
-
drafting intermediate, surfaced as a friendly batch conversion rather
|
|
1805
|
-
than red "Unsupported extension" errors. The raw reasons stay available
|
|
1806
|
-
under a collapsed "Technical details" disclosure. */}
|
|
1807
|
-
{conversionJobs.length > 0 && (
|
|
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
|
-
>
|
|
1812
|
-
<div className="flex items-center gap-2 flex-wrap">
|
|
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
|
|
1819
|
-
</span>
|
|
1820
|
-
<button
|
|
1821
|
-
onClick={() => convertAll(conversionJobs)}
|
|
1822
|
-
disabled={converting}
|
|
1823
|
-
data-testid="convert-all-btn"
|
|
1824
|
-
className="ml-auto px-2 py-0.5 border border-amber-500/50 text-amber-800 rounded hover:bg-amber-500/20 disabled:opacity-50"
|
|
1825
|
-
>
|
|
1826
|
-
{converting ? "Converting…" : "Convert all to WebP"}
|
|
1827
|
-
</button>
|
|
1828
|
-
</div>
|
|
1829
|
-
<p className="mt-1 text-[10px] text-muted">
|
|
1830
|
-
PNG artwork is fine while drafting. Convert it before
|
|
1831
|
-
lettering/export so PlotLink can publish it safely.
|
|
1832
|
-
</p>
|
|
1833
|
-
{convertResult && (
|
|
1834
|
-
<p
|
|
1835
|
-
className="mt-1 text-[10px] text-muted"
|
|
1836
|
-
data-testid="convert-result"
|
|
1837
|
-
>
|
|
1838
|
-
{convertResult}
|
|
1839
|
-
</p>
|
|
1840
|
-
)}
|
|
1841
|
-
{conversionIssues.length > 0 && (
|
|
1842
|
-
<details className="mt-1" data-testid="convert-technical-details">
|
|
1843
|
-
<summary className="text-[10px] text-muted cursor-pointer">
|
|
1844
|
-
Technical details
|
|
1845
|
-
</summary>
|
|
1846
|
-
<ul className="mt-1 ml-3 list-disc text-[10px] text-muted">
|
|
1847
|
-
{conversionIssues.map((m, i) => (
|
|
1848
|
-
<li key={i}>{m}</li>
|
|
1849
|
-
))}
|
|
1850
|
-
</ul>
|
|
1851
|
-
</details>
|
|
1852
|
-
)}
|
|
1853
|
-
</div>
|
|
1854
|
-
)}
|
|
1855
|
-
{/* Read-only per-cut asset state validated against disk (#427): a compact
|
|
1856
|
-
state tally + a precise per-cut reason when a recorded path is broken,
|
|
1857
|
-
so "files exist but aren't shown" / a typoed path is a clear diagnostic
|
|
1858
|
-
rather than a generic publish warning. */}
|
|
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
|
-
})()}
|
|
1890
|
-
{/* Guided Finish-episode flow (#414): writer-language step status, one primary
|
|
1891
|
-
"Finish episode" action that uploads finals then prepares the publish
|
|
1892
|
-
markdown in order, and any blockers grouped by the step that fixes them —
|
|
1893
|
-
replacing the old flat amber warning list. The lower-level controls in the
|
|
1894
|
-
header above stay available for manual recovery. */}
|
|
1895
|
-
<FinishEpisodePanel
|
|
1896
|
-
checklist={finishChecklist}
|
|
1897
|
-
issues={genWarnings}
|
|
1898
|
-
onFinish={finishEpisode}
|
|
1899
|
-
finishing={uploading}
|
|
1900
|
-
progressText={uploadProgress}
|
|
1901
|
-
canFinish={canFinish}
|
|
1902
|
-
markdownReady={episodeState.markdownReady}
|
|
1903
|
-
published={episodeState.published}
|
|
1904
|
-
/>
|
|
1905
|
-
|
|
1906
1998
|
{/* Full cut review (#488): all clean cuts are shown vertically first, with
|
|
1907
1999
|
explicit between-scene slots for narration/title cards. */}
|
|
1908
2000
|
<div
|
|
@@ -1926,6 +2018,7 @@ export function CutListPanel({
|
|
|
1926
2018
|
cut={cut}
|
|
1927
2019
|
storyName={storyName}
|
|
1928
2020
|
plotFile={plotFile}
|
|
2021
|
+
language={language}
|
|
1929
2022
|
expanded={expandedCut === cut.id}
|
|
1930
2023
|
onToggle={() =>
|
|
1931
2024
|
setExpandedCut(expandedCut === cut.id ? null : cut.id)
|
|
@@ -1950,6 +2043,7 @@ export function CutListPanel({
|
|
|
1950
2043
|
conversionPng={conversionByCut.get(cut.id) ?? null}
|
|
1951
2044
|
onConvert={convertCut}
|
|
1952
2045
|
converting={converting}
|
|
2046
|
+
featured={index === 0}
|
|
1953
2047
|
rowRef={(el) => {
|
|
1954
2048
|
if (el) rowRefs.current.set(cut.id, el);
|
|
1955
2049
|
else rowRefs.current.delete(cut.id);
|
|
@@ -1964,6 +2058,12 @@ export function CutListPanel({
|
|
|
1964
2058
|
disabled={addingPanel}
|
|
1965
2059
|
onAdd={() => addTextPanelAt(cutsFile.cuts.length)}
|
|
1966
2060
|
/>
|
|
2061
|
+
{compactEpisodeChrome && (
|
|
2062
|
+
<>
|
|
2063
|
+
{compactEndSummary}
|
|
2064
|
+
{workspaceTools}
|
|
2065
|
+
</>
|
|
2066
|
+
)}
|
|
1967
2067
|
</div>
|
|
1968
2068
|
</div>
|
|
1969
2069
|
);
|
|
@@ -2007,3 +2107,32 @@ function BetweenSceneSlot({
|
|
|
2007
2107
|
</div>
|
|
2008
2108
|
);
|
|
2009
2109
|
}
|
|
2110
|
+
|
|
2111
|
+
function workspaceAssetSummary(
|
|
2112
|
+
assetDiagnostics: CutAssetDiagnostic[] | null,
|
|
2113
|
+
): string | null {
|
|
2114
|
+
if (!assetDiagnostics || assetDiagnostics.length === 0) return null;
|
|
2115
|
+
const s = summarizeAssetDiagnostics(assetDiagnostics);
|
|
2116
|
+
const parts = [
|
|
2117
|
+
`${s.cleanReady} clean`,
|
|
2118
|
+
`${s.finalReady} final`,
|
|
2119
|
+
`${s.uploaded} uploaded`,
|
|
2120
|
+
];
|
|
2121
|
+
if (s.needsConversion > 0) parts.push(`${s.needsConversion} PNG`);
|
|
2122
|
+
if (s.missing > 0) parts.push(`${s.missing} missing`);
|
|
2123
|
+
return parts.join(" · ");
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
function currentWorkflowLabel(
|
|
2127
|
+
checklist: ReturnType<typeof cartoonChecklist>,
|
|
2128
|
+
markdownReady: boolean,
|
|
2129
|
+
published: boolean,
|
|
2130
|
+
): string {
|
|
2131
|
+
return (
|
|
2132
|
+
buildCartoonProductionStatus({
|
|
2133
|
+
checklist,
|
|
2134
|
+
markdownReady,
|
|
2135
|
+
published,
|
|
2136
|
+
})?.statusLabel ?? "Review cuts"
|
|
2137
|
+
);
|
|
2138
|
+
}
|