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.
@@ -1,6 +1,7 @@
1
1
  import { Fragment, useState, useEffect, useCallback, useRef } from "react";
2
2
  import { LetteringEditor } from "./LetteringEditor";
3
- import { AssetImage, assetUrl } from "./asset-image";
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 ? "Re-draft with AI" : "AI draft lettering";
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
- {thumbPath ? (
467
- <AssetImage
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
- className="w-full max-h-[32rem] object-contain rounded border border-border bg-white"
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="w-full min-h-28 rounded border border-dashed border-border bg-surface/40 flex items-center justify-center text-[10px] text-muted"
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-advanced"
1659
+ className="border-b border-border bg-surface/35 flex-shrink-0"
1660
+ data-testid="cut-workspace-tools"
1635
1661
  >
1636
- <summary className="px-3 py-1.5 text-[10px] text-muted cursor-pointer hover:text-foreground">
1637
- Technical details
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 flex flex-wrap items-center gap-2 text-[10px]">
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
+ }