plotlink-ows 1.2.96 → 1.2.97
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/lib/cartoon-production-status.ts +77 -0
- package/app/web/components/CartoonNextAction.tsx +154 -36
- package/app/web/components/CartoonProductionStatus.tsx +142 -0
- package/app/web/components/CartoonPublishPage.tsx +34 -25
- package/app/web/components/CutListPanel.tsx +250 -196
- package/app/web/components/CutOverlayPreview.tsx +306 -0
- package/app/web/components/EpisodesPage.tsx +24 -0
- package/app/web/components/FinishEpisodePanel.tsx +21 -108
- package/app/web/components/LetteringEditor.tsx +180 -109
- package/app/web/components/PreviewPanel.tsx +58 -63
- package/app/web/components/StoriesPage.tsx +99 -78
- package/app/web/components/StoryInfoPage.tsx +31 -14
- package/app/web/components/StoryProgressPanel.tsx +19 -23
- package/app/web/dist/assets/{export-cut-BqZI0-Rv.js → export-cut-Cj-cOtan.js} +1 -1
- package/app/web/dist/assets/index-CCeEYE7p.css +32 -0
- package/app/web/dist/assets/index-CF7pE09m.js +141 -0
- package/app/web/dist/index.html +2 -2
- package/package.json +1 -1
- package/app/web/dist/assets/index-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,
|
|
@@ -265,6 +267,7 @@ function CutRow({
|
|
|
265
267
|
cut,
|
|
266
268
|
storyName,
|
|
267
269
|
plotFile,
|
|
270
|
+
language,
|
|
268
271
|
expanded,
|
|
269
272
|
onToggle,
|
|
270
273
|
authFetch,
|
|
@@ -282,10 +285,12 @@ function CutRow({
|
|
|
282
285
|
onConvert,
|
|
283
286
|
converting,
|
|
284
287
|
rowRef,
|
|
288
|
+
featured = false,
|
|
285
289
|
}: {
|
|
286
290
|
cut: Cut;
|
|
287
291
|
storyName: string;
|
|
288
292
|
plotFile: string;
|
|
293
|
+
language?: string;
|
|
289
294
|
expanded: boolean;
|
|
290
295
|
onToggle: () => void;
|
|
291
296
|
authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
|
|
@@ -304,6 +309,7 @@ function CutRow({
|
|
|
304
309
|
onConvert: (cutId: number, pngPath: string) => Promise<boolean>;
|
|
305
310
|
converting: boolean;
|
|
306
311
|
rowRef?: (el: HTMLDivElement | null) => void;
|
|
312
|
+
featured?: boolean;
|
|
307
313
|
}) {
|
|
308
314
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
309
315
|
const [uploading, setUploading] = useState(false);
|
|
@@ -407,7 +413,9 @@ function CutRow({
|
|
|
407
413
|
!cut.uploadedUrl &&
|
|
408
414
|
canDraftLettering(cut);
|
|
409
415
|
const aiDraftLabel =
|
|
410
|
-
(cut.overlays?.length ?? 0) > 0
|
|
416
|
+
(cut.overlays?.length ?? 0) > 0
|
|
417
|
+
? "Re-draft bubbles with AI"
|
|
418
|
+
: "AI draft bubbles";
|
|
411
419
|
|
|
412
420
|
const primary: { label: string; onClick: () => void; testid: string } | null =
|
|
413
421
|
board.key === "convert"
|
|
@@ -436,6 +444,65 @@ function CutRow({
|
|
|
436
444
|
}
|
|
437
445
|
: null; // exported / uploaded — the next action is the episode-level upload/publish
|
|
438
446
|
const reviewState = letteringReviewState(cut);
|
|
447
|
+
const canOpenPreviewEditor =
|
|
448
|
+
!!cut.cleanImagePath ||
|
|
449
|
+
!!cut.narration ||
|
|
450
|
+
cut.dialogue.length > 0 ||
|
|
451
|
+
isTextPanel(cut);
|
|
452
|
+
const previewClassName = featured
|
|
453
|
+
? "w-full rounded border border-border bg-white min-h-[14rem] md:min-h-[18rem] xl:min-h-[21rem]"
|
|
454
|
+
: "w-full rounded border border-border bg-white min-h-[11rem] md:min-h-[13rem]";
|
|
455
|
+
const actionControls = (
|
|
456
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
457
|
+
{atLetteringStage ? (
|
|
458
|
+
<>
|
|
459
|
+
<button
|
|
460
|
+
onClick={onOpenEditor}
|
|
461
|
+
data-testid={`add-bubbles-${cut.id}`}
|
|
462
|
+
className="px-2.5 py-1 text-[11px] font-medium rounded bg-accent text-white hover:bg-accent-dim"
|
|
463
|
+
>
|
|
464
|
+
{bubblesPlaced > 0 ? "Review lettering" : "Open focused editor"}
|
|
465
|
+
</button>
|
|
466
|
+
{canAiDraft && (
|
|
467
|
+
<button
|
|
468
|
+
onClick={onAiDraft}
|
|
469
|
+
disabled={aiDrafting}
|
|
470
|
+
data-testid={`ai-draft-${cut.id}`}
|
|
471
|
+
className="px-2.5 py-1 text-[11px] rounded border border-accent/40 text-accent hover:bg-accent/5 disabled:opacity-50"
|
|
472
|
+
>
|
|
473
|
+
{aiDrafting ? "Drafting…" : aiDraftLabel}
|
|
474
|
+
</button>
|
|
475
|
+
)}
|
|
476
|
+
</>
|
|
477
|
+
) : primary ? (
|
|
478
|
+
<button
|
|
479
|
+
onClick={primary.onClick}
|
|
480
|
+
disabled={board.key === "convert" && (convertingThis || converting)}
|
|
481
|
+
data-testid={primary.testid}
|
|
482
|
+
className="px-2.5 py-1 text-[11px] font-medium rounded bg-accent text-white hover:bg-accent-dim disabled:opacity-50"
|
|
483
|
+
>
|
|
484
|
+
{primary.label}
|
|
485
|
+
</button>
|
|
486
|
+
) : null}
|
|
487
|
+
{!atLetteringStage && !primary && canAiDraft && (
|
|
488
|
+
<button
|
|
489
|
+
onClick={onAiDraft}
|
|
490
|
+
disabled={aiDrafting}
|
|
491
|
+
data-testid={`ai-draft-${cut.id}`}
|
|
492
|
+
className="px-2.5 py-1 text-[11px] rounded border border-accent/40 text-accent hover:bg-accent/5 disabled:opacity-50"
|
|
493
|
+
>
|
|
494
|
+
{aiDrafting ? "Drafting…" : aiDraftLabel}
|
|
495
|
+
</button>
|
|
496
|
+
)}
|
|
497
|
+
<button
|
|
498
|
+
onClick={onToggle}
|
|
499
|
+
data-testid={`cut-details-${cut.id}`}
|
|
500
|
+
className="px-2.5 py-1 text-[11px] rounded border border-border text-muted hover:border-accent hover:text-accent"
|
|
501
|
+
>
|
|
502
|
+
{expanded ? "Hide details" : "Open details"}
|
|
503
|
+
</button>
|
|
504
|
+
</div>
|
|
505
|
+
);
|
|
439
506
|
|
|
440
507
|
return (
|
|
441
508
|
<div
|
|
@@ -463,17 +530,24 @@ function CutRow({
|
|
|
463
530
|
{board.label}
|
|
464
531
|
</span>
|
|
465
532
|
</div>
|
|
466
|
-
{
|
|
467
|
-
|
|
533
|
+
{actionControls}
|
|
534
|
+
{thumbPath || isTextPanel(cut) ? (
|
|
535
|
+
<CutOverlayPreview
|
|
468
536
|
storyName={storyName}
|
|
469
537
|
assetPath={thumbPath}
|
|
470
538
|
authFetch={authFetch}
|
|
471
539
|
alt={`Cut ${cut.id} artwork`}
|
|
472
|
-
|
|
540
|
+
overlays={cut.overlays}
|
|
541
|
+
language={language}
|
|
542
|
+
background={cut.background}
|
|
543
|
+
aspectRatio={cut.aspectRatio}
|
|
544
|
+
onClick={canOpenPreviewEditor ? onOpenEditor : undefined}
|
|
545
|
+
className={previewClassName}
|
|
546
|
+
testId={`cut-preview-${cut.id}`}
|
|
473
547
|
/>
|
|
474
548
|
) : (
|
|
475
549
|
<div
|
|
476
|
-
className="
|
|
550
|
+
className={`${featured ? "min-h-[14rem] md:min-h-[18rem] xl:min-h-[21rem]" : "min-h-28 md:min-h-40"} w-full rounded border border-dashed border-border bg-surface/40 flex items-center justify-center text-[10px] text-muted`}
|
|
477
551
|
data-testid={`cut-card-noart-${cut.id}`}
|
|
478
552
|
>
|
|
479
553
|
{isTextPanel(cut)
|
|
@@ -495,57 +569,6 @@ function CutRow({
|
|
|
495
569
|
>
|
|
496
570
|
{cut.description || "No description"}
|
|
497
571
|
</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
572
|
</div>
|
|
550
573
|
|
|
551
574
|
{expanded && (
|
|
@@ -1534,6 +1557,11 @@ export function CutListPanel({
|
|
|
1534
1557
|
const canFinish =
|
|
1535
1558
|
cutsFile.cuts.some((ct) => ct.finalImagePath && !ct.uploadedCid) ||
|
|
1536
1559
|
(uploadStepDone && !episodeState.markdownReady);
|
|
1560
|
+
const workflowLabel = currentWorkflowLabel(
|
|
1561
|
+
finishChecklist,
|
|
1562
|
+
episodeState.markdownReady,
|
|
1563
|
+
episodeState.published,
|
|
1564
|
+
);
|
|
1537
1565
|
|
|
1538
1566
|
// PNG clean images awaiting conversion (#441): a friendly, batch-able step, not
|
|
1539
1567
|
// a red unsupported-extension dump. Built from the disk-validated diagnostics.
|
|
@@ -1582,6 +1610,7 @@ export function CutListPanel({
|
|
|
1582
1610
|
!cut.uploadedCid &&
|
|
1583
1611
|
!cut.uploadedUrl,
|
|
1584
1612
|
).length;
|
|
1613
|
+
const assetSummary = workspaceAssetSummary(assetDiagnostics);
|
|
1585
1614
|
|
|
1586
1615
|
return (
|
|
1587
1616
|
<div
|
|
@@ -1626,17 +1655,45 @@ export function CutListPanel({
|
|
|
1626
1655
|
)}
|
|
1627
1656
|
</div>
|
|
1628
1657
|
</div>
|
|
1629
|
-
{/* Lower-level / manual controls, collapsed by default so the board stays
|
|
1630
|
-
focused on per-cut actions (#440). The guided Finish flow + per-cut
|
|
1631
|
-
primary actions are the main path; these stay for power users. */}
|
|
1632
1658
|
<details
|
|
1633
|
-
className="border-b border-border flex-shrink-0"
|
|
1634
|
-
data-testid="cut-
|
|
1659
|
+
className="border-b border-border bg-surface/35 flex-shrink-0"
|
|
1660
|
+
data-testid="cut-workspace-tools"
|
|
1635
1661
|
>
|
|
1636
|
-
<summary className="
|
|
1637
|
-
|
|
1662
|
+
<summary className="list-none cursor-pointer px-3 py-2 hover:bg-surface/50">
|
|
1663
|
+
<div className="flex items-start gap-3 justify-between">
|
|
1664
|
+
<div className="min-w-0 space-y-1">
|
|
1665
|
+
<div className="flex flex-wrap items-center gap-1.5 text-[10px]">
|
|
1666
|
+
<span className="rounded-full border border-accent/30 bg-background px-2 py-0.5 font-medium text-accent">
|
|
1667
|
+
Workflow: {workflowLabel}
|
|
1668
|
+
</span>
|
|
1669
|
+
{assetSummary && (
|
|
1670
|
+
<span
|
|
1671
|
+
className="rounded-full border border-border bg-background px-2 py-0.5 text-muted"
|
|
1672
|
+
data-testid="workspace-asset-summary"
|
|
1673
|
+
>
|
|
1674
|
+
Assets: {assetSummary}
|
|
1675
|
+
</span>
|
|
1676
|
+
)}
|
|
1677
|
+
{conversionJobs.length > 0 && (
|
|
1678
|
+
<span className="rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-amber-700">
|
|
1679
|
+
Convert {conversionJobs.length} PNG
|
|
1680
|
+
{conversionJobs.length === 1 ? "" : "s"}
|
|
1681
|
+
</span>
|
|
1682
|
+
)}
|
|
1683
|
+
</div>
|
|
1684
|
+
<p className="text-[10px] text-muted">
|
|
1685
|
+
Per-cut actions stay on each card. Open workspace tools for
|
|
1686
|
+
publish prep, asset recovery, narration cards, and blocked-step
|
|
1687
|
+
details.
|
|
1688
|
+
</p>
|
|
1689
|
+
</div>
|
|
1690
|
+
<span className="flex-shrink-0 rounded border border-border bg-background px-2 py-1 text-[10px] text-muted">
|
|
1691
|
+
Workspace tools
|
|
1692
|
+
</span>
|
|
1693
|
+
</div>
|
|
1638
1694
|
</summary>
|
|
1639
|
-
<div className="px-3 py-2
|
|
1695
|
+
<div className="border-t border-border px-3 py-2 space-y-3">
|
|
1696
|
+
<div className="flex flex-wrap items-center gap-2 text-[10px]">
|
|
1640
1697
|
<span className="font-mono text-muted">
|
|
1641
1698
|
{cutsFile.cuts.length} cuts
|
|
1642
1699
|
</span>
|
|
@@ -1720,40 +1777,109 @@ export function CutListPanel({
|
|
|
1720
1777
|
>
|
|
1721
1778
|
{uploadProgress || "Upload & Prepare for Publish"}
|
|
1722
1779
|
</button>
|
|
1723
|
-
</div>
|
|
1724
|
-
</details>
|
|
1725
|
-
{/* Plain-language workflow + text-panel explainer (#360) so a non-technical
|
|
1726
|
-
writer understands the order of operations and what a text panel is. */}
|
|
1727
|
-
<details
|
|
1728
|
-
className="px-3 py-1.5 border-b border-border bg-surface/40 flex-shrink-0"
|
|
1729
|
-
data-testid="cartoon-workflow-help"
|
|
1730
|
-
>
|
|
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
1780
|
</div>
|
|
1752
1781
|
<div className="mt-1 text-[10px] text-muted">
|
|
1753
1782
|
Use <span className="text-accent">Add narration/text panel</span>{" "}
|
|
1754
1783
|
for a narration or title card. It becomes a solid card exported as a
|
|
1755
1784
|
final image.
|
|
1756
1785
|
</div>
|
|
1786
|
+
{conversionJobs.length > 0 && (
|
|
1787
|
+
<div
|
|
1788
|
+
className="rounded border border-amber-500/40 bg-amber-500/10 p-2 text-[11px]"
|
|
1789
|
+
data-testid="convert-artwork"
|
|
1790
|
+
>
|
|
1791
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
1792
|
+
<span
|
|
1793
|
+
className="font-medium text-amber-700"
|
|
1794
|
+
data-testid="convert-artwork-count"
|
|
1795
|
+
>
|
|
1796
|
+
{conversionJobs.length} PNG image
|
|
1797
|
+
{conversionJobs.length === 1 ? "" : "s"} found
|
|
1798
|
+
</span>
|
|
1799
|
+
<button
|
|
1800
|
+
onClick={() => convertAll(conversionJobs)}
|
|
1801
|
+
disabled={converting}
|
|
1802
|
+
data-testid="convert-all-btn"
|
|
1803
|
+
className="ml-auto px-2 py-0.5 border border-amber-500/50 text-amber-800 rounded hover:bg-amber-500/20 disabled:opacity-50"
|
|
1804
|
+
>
|
|
1805
|
+
{converting ? "Converting…" : "Convert all to WebP"}
|
|
1806
|
+
</button>
|
|
1807
|
+
</div>
|
|
1808
|
+
<p className="mt-1 text-[10px] text-muted">
|
|
1809
|
+
PNG artwork is fine while drafting. Convert it before
|
|
1810
|
+
lettering/export so PlotLink can publish it safely.
|
|
1811
|
+
</p>
|
|
1812
|
+
{convertResult && (
|
|
1813
|
+
<p
|
|
1814
|
+
className="mt-1 text-[10px] text-muted"
|
|
1815
|
+
data-testid="convert-result"
|
|
1816
|
+
>
|
|
1817
|
+
{convertResult}
|
|
1818
|
+
</p>
|
|
1819
|
+
)}
|
|
1820
|
+
{conversionIssues.length > 0 && (
|
|
1821
|
+
<details
|
|
1822
|
+
className="mt-1"
|
|
1823
|
+
data-testid="convert-technical-details"
|
|
1824
|
+
>
|
|
1825
|
+
<summary className="text-[10px] text-muted cursor-pointer">
|
|
1826
|
+
Conversion notes
|
|
1827
|
+
</summary>
|
|
1828
|
+
<ul className="mt-1 ml-3 list-disc text-[10px] text-muted">
|
|
1829
|
+
{conversionIssues.map((m, i) => (
|
|
1830
|
+
<li key={i}>{m}</li>
|
|
1831
|
+
))}
|
|
1832
|
+
</ul>
|
|
1833
|
+
</details>
|
|
1834
|
+
)}
|
|
1835
|
+
</div>
|
|
1836
|
+
)}
|
|
1837
|
+
{assetDiagnostics &&
|
|
1838
|
+
assetDiagnostics.length > 0 &&
|
|
1839
|
+
(() => {
|
|
1840
|
+
const s = summarizeAssetDiagnostics(assetDiagnostics);
|
|
1841
|
+
const missing = assetDiagnostics.filter(
|
|
1842
|
+
(d) => d.state === "missing",
|
|
1843
|
+
);
|
|
1844
|
+
return (
|
|
1845
|
+
<div
|
|
1846
|
+
className="rounded border border-border bg-background p-2 text-[10px]"
|
|
1847
|
+
data-testid="asset-diagnostics"
|
|
1848
|
+
>
|
|
1849
|
+
<span
|
|
1850
|
+
className="text-muted"
|
|
1851
|
+
data-testid="asset-diag-summary"
|
|
1852
|
+
>
|
|
1853
|
+
Assets: {s.uploaded} uploaded · {s.finalReady} final ·{" "}
|
|
1854
|
+
{s.cleanReady} clean · {s.planned} planned
|
|
1855
|
+
{s.needsConversion > 0
|
|
1856
|
+
? ` · ${s.needsConversion} needs conversion`
|
|
1857
|
+
: ""}
|
|
1858
|
+
{s.missing > 0 ? ` · ${s.missing} missing` : ""}
|
|
1859
|
+
</span>
|
|
1860
|
+
{missing.length > 0 && (
|
|
1861
|
+
<ul
|
|
1862
|
+
className="mt-1 ml-3 list-disc text-error"
|
|
1863
|
+
data-testid="asset-diag-issues"
|
|
1864
|
+
>
|
|
1865
|
+
{missing.map((d) => (
|
|
1866
|
+
<li key={d.cutId}>{d.issue}</li>
|
|
1867
|
+
))}
|
|
1868
|
+
</ul>
|
|
1869
|
+
)}
|
|
1870
|
+
</div>
|
|
1871
|
+
);
|
|
1872
|
+
})()}
|
|
1873
|
+
<FinishEpisodePanel
|
|
1874
|
+
checklist={finishChecklist}
|
|
1875
|
+
issues={genWarnings}
|
|
1876
|
+
onFinish={finishEpisode}
|
|
1877
|
+
finishing={uploading}
|
|
1878
|
+
progressText={uploadProgress}
|
|
1879
|
+
canFinish={canFinish}
|
|
1880
|
+
markdownReady={episodeState.markdownReady}
|
|
1881
|
+
published={episodeState.published}
|
|
1882
|
+
/>
|
|
1757
1883
|
</div>
|
|
1758
1884
|
</details>
|
|
1759
1885
|
{/* Stale bubble-renderer warning (#381): a final image lettered before the
|
|
@@ -1800,109 +1926,6 @@ export function CutListPanel({
|
|
|
1800
1926
|
{syncResult}
|
|
1801
1927
|
</div>
|
|
1802
1928
|
)}
|
|
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
1929
|
{/* Full cut review (#488): all clean cuts are shown vertically first, with
|
|
1907
1930
|
explicit between-scene slots for narration/title cards. */}
|
|
1908
1931
|
<div
|
|
@@ -1926,6 +1949,7 @@ export function CutListPanel({
|
|
|
1926
1949
|
cut={cut}
|
|
1927
1950
|
storyName={storyName}
|
|
1928
1951
|
plotFile={plotFile}
|
|
1952
|
+
language={language}
|
|
1929
1953
|
expanded={expandedCut === cut.id}
|
|
1930
1954
|
onToggle={() =>
|
|
1931
1955
|
setExpandedCut(expandedCut === cut.id ? null : cut.id)
|
|
@@ -1950,6 +1974,7 @@ export function CutListPanel({
|
|
|
1950
1974
|
conversionPng={conversionByCut.get(cut.id) ?? null}
|
|
1951
1975
|
onConvert={convertCut}
|
|
1952
1976
|
converting={converting}
|
|
1977
|
+
featured={index === 0}
|
|
1953
1978
|
rowRef={(el) => {
|
|
1954
1979
|
if (el) rowRefs.current.set(cut.id, el);
|
|
1955
1980
|
else rowRefs.current.delete(cut.id);
|
|
@@ -2007,3 +2032,32 @@ function BetweenSceneSlot({
|
|
|
2007
2032
|
</div>
|
|
2008
2033
|
);
|
|
2009
2034
|
}
|
|
2035
|
+
|
|
2036
|
+
function workspaceAssetSummary(
|
|
2037
|
+
assetDiagnostics: CutAssetDiagnostic[] | null,
|
|
2038
|
+
): string | null {
|
|
2039
|
+
if (!assetDiagnostics || assetDiagnostics.length === 0) return null;
|
|
2040
|
+
const s = summarizeAssetDiagnostics(assetDiagnostics);
|
|
2041
|
+
const parts = [
|
|
2042
|
+
`${s.cleanReady} clean`,
|
|
2043
|
+
`${s.finalReady} final`,
|
|
2044
|
+
`${s.uploaded} uploaded`,
|
|
2045
|
+
];
|
|
2046
|
+
if (s.needsConversion > 0) parts.push(`${s.needsConversion} PNG`);
|
|
2047
|
+
if (s.missing > 0) parts.push(`${s.missing} missing`);
|
|
2048
|
+
return parts.join(" · ");
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
function currentWorkflowLabel(
|
|
2052
|
+
checklist: ReturnType<typeof cartoonChecklist>,
|
|
2053
|
+
markdownReady: boolean,
|
|
2054
|
+
published: boolean,
|
|
2055
|
+
): string {
|
|
2056
|
+
return (
|
|
2057
|
+
buildCartoonProductionStatus({
|
|
2058
|
+
checklist,
|
|
2059
|
+
markdownReady,
|
|
2060
|
+
published,
|
|
2061
|
+
})?.statusLabel ?? "Review cuts"
|
|
2062
|
+
);
|
|
2063
|
+
}
|