From 26b43a74742966707e1b11986180c94c498e86d4 Mon Sep 17 00:00:00 2001 From: Project7 Date: Sun, 7 Jun 2026 21:39:53 +0000 Subject: [PATCH 1/2] [#493] Make cartoon lettering editor fullscreen --- app/web/components/CutListPanel.test.tsx | 2196 +++++++++++++--- app/web/components/CutListPanel.tsx | 1299 +++++++--- app/web/components/LetteringEditor.tsx | 1189 ++++++--- app/web/components/PreviewPanel.tsx | 2304 +++++++++++------ app/web/components/StoriesPage.test.tsx | 520 +++- app/web/components/StoriesPage.tsx | 1487 +++++++---- ...cut-che5mMWc.js => export-cut-DmVsiKji.js} | 2 +- app/web/dist/assets/index-D3eZu5VE.js | 143 + app/web/dist/assets/index-Dc2TQ3Ij.js | 143 - app/web/dist/index.html | 2 +- 10 files changed, 6570 insertions(+), 2715 deletions(-) rename app/web/dist/assets/{export-cut-che5mMWc.js => export-cut-DmVsiKji.js} (98%) create mode 100644 app/web/dist/assets/index-D3eZu5VE.js delete mode 100644 app/web/dist/assets/index-Dc2TQ3Ij.js diff --git a/app/web/components/CutListPanel.test.tsx b/app/web/components/CutListPanel.test.tsx index 6e2c2f9..e241bad 100644 --- a/app/web/components/CutListPanel.test.tsx +++ b/app/web/components/CutListPanel.test.tsx @@ -1,5 +1,21 @@ -import { describe, it, expect, vi, afterEach, beforeAll, beforeEach } from "vitest"; -import { render, screen, cleanup, waitFor, fireEvent, act, within } from "@testing-library/react"; +import { + describe, + it, + expect, + vi, + afterEach, + beforeAll, + beforeEach, +} from "vitest"; +import { + render, + screen, + cleanup, + waitFor, + fireEvent, + act, + within, +} from "@testing-library/react"; import { CutListPanel } from "./CutListPanel"; import { installObjectUrlStub, MOCK_BLOB_URL } from "./asset-test-utils"; import { CARTOON_BUBBLE_RENDERER_VERSION } from "@app-lib/overlays"; @@ -8,11 +24,27 @@ beforeAll(() => { installObjectUrlStub(); global.ResizeObserver = class { callback: ResizeObserverCallback; - constructor(callback: ResizeObserverCallback) { this.callback = callback; } + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + } observe(target: Element) { - Object.defineProperty(target, "clientWidth", { value: 400, configurable: true }); - Object.defineProperty(target, "clientHeight", { value: 300, configurable: true }); - this.callback([{ contentRect: { width: 400, height: 300 }, target } as unknown as ResizeObserverEntry], this); + Object.defineProperty(target, "clientWidth", { + value: 400, + configurable: true, + }); + Object.defineProperty(target, "clientHeight", { + value: 300, + configurable: true, + }); + this.callback( + [ + { + contentRect: { width: 400, height: 300 }, + target, + } as unknown as ResizeObserverEntry, + ], + this, + ); } unobserve() {} disconnect() {} @@ -25,11 +57,20 @@ beforeEach(() => { afterEach(cleanup); -function mockAuthFetch(response: { ok: boolean; status?: number; data?: unknown }) { +function mockAuthFetch(response: { + ok: boolean; + status?: number; + data?: unknown; +}) { return vi.fn((url: string) => Promise.resolve( url.includes("/asset/") - ? { ok: true, status: 200, blob: () => Promise.resolve(new Blob(["img"], { type: "image/webp" })) } + ? { + ok: true, + status: 200, + blob: () => + Promise.resolve(new Blob(["img"], { type: "image/webp" })), + } : { ok: response.ok, status: response.status ?? (response.ok ? 200 : 400), @@ -41,18 +82,37 @@ function mockAuthFetch(response: { ok: boolean; status?: number; data?: unknown function makeCut(overrides: Record = {}) { return { - id: 1, shotType: "medium", description: "Test scene", - characters: [], dialogue: [], narration: "", sfx: "", - cleanImagePath: null, finalImagePath: null, - exportedAt: null, uploadedCid: null, uploadedUrl: null, overlays: [], + id: 1, + shotType: "medium", + description: "Test scene", + characters: [], + dialogue: [], + narration: "", + sfx: "", + cleanImagePath: null, + finalImagePath: null, + exportedAt: null, + uploadedCid: null, + uploadedUrl: null, + overlays: [], ...overrides, }; } describe("CutListPanel", () => { it("shows empty state when no cuts file", async () => { - const authFetch = mockAuthFetch({ ok: false, status: 404, data: { error: "Not found" } }); - render(); + const authFetch = mockAuthFetch({ + ok: false, + status: 404, + data: { error: "Not found" }, + }); + render( + , + ); await waitFor(() => { expect(screen.getByText("No cuts yet")).toBeInTheDocument(); @@ -61,11 +121,18 @@ describe("CutListPanel", () => { it("shows missing status for cut without clean image", async () => { const cutsData = { - version: 1, plotFile: "plot-01", + version: 1, + plotFile: "plot-01", cuts: [makeCut({ id: 1, cleanImagePath: null })], }; const authFetch = mockAuthFetch({ ok: true, data: cutsData }); - render(); + render( + , + ); await waitFor(() => { expect(screen.getByText("Needs image")).toBeInTheDocument(); @@ -75,11 +142,20 @@ describe("CutListPanel", () => { it("shows clean status for cut with clean image", async () => { const cutsData = { - version: 1, plotFile: "plot-01", - cuts: [makeCut({ id: 1, cleanImagePath: "assets/plot-01/cut-01-clean.webp" })], + version: 1, + plotFile: "plot-01", + cuts: [ + makeCut({ id: 1, cleanImagePath: "assets/plot-01/cut-01-clean.webp" }), + ], }; const authFetch = mockAuthFetch({ ok: true, data: cutsData }); - render(); + render( + , + ); await waitFor(() => { expect(screen.getByText("Ready for lettering")).toBeInTheDocument(); @@ -89,29 +165,49 @@ describe("CutListPanel", () => { it("shows lettered status for cut with finalImagePath", async () => { const cutsData = { - version: 1, plotFile: "plot-01", - cuts: [makeCut({ - id: 1, - cleanImagePath: "assets/plot-01/cut-01-clean.webp", - finalImagePath: "assets/plot-01/cut-01-final.webp", - })], + version: 1, + plotFile: "plot-01", + cuts: [ + makeCut({ + id: 1, + cleanImagePath: "assets/plot-01/cut-01-clean.webp", + finalImagePath: "assets/plot-01/cut-01-final.webp", + }), + ], }; const authFetch = mockAuthFetch({ ok: true, data: cutsData }); - render(); + render( + , + ); await waitFor(() => { - expect(screen.getByTestId("cut-card-status-1")).toHaveTextContent("Exported"); + expect(screen.getByTestId("cut-card-status-1")).toHaveTextContent( + "Exported", + ); expect(screen.getByText("1 lettered")).toBeInTheDocument(); }); }); it("shows uploaded status for cut with uploadedCid", async () => { const cutsData = { - version: 1, plotFile: "plot-01", - cuts: [makeCut({ id: 1, uploadedCid: "QmTest", cleanImagePath: "x.webp" })], + version: 1, + plotFile: "plot-01", + cuts: [ + makeCut({ id: 1, uploadedCid: "QmTest", cleanImagePath: "x.webp" }), + ], }; const authFetch = mockAuthFetch({ ok: true, data: cutsData }); - render(); + render( + , + ); await waitFor(() => { expect(screen.getByText("Uploaded")).toBeInTheDocument(); @@ -121,11 +217,18 @@ describe("CutListPanel", () => { it("expands cut to show upload button", async () => { const cutsData = { - version: 1, plotFile: "plot-01", + version: 1, + plotFile: "plot-01", cuts: [makeCut({ id: 1, description: "Wide city shot" })], }; const authFetch = mockAuthFetch({ ok: true, data: cutsData }); - render(); + render( + , + ); await waitFor(() => { expect(screen.getByText("Wide city shot")).toBeInTheDocument(); @@ -140,21 +243,32 @@ describe("CutListPanel", () => { it("keeps later cuts selectable after another row is expanded", async () => { const cutsData = { - version: 1, plotFile: "plot-01", - cuts: Array.from({ length: 10 }, (_, i) => makeCut({ - id: i + 1, - description: `Cut ${i + 1} scene`, - cleanImagePath: `assets/plot-01/cut-${String(i + 1).padStart(2, "0")}-clean.webp`, - })), + version: 1, + plotFile: "plot-01", + cuts: Array.from({ length: 10 }, (_, i) => + makeCut({ + id: i + 1, + description: `Cut ${i + 1} scene`, + cleanImagePath: `assets/plot-01/cut-${String(i + 1).padStart(2, "0")}-clean.webp`, + }), + ), }; const authFetch = mockAuthFetch({ ok: true, data: cutsData }); - render(); + render( + , + ); const scroll = await screen.findByTestId("lettering-review-board"); expect(scroll).toHaveClass("min-h-56"); fireEvent.click(screen.getByText("Cut 1 scene")); - await waitFor(() => expect(screen.getByTestId("open-editor-1")).toBeInTheDocument()); + await waitFor(() => + expect(screen.getByTestId("open-editor-1")).toBeInTheDocument(), + ); fireEvent.click(screen.getByText("Cut 9 scene")); @@ -166,13 +280,22 @@ describe("CutListPanel", () => { it("shows the clean-image handoff helper and Copy prompt button for a cut with no clean image", async () => { const cutsData = { - version: 1, plotFile: "plot-01", + version: 1, + plotFile: "plot-01", cuts: [makeCut({ id: 1, description: "Wide city shot" })], }; const authFetch = mockAuthFetch({ ok: true, data: cutsData }); - render(); + render( + , + ); - await waitFor(() => expect(screen.getByText("Wide city shot")).toBeInTheDocument()); + await waitFor(() => + expect(screen.getByText("Wide city shot")).toBeInTheDocument(), + ); fireEvent.click(screen.getByText("Wide city shot")); await waitFor(() => { @@ -193,20 +316,39 @@ describe("CutListPanel", () => { it("Copy prompt copies an actionable Codex task (output path + create-file + visual prompt)", async () => { const cutsData = { - version: 1, plotFile: "plot-01", - cuts: [makeCut({ id: 1, shotType: "wide", description: "Rainy alley", characters: ["Mira"] })], + version: 1, + plotFile: "plot-01", + cuts: [ + makeCut({ + id: 1, + shotType: "wide", + description: "Rainy alley", + characters: ["Mira"], + }), + ], }; const authFetch = mockAuthFetch({ ok: true, data: cutsData }); - render(); + render( + , + ); - await waitFor(() => expect(screen.getByText("Rainy alley")).toBeInTheDocument()); + await waitFor(() => + expect(screen.getByText("Rainy alley")).toBeInTheDocument(), + ); fireEvent.click(screen.getByText("Rainy alley")); - await waitFor(() => expect(screen.getByTestId("copy-prompt-1")).toBeInTheDocument()); + await waitFor(() => + expect(screen.getByTestId("copy-prompt-1")).toBeInTheDocument(), + ); fireEvent.click(screen.getByTestId("copy-prompt-1")); expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1); - const copied = (navigator.clipboard.writeText as ReturnType).mock.calls[0][0] as string; + const copied = (navigator.clipboard.writeText as ReturnType) + .mock.calls[0][0] as string; // Actionable Codex task content (the #267 deliverable, updated for #403): expect(copied).toContain("assets/plot-01/cut-01-clean.webp"); expect(copied).toContain("Produce the actual image"); @@ -218,22 +360,43 @@ describe("CutListPanel", () => { expect(copied).toContain("Wide shot. Rainy alley"); expect(copied).toContain("Characters: Mira."); expect(copied).toContain("No speech bubbles"); - await waitFor(() => expect(screen.getByText("Copied!")).toBeInTheDocument()); + await waitFor(() => + expect(screen.getByText("Copied!")).toBeInTheDocument(), + ); }); it("does not show the handoff helper once a clean image exists", async () => { const cutsData = { - version: 1, plotFile: "plot-01", - cuts: [makeCut({ id: 1, cleanImagePath: "assets/plot-01/cut-01-clean.webp", description: "Has image" })], + version: 1, + plotFile: "plot-01", + cuts: [ + makeCut({ + id: 1, + cleanImagePath: "assets/plot-01/cut-01-clean.webp", + description: "Has image", + }), + ], }; const authFetch = mockAuthFetch({ ok: true, data: cutsData }); - render(); + render( + , + ); - await waitFor(() => expect(screen.getByText("Has image")).toBeInTheDocument()); + await waitFor(() => + expect(screen.getByText("Has image")).toBeInTheDocument(), + ); fireEvent.click(screen.getByText("Has image")); - await waitFor(() => expect(screen.getByTestId("copy-prompt-1")).toBeInTheDocument()); - expect(screen.queryByTestId("clean-image-handoff-1")).not.toBeInTheDocument(); + await waitFor(() => + expect(screen.getByTestId("copy-prompt-1")).toBeInTheDocument(), + ); + expect( + screen.queryByTestId("clean-image-handoff-1"), + ).not.toBeInTheDocument(); }); it("renders the expanded clean-image preview via an authFetch blob URL, not the raw protected URL", async () => { @@ -241,27 +404,57 @@ describe("CutListPanel", () => { // , which can't carry the bearer // header and 401s. It must load through authFetch like the other surfaces. const cutsData = { - version: 1, plotFile: "plot-01", - cuts: [makeCut({ id: 1, cleanImagePath: "assets/plot-01/cut-01-clean.webp", description: "Has image" })], + version: 1, + plotFile: "plot-01", + cuts: [ + makeCut({ + id: 1, + cleanImagePath: "assets/plot-01/cut-01-clean.webp", + description: "Has image", + }), + ], }; const authFetch = mockAuthFetch({ ok: true, data: cutsData }); - render(); + render( + , + ); - await waitFor(() => expect(screen.getByText("Has image")).toBeInTheDocument()); + await waitFor(() => + expect(screen.getByText("Has image")).toBeInTheDocument(), + ); // The artwork preview now lives in the always-visible card head (#440). const img = await screen.findByAltText("Cut 1 artwork"); expect(img).toHaveAttribute("src", MOCK_BLOB_URL); expect(img.getAttribute("src")).not.toContain("/api/stories/"); - expect(authFetch).toHaveBeenCalledWith("/api/stories/story/asset/plot-01/cut-01-clean.webp"); + expect(authFetch).toHaveBeenCalledWith( + "/api/stories/story/asset/plot-01/cut-01-clean.webp", + ); }); it("shows replace button when clean image exists", async () => { const cutsData = { - version: 1, plotFile: "plot-01", - cuts: [makeCut({ id: 1, cleanImagePath: "assets/plot-01/cut-01-clean.webp", description: "Scene" })], + version: 1, + plotFile: "plot-01", + cuts: [ + makeCut({ + id: 1, + cleanImagePath: "assets/plot-01/cut-01-clean.webp", + description: "Scene", + }), + ], }; const authFetch = mockAuthFetch({ ok: true, data: cutsData }); - render(); + render( + , + ); await waitFor(() => expect(screen.getByText("Scene")).toBeInTheDocument()); fireEvent.click(screen.getByText("Scene")); @@ -273,24 +466,60 @@ describe("CutListPanel", () => { it("calls upload endpoint when file is selected", async () => { const cutsData = { - version: 1, plotFile: "plot-01", + version: 1, + plotFile: "plot-01", cuts: [makeCut({ id: 1, description: "Upload test" })], }; - const authFetch = vi.fn() - .mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve(cutsData) }) - .mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ ok: true, cleanImagePath: "assets/plot-01/cut-01-clean.webp" }) }) - .mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ - ...cutsData, - cuts: [{ ...cutsData.cuts[0], cleanImagePath: "assets/plot-01/cut-01-clean.webp" }], - }) }); + const authFetch = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve(cutsData), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + ok: true, + cleanImagePath: "assets/plot-01/cut-01-clean.webp", + }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + ...cutsData, + cuts: [ + { + ...cutsData.cuts[0], + cleanImagePath: "assets/plot-01/cut-01-clean.webp", + }, + ], + }), + }); - render(); + render( + , + ); - await waitFor(() => expect(screen.getByText("Upload test")).toBeInTheDocument()); + await waitFor(() => + expect(screen.getByText("Upload test")).toBeInTheDocument(), + ); fireEvent.click(screen.getByText("Upload test")); - await waitFor(() => expect(screen.getByText("Upload clean image")).toBeInTheDocument()); + await waitFor(() => + expect(screen.getByText("Upload clean image")).toBeInTheDocument(), + ); - const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + const fileInput = document.querySelector( + 'input[type="file"]', + ) as HTMLInputElement; const file = new File(["img"], "test.webp", { type: "image/webp" }); fireEvent.change(fileInput, { target: { files: [file] } }); @@ -304,43 +533,95 @@ describe("CutListPanel", () => { it("passes language to editor for non-English cartoon", async () => { const cutsData = { - version: 1, plotFile: "plot-01", - cuts: [makeCut({ id: 1, cleanImagePath: "assets/plot-01/cut-01-clean.webp", description: "Korean scene", overlays: [{ - id: "kr-overlay", type: "speech", x: 0.1, y: 0.1, width: 0.25, height: 0.12, - text: "안녕", speaker: "주인공", - }] })], + version: 1, + plotFile: "plot-01", + cuts: [ + makeCut({ + id: 1, + cleanImagePath: "assets/plot-01/cut-01-clean.webp", + description: "Korean scene", + overlays: [ + { + id: "kr-overlay", + type: "speech", + x: 0.1, + y: 0.1, + width: 0.25, + height: 0.12, + text: "안녕", + speaker: "주인공", + }, + ], + }), + ], }; const authFetch = mockAuthFetch({ ok: true, data: cutsData }); - render(); + render( + , + ); - await waitFor(() => expect(screen.getByText("Korean scene")).toBeInTheDocument()); + await waitFor(() => + expect(screen.getByText("Korean scene")).toBeInTheDocument(), + ); fireEvent.click(screen.getByText("Korean scene")); - await waitFor(() => expect(screen.getByText("Open editor")).toBeInTheDocument()); + await waitFor(() => + expect(screen.getByText("Open editor")).toBeInTheDocument(), + ); fireEvent.click(screen.getByText("Open editor")); // Simulate image load in editor — the clean image loads asynchronously via // authFetch -> blob -> object URL, so await the before firing load. const img = await screen.findByRole("img"); - Object.defineProperty(img, "naturalWidth", { value: 800, configurable: true }); - Object.defineProperty(img, "naturalHeight", { value: 600, configurable: true }); - act(() => { fireEvent.load(img); }); + Object.defineProperty(img, "naturalWidth", { + value: 800, + configurable: true, + }); + Object.defineProperty(img, "naturalHeight", { + value: 600, + configurable: true, + }); + act(() => { + fireEvent.load(img); + }); // Click the overlay to see inspector font - await waitFor(() => expect(screen.getByTestId("overlay-kr-overlay")).toBeInTheDocument()); + await waitFor(() => + expect(screen.getByTestId("overlay-kr-overlay")).toBeInTheDocument(), + ); fireEvent.click(screen.getByTestId("overlay-kr-overlay")); await waitFor(() => { - expect(screen.getByTestId("inspector-font")).toHaveTextContent("Noto Sans KR"); + expect(screen.getByTestId("inspector-font")).toHaveTextContent( + "Noto Sans KR", + ); }); }); it("shows Upload & Generate button when cuts have final images", async () => { const cutsData = { - version: 1, plotFile: "plot-01", - cuts: [makeCut({ id: 1, finalImagePath: "assets/plot-01/cut-01-final.webp", overlays: [] })], + version: 1, + plotFile: "plot-01", + cuts: [ + makeCut({ + id: 1, + finalImagePath: "assets/plot-01/cut-01-final.webp", + overlays: [], + }), + ], }; const authFetch = mockAuthFetch({ ok: true, data: cutsData }); - render(); + render( + , + ); await waitFor(() => { expect(screen.getByTestId("upload-generate-btn")).toBeInTheDocument(); @@ -350,11 +631,26 @@ describe("CutListPanel", () => { it("disables Upload & Generate when all cuts are already uploaded", async () => { const cutsData = { - version: 1, plotFile: "plot-01", - cuts: [makeCut({ id: 1, finalImagePath: "x.webp", uploadedCid: "QmDone", uploadedUrl: "https://done", overlays: [] })], + version: 1, + plotFile: "plot-01", + cuts: [ + makeCut({ + id: 1, + finalImagePath: "x.webp", + uploadedCid: "QmDone", + uploadedUrl: "https://done", + overlays: [], + }), + ], }; const authFetch = mockAuthFetch({ ok: true, data: cutsData }); - render(); + render( + , + ); await waitFor(() => { expect(screen.getByTestId("upload-generate-btn")).toBeDisabled(); @@ -363,11 +659,24 @@ describe("CutListPanel", () => { it("Upload & Prepare button exposes no markdown/MD jargon in its label or hover title (#360)", async () => { const cutsData = { - version: 1, plotFile: "plot-01", - cuts: [makeCut({ id: 1, finalImagePath: "assets/plot-01/cut-01-final.webp", overlays: [] })], + version: 1, + plotFile: "plot-01", + cuts: [ + makeCut({ + id: 1, + finalImagePath: "assets/plot-01/cut-01-final.webp", + overlays: [], + }), + ], }; const authFetch = mockAuthFetch({ ok: true, data: cutsData }); - render(); + render( + , + ); const btn = await screen.findByTestId("upload-generate-btn"); const jargon = /markdown|\bMD\b|Generate MD/i; @@ -377,92 +686,258 @@ describe("CutListPanel", () => { it("Upload & Prepare progress copy uses creator-facing language, not markdown jargon (#360)", async () => { const cutsData = { - version: 1, plotFile: "plot-01", - cuts: [makeCut({ id: 1, finalImagePath: "assets/plot-01/cut-01-final.webp", overlays: [] })], + version: 1, + plotFile: "plot-01", + cuts: [ + makeCut({ + id: 1, + finalImagePath: "assets/plot-01/cut-01-final.webp", + overlays: [], + }), + ], }; // Hold generate-markdown unresolved so the "Preparing…" progress copy stays rendered. let releaseMd: () => void = () => {}; - const mdGate = new Promise<{ ok: boolean; status: number; json: () => Promise }>((resolve) => { - releaseMd = () => resolve({ ok: true, status: 200, json: () => Promise.resolve({ ok: true, warnings: [] }) }); + const mdGate = new Promise<{ + ok: boolean; + status: number; + json: () => Promise; + }>((resolve) => { + releaseMd = () => + resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ ok: true, warnings: [] }), + }); }); const authFetch = vi.fn((url: string) => { - if (url.endsWith("/detect-clean-images")) return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ detected: [] }) }); - if (url.includes("/asset/")) return Promise.resolve({ ok: true, status: 200, blob: () => Promise.resolve(new Blob([new Uint8Array(10)], { type: "image/webp" })) }); - if (url === "/api/publish/upload-plot-image") return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ cid: "QmNewCid123", url: "https://ipfs.example.com/QmNewCid123" }) }); - if (url.includes("set-uploaded")) return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ ok: true }) }); + if (url.endsWith("/detect-clean-images")) + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ detected: [] }), + }); + if (url.includes("/asset/")) + return Promise.resolve({ + ok: true, + status: 200, + blob: () => + Promise.resolve( + new Blob([new Uint8Array(10)], { type: "image/webp" }), + ), + }); + if (url === "/api/publish/upload-plot-image") + return Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + cid: "QmNewCid123", + url: "https://ipfs.example.com/QmNewCid123", + }), + }); + if (url.includes("set-uploaded")) + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ ok: true }), + }); if (url.includes("generate-markdown")) return mdGate; - return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(cutsData) }); + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve(cutsData), + }); }); - render(); + render( + , + ); fireEvent.click(await screen.findByTestId("upload-generate-btn")); // While generate-markdown is in flight, the progress copy is shown in the button. await waitFor(() => { - expect(screen.getByTestId("upload-generate-btn").textContent || "").toMatch(/Preparing episode for publishing/i); + expect( + screen.getByTestId("upload-generate-btn").textContent || "", + ).toMatch(/Preparing episode for publishing/i); }); - expect(screen.getByTestId("upload-generate-btn").textContent || "").not.toMatch(/markdown|\bMD\b/i); + expect( + screen.getByTestId("upload-generate-btn").textContent || "", + ).not.toMatch(/markdown|\bMD\b/i); releaseMd(); }); it("Upload & Generate calls upload-plot-image, forwards CID to set-uploaded, then generate-markdown", async () => { const cutsData = { - version: 1, plotFile: "plot-01", - cuts: [makeCut({ id: 1, finalImagePath: "assets/plot-01/cut-01-final.webp", overlays: [] })], + version: 1, + plotFile: "plot-01", + cuts: [ + makeCut({ + id: 1, + finalImagePath: "assets/plot-01/cut-01-final.webp", + overlays: [], + }), + ], }; // URL-aware mock (order-independent: a detect-clean-images fetch also fires on mount). const authFetch = vi.fn((url: string) => { - if (url.endsWith("/detect-clean-images")) return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ detected: [] }) }); - if (url.includes("/asset/")) return Promise.resolve({ ok: true, status: 200, blob: () => Promise.resolve(new Blob([new Uint8Array(10)], { type: "image/webp" })) }); - if (url === "/api/publish/upload-plot-image") return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ cid: "QmNewCid123", url: "https://ipfs.example.com/QmNewCid123" }) }); - if (url.includes("set-uploaded")) return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ ok: true }) }); - if (url.includes("generate-markdown")) return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ ok: true, warnings: [] }) }); - return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(cutsData) }); + if (url.endsWith("/detect-clean-images")) + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ detected: [] }), + }); + if (url.includes("/asset/")) + return Promise.resolve({ + ok: true, + status: 200, + blob: () => + Promise.resolve( + new Blob([new Uint8Array(10)], { type: "image/webp" }), + ), + }); + if (url === "/api/publish/upload-plot-image") + return Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + cid: "QmNewCid123", + url: "https://ipfs.example.com/QmNewCid123", + }), + }); + if (url.includes("set-uploaded")) + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ ok: true }), + }); + if (url.includes("generate-markdown")) + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ ok: true, warnings: [] }), + }); + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve(cutsData), + }); }); - render(); + render( + , + ); - await waitFor(() => expect(screen.getByTestId("upload-generate-btn")).toBeInTheDocument()); + await waitFor(() => + expect(screen.getByTestId("upload-generate-btn")).toBeInTheDocument(), + ); fireEvent.click(screen.getByTestId("upload-generate-btn")); await waitFor(() => { const calls = authFetch.mock.calls; const urls = calls.map((c: [string]) => c[0]); - expect(urls).toContain("/api/stories/story/asset/plot-01/cut-01-final.webp"); - expect(urls.some((u: string) => u === "/api/publish/upload-plot-image")).toBe(true); + expect(urls).toContain( + "/api/stories/story/asset/plot-01/cut-01-final.webp", + ); + expect( + urls.some((u: string) => u === "/api/publish/upload-plot-image"), + ).toBe(true); - const setUploadedCall = calls.find((c: [string, RequestInit?]) => typeof c[0] === "string" && c[0].includes("set-uploaded")); + const setUploadedCall = calls.find( + (c: [string, RequestInit?]) => + typeof c[0] === "string" && c[0].includes("set-uploaded"), + ); expect(setUploadedCall).toBeTruthy(); const setUploadedBody = JSON.parse(setUploadedCall![1]?.body as string); expect(setUploadedBody.cid).toBe("QmNewCid123"); expect(setUploadedBody.url).toBe("https://ipfs.example.com/QmNewCid123"); - expect(urls.some((u: string) => u.includes("generate-markdown"))).toBe(true); + expect(urls.some((u: string) => u.includes("generate-markdown"))).toBe( + true, + ); }); }); it("retries a rate-limited cut upload then records it (no batch failure) (#288)", async () => { const cutsData = { - version: 1, plotFile: "plot-01", - cuts: [makeCut({ id: 1, finalImagePath: "assets/plot-01/cut-01-final.webp", overlays: [] })], + version: 1, + plotFile: "plot-01", + cuts: [ + makeCut({ + id: 1, + finalImagePath: "assets/plot-01/cut-01-final.webp", + overlays: [], + }), + ], }; let uploadCalls = 0; const authFetch = vi.fn((url: string) => { - if (url.endsWith("/detect-clean-images")) return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ detected: [] }) }); - if (url.includes("/asset/")) return Promise.resolve({ ok: true, status: 200, blob: () => Promise.resolve(new Blob([new Uint8Array(10)], { type: "image/webp" })) }); + if (url.endsWith("/detect-clean-images")) + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ detected: [] }), + }); + if (url.includes("/asset/")) + return Promise.resolve({ + ok: true, + status: 200, + blob: () => + Promise.resolve( + new Blob([new Uint8Array(10)], { type: "image/webp" }), + ), + }); if (url === "/api/publish/upload-plot-image") { uploadCalls += 1; // First attempt is rate-limited (the OWS route forwards PlotLink's // limit as a 500 with the upstream message); the retry succeeds. if (uploadCalls === 1) { - return Promise.resolve({ ok: false, status: 500, json: () => Promise.resolve({ error: "Rate limit exceeded. Max 5 uploads per minute." }) }); + return Promise.resolve({ + ok: false, + status: 500, + json: () => + Promise.resolve({ + error: "Rate limit exceeded. Max 5 uploads per minute.", + }), + }); } - return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ cid: "QmRetryOk", url: "https://ipfs.example.com/QmRetryOk" }) }); + return Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + cid: "QmRetryOk", + url: "https://ipfs.example.com/QmRetryOk", + }), + }); } - if (url.includes("set-uploaded")) return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ ok: true }) }); - if (url.includes("generate-markdown")) return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ ok: true, warnings: [] }) }); - return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(cutsData) }); + if (url.includes("set-uploaded")) + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ ok: true }), + }); + if (url.includes("generate-markdown")) + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ ok: true, warnings: [] }), + }); + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve(cutsData), + }); }); render( @@ -474,7 +949,9 @@ describe("CutListPanel", () => { />, ); - await waitFor(() => expect(screen.getByTestId("upload-generate-btn")).toBeInTheDocument()); + await waitFor(() => + expect(screen.getByTestId("upload-generate-btn")).toBeInTheDocument(), + ); fireEvent.click(screen.getByTestId("upload-generate-btn")); await waitFor(() => { @@ -482,24 +959,65 @@ describe("CutListPanel", () => { // successful retry's CID, then markdown generated — batch did not fail. expect(uploadCalls).toBe(2); const calls = authFetch.mock.calls; - const setUploadedCall = calls.find((c: [string, RequestInit?]) => typeof c[0] === "string" && c[0].includes("set-uploaded")); + const setUploadedCall = calls.find( + (c: [string, RequestInit?]) => + typeof c[0] === "string" && c[0].includes("set-uploaded"), + ); expect(setUploadedCall).toBeTruthy(); - expect(JSON.parse(setUploadedCall![1]?.body as string).cid).toBe("QmRetryOk"); - expect(calls.some((c: [string]) => c[0].includes("generate-markdown"))).toBe(true); + expect(JSON.parse(setUploadedCall![1]?.body as string).cid).toBe( + "QmRetryOk", + ); + expect( + calls.some((c: [string]) => c[0].includes("generate-markdown")), + ).toBe(true); }); }); it("reports the affected cut and reason when rate-limit retries are exhausted (#288)", async () => { const cutsData = { - version: 1, plotFile: "plot-01", - cuts: [makeCut({ id: 3, finalImagePath: "assets/plot-01/cut-03-final.webp", overlays: [] })], + version: 1, + plotFile: "plot-01", + cuts: [ + makeCut({ + id: 3, + finalImagePath: "assets/plot-01/cut-03-final.webp", + overlays: [], + }), + ], }; let uploadCalls = 0; const authFetch = vi.fn((url: string) => { - if (url.endsWith("/detect-clean-images")) return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ detected: [] }) }); - if (url.includes("/asset/")) return Promise.resolve({ ok: true, status: 200, blob: () => Promise.resolve(new Blob([new Uint8Array(10)], { type: "image/webp" })) }); - if (url === "/api/publish/upload-plot-image") { uploadCalls += 1; return Promise.resolve({ ok: false, status: 429, json: () => Promise.resolve({ error: "Rate limit exceeded. Max 5 uploads per minute." }) }); } - return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(cutsData) }); + if (url.endsWith("/detect-clean-images")) + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ detected: [] }), + }); + if (url.includes("/asset/")) + return Promise.resolve({ + ok: true, + status: 200, + blob: () => + Promise.resolve( + new Blob([new Uint8Array(10)], { type: "image/webp" }), + ), + }); + if (url === "/api/publish/upload-plot-image") { + uploadCalls += 1; + return Promise.resolve({ + ok: false, + status: 429, + json: () => + Promise.resolve({ + error: "Rate limit exceeded. Max 5 uploads per minute.", + }), + }); + } + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve(cutsData), + }); }); render( @@ -507,42 +1025,92 @@ describe("CutListPanel", () => { storyName="story" fileName="plot-01.md" authFetch={authFetch} - uploadRetry={{ sleep: () => Promise.resolve(), baseDelayMs: 0, maxRetries: 2 }} + uploadRetry={{ + sleep: () => Promise.resolve(), + baseDelayMs: 0, + maxRetries: 2, + }} />, ); - await waitFor(() => expect(screen.getByTestId("upload-generate-btn")).toBeInTheDocument()); + await waitFor(() => + expect(screen.getByTestId("upload-generate-btn")).toBeInTheDocument(), + ); fireEvent.click(screen.getByTestId("upload-generate-btn")); await waitFor(() => { // Affected cut number + the rate-limit reason are surfaced; markdown is NOT generated. - const warning = screen.getByText(/Cut 3: upload failed — Rate limit exceeded/); + const warning = screen.getByText( + /Cut 3: upload failed — Rate limit exceeded/, + ); expect(warning).toBeInTheDocument(); expect(uploadCalls).toBe(3); // initial + 2 retries before giving up - expect(authFetch.mock.calls.some((c: [string]) => c[0].includes("generate-markdown"))).toBe(false); + expect( + authFetch.mock.calls.some((c: [string]) => + c[0].includes("generate-markdown"), + ), + ).toBe(false); }); }); it("completes a 7-cut batch upload under the 5/min limit without manual waiting (#413)", async () => { const cuts = Array.from({ length: 7 }, (_, i) => - makeCut({ id: i + 1, finalImagePath: `assets/plot-01/cut-0${i + 1}-final.webp`, overlays: [] }), + makeCut({ + id: i + 1, + finalImagePath: `assets/plot-01/cut-0${i + 1}-final.webp`, + overlays: [], + }), ); const cutsData = { version: 1, plotFile: "plot-01", cuts }; let uploadCalls = 0; const setUploadedIds: number[] = []; const authFetch = vi.fn((url: string) => { - if (url.endsWith("/detect-clean-images")) return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ detected: [] }) }); - if (url.includes("/asset/")) return Promise.resolve({ ok: true, status: 200, blob: () => Promise.resolve(new Blob([new Uint8Array(10)], { type: "image/webp" })) }); + if (url.endsWith("/detect-clean-images")) + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ detected: [] }), + }); + if (url.includes("/asset/")) + return Promise.resolve({ + ok: true, + status: 200, + blob: () => + Promise.resolve( + new Blob([new Uint8Array(10)], { type: "image/webp" }), + ), + }); if (url === "/api/publish/upload-plot-image") { uploadCalls += 1; - return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ cid: `Qm${uploadCalls}`, url: `https://ipfs.example.com/Qm${uploadCalls}` }) }); + return Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + cid: `Qm${uploadCalls}`, + url: `https://ipfs.example.com/Qm${uploadCalls}`, + }), + }); } if (url.includes("set-uploaded")) { setUploadedIds.push(Number(url.split("set-uploaded/")[1])); - return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ ok: true }) }); + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ ok: true }), + }); } - if (url.includes("generate-markdown")) return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ ok: true, warnings: [] }) }); - return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(cutsData) }); + if (url.includes("generate-markdown")) + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ ok: true, warnings: [] }), + }); + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve(cutsData), + }); }); render( @@ -557,31 +1125,111 @@ describe("CutListPanel", () => { />, ); - await waitFor(() => expect(screen.getByTestId("upload-generate-btn")).toBeInTheDocument()); + await waitFor(() => + expect(screen.getByTestId("upload-generate-btn")).toBeInTheDocument(), + ); fireEvent.click(screen.getByTestId("upload-generate-btn")); await waitFor(() => { expect(uploadCalls).toBe(7); - expect(setUploadedIds.sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5, 6, 7]); - expect(authFetch.mock.calls.some((c: [string]) => c[0].includes("generate-markdown"))).toBe(true); + expect(setUploadedIds.sort((a, b) => a - b)).toEqual([ + 1, 2, 3, 4, 5, 6, 7, + ]); + expect( + authFetch.mock.calls.some((c: [string]) => + c[0].includes("generate-markdown"), + ), + ).toBe(true); }); }); it("guided Finish episode panel: the primary button uploads finals then prepares markdown (#414)", async () => { const cuts = [ - makeCut({ id: 1, cleanImagePath: "assets/plot-01/cut-01-clean.webp", finalImagePath: "assets/plot-01/cut-01-final.webp", exportedAt: "t", overlays: [{ id: "o", type: "speech", x: 0.1, y: 0.1, width: 0.2, height: 0.1, text: "hi" }] }), - makeCut({ id: 2, cleanImagePath: "assets/plot-01/cut-02-clean.webp", finalImagePath: "assets/plot-01/cut-02-final.webp", exportedAt: "t", overlays: [{ id: "o", type: "speech", x: 0.1, y: 0.1, width: 0.2, height: 0.1, text: "ho" }] }), + makeCut({ + id: 1, + cleanImagePath: "assets/plot-01/cut-01-clean.webp", + finalImagePath: "assets/plot-01/cut-01-final.webp", + exportedAt: "t", + overlays: [ + { + id: "o", + type: "speech", + x: 0.1, + y: 0.1, + width: 0.2, + height: 0.1, + text: "hi", + }, + ], + }), + makeCut({ + id: 2, + cleanImagePath: "assets/plot-01/cut-02-clean.webp", + finalImagePath: "assets/plot-01/cut-02-final.webp", + exportedAt: "t", + overlays: [ + { + id: "o", + type: "speech", + x: 0.1, + y: 0.1, + width: 0.2, + height: 0.1, + text: "ho", + }, + ], + }), ]; const cutsData = { version: 1, plotFile: "plot-01", cuts }; let uploadCalls = 0; let markdownCalled = false; const authFetch = vi.fn((url: string) => { - if (url.endsWith("/detect-clean-images")) return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ detected: [] }) }); - if (url.includes("/asset/")) return Promise.resolve({ ok: true, status: 200, blob: () => Promise.resolve(new Blob([new Uint8Array(10)], { type: "image/webp" })) }); - if (url === "/api/publish/upload-plot-image") { uploadCalls += 1; return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ cid: `Qm${uploadCalls}`, url: `https://x/Qm${uploadCalls}` }) }); } - if (url.includes("set-uploaded")) return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ ok: true }) }); - if (url.includes("generate-markdown")) { markdownCalled = true; return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ ok: true, warnings: [] }) }); } - return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(cutsData) }); + if (url.endsWith("/detect-clean-images")) + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ detected: [] }), + }); + if (url.includes("/asset/")) + return Promise.resolve({ + ok: true, + status: 200, + blob: () => + Promise.resolve( + new Blob([new Uint8Array(10)], { type: "image/webp" }), + ), + }); + if (url === "/api/publish/upload-plot-image") { + uploadCalls += 1; + return Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + cid: `Qm${uploadCalls}`, + url: `https://x/Qm${uploadCalls}`, + }), + }); + } + if (url.includes("set-uploaded")) + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ ok: true }), + }); + if (url.includes("generate-markdown")) { + markdownCalled = true; + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ ok: true, warnings: [] }), + }); + } + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve(cutsData), + }); }); render( @@ -595,8 +1243,12 @@ describe("CutListPanel", () => { // The guided panel renders with writer-language step status; "Upload final // images" is the current step (everything before it done, publish todo). - await waitFor(() => expect(screen.getByTestId("finish-episode-panel")).toBeInTheDocument()); - expect(screen.getByTestId("finish-step-upload").getAttribute("data-status")).toBe("current"); + await waitFor(() => + expect(screen.getByTestId("finish-episode-panel")).toBeInTheDocument(), + ); + expect( + screen.getByTestId("finish-step-upload").getAttribute("data-status"), + ).toBe("current"); fireEvent.click(screen.getByTestId("finish-episode-btn")); @@ -608,40 +1260,85 @@ describe("CutListPanel", () => { it("shows a Sync clean images button that POSTs sync-clean-images then reloads", async () => { const cutsData = { - version: 1, plotFile: "plot-01", + version: 1, + plotFile: "plot-01", cuts: [makeCut({ id: 1, description: "Sync scene" })], }; // URL-aware mock so the extra detect-clean-images fetch on mount/after-sync // does not disturb call ordering. const authFetch = vi.fn((url: string) => { - if (url.endsWith("/detect-clean-images")) return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ detected: [] }) }); - if (url.endsWith("/sync-clean-images")) return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ ok: true, changed: true, synced: [1], rejected: [] }) }); - return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(cutsData) }); + if (url.endsWith("/detect-clean-images")) + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ detected: [] }), + }); + if (url.endsWith("/sync-clean-images")) + return Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + ok: true, + changed: true, + synced: [1], + rejected: [], + }), + }); + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve(cutsData), + }); }); - render(); + render( + , + ); - await waitFor(() => expect(screen.getByTestId("sync-clean-btn")).toBeInTheDocument()); + await waitFor(() => + expect(screen.getByTestId("sync-clean-btn")).toBeInTheDocument(), + ); fireEvent.click(screen.getByTestId("sync-clean-btn")); await waitFor(() => { const urls = authFetch.mock.calls.map((c: [string]) => c[0]); - expect(urls.some((u: string) => u.includes("/sync-clean-images"))).toBe(true); + expect(urls.some((u: string) => u.includes("/sync-clean-images"))).toBe( + true, + ); // reload happened (GET cuts called at least twice total) - expect(urls.filter((u: string) => u === "/api/stories/story/cuts/plot-01").length).toBeGreaterThanOrEqual(2); + expect( + urls.filter((u: string) => u === "/api/stories/story/cuts/plot-01") + .length, + ).toBeGreaterThanOrEqual(2); }); - await waitFor(() => expect(screen.getByTestId("sync-result")).toHaveTextContent("Synced 1")); + await waitFor(() => + expect(screen.getByTestId("sync-result")).toHaveTextContent("Synced 1"), + ); }); it("missing cut shows Copy prompt, Ask Codex and Upload affordances", async () => { const cutsData = { - version: 1, plotFile: "plot-01", + version: 1, + plotFile: "plot-01", cuts: [makeCut({ id: 1, description: "Missing scene" })], }; const authFetch = mockAuthFetch({ ok: true, data: cutsData }); - render(); + render( + , + ); - await waitFor(() => expect(screen.getByText("Missing scene")).toBeInTheDocument()); + await waitFor(() => + expect(screen.getByText("Missing scene")).toBeInTheDocument(), + ); fireEvent.click(screen.getByText("Missing scene")); await waitFor(() => { @@ -660,22 +1357,49 @@ describe("CutListPanel", () => { it("does not show Ask Codex affordance once a clean image exists", async () => { const cutsData = { - version: 1, plotFile: "plot-01", - cuts: [makeCut({ id: 1, cleanImagePath: "assets/plot-01/cut-01-clean.webp", description: "Has clean" })], + version: 1, + plotFile: "plot-01", + cuts: [ + makeCut({ + id: 1, + cleanImagePath: "assets/plot-01/cut-01-clean.webp", + description: "Has clean", + }), + ], }; const authFetch = mockAuthFetch({ ok: true, data: cutsData }); - render(); + render( + , + ); - await waitFor(() => expect(screen.getByText("Has clean")).toBeInTheDocument()); + await waitFor(() => + expect(screen.getByText("Has clean")).toBeInTheDocument(), + ); fireEvent.click(screen.getByText("Has clean")); - await waitFor(() => expect(screen.getByTestId("copy-prompt-1")).toBeInTheDocument()); + await waitFor(() => + expect(screen.getByTestId("copy-prompt-1")).toBeInTheDocument(), + ); expect(screen.queryByTestId("ask-codex-1")).not.toBeInTheDocument(); }); it("shows error state on fetch failure", async () => { - const authFetch = mockAuthFetch({ ok: false, status: 400, data: { error: "Bad data" } }); - render(); + const authFetch = mockAuthFetch({ + ok: false, + status: 400, + data: { error: "Bad data" }, + }); + render( + , + ); await waitFor(() => { expect(screen.getByText("Bad data")).toBeInTheDocument(); @@ -684,8 +1408,20 @@ describe("CutListPanel", () => { }); it("shows actionable v1 schema guidance for invalid cuts (wrong schema)", async () => { - const authFetch = mockAuthFetch({ ok: false, status: 400, data: { error: "plot-01.cuts.json is invalid: Cut 0 has invalid shotType" } }); - render(); + const authFetch = mockAuthFetch({ + ok: false, + status: 400, + data: { + error: "plot-01.cuts.json is invalid: Cut 0 has invalid shotType", + }, + }); + render( + , + ); await waitFor(() => { expect(screen.getByTestId("cuts-error")).toBeInTheDocument(); @@ -696,8 +1432,18 @@ describe("CutListPanel", () => { }); it("shows actionable error for invalid JSON", async () => { - const authFetch = mockAuthFetch({ ok: false, status: 400, data: { error: "plot-01.cuts.json contains invalid JSON" } }); - render(); + const authFetch = mockAuthFetch({ + ok: false, + status: 400, + data: { error: "plot-01.cuts.json contains invalid JSON" }, + }); + render( + , + ); await waitFor(() => { expect(screen.getByText(/contains invalid JSON/)).toBeInTheDocument(); @@ -706,8 +1452,18 @@ describe("CutListPanel", () => { }); it("missing cuts file (404) shows No cuts, not an error", async () => { - const authFetch = mockAuthFetch({ ok: false, status: 404, data: { error: "Cuts file not found" } }); - render(); + const authFetch = mockAuthFetch({ + ok: false, + status: 404, + data: { error: "Cuts file not found" }, + }); + render( + , + ); await waitFor(() => { expect(screen.getByText("No cuts yet")).toBeInTheDocument(); @@ -720,44 +1476,88 @@ describe("CutListPanel", () => { return vi.fn((url: string) => { let data: unknown = {}; if (url.endsWith("/detect-clean-images")) data = { detected }; - else if (url.endsWith("/sync-clean-images")) data = { ok: true, changed: true, synced: detected, rejected: [] }; + else if (url.endsWith("/sync-clean-images")) + data = { ok: true, changed: true, synced: detected, rejected: [] }; else data = { version: 1, plotFile: "plot-01", cuts }; - return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(data) }); + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve(data), + }); }); } it("shows per-cut found-local-clean affordance when detect reports the cut id", async () => { - const authFetch = makeRouteFetch([makeCut({ id: 1, cleanImagePath: null })], [1]); - render(); + const authFetch = makeRouteFetch( + [makeCut({ id: 1, cleanImagePath: null })], + [1], + ); + render( + , + ); - await waitFor(() => expect(screen.getByText("Test scene")).toBeInTheDocument()); + await waitFor(() => + expect(screen.getByText("Test scene")).toBeInTheDocument(), + ); fireEvent.click(screen.getByText("Test scene")); await waitFor(() => { expect(screen.getByTestId("found-local-clean-1")).toBeInTheDocument(); - expect(screen.getByText("Found local clean image — sync to cut plan")).toBeInTheDocument(); + expect( + screen.getByText("Found local clean image — sync to cut plan"), + ).toBeInTheDocument(); }); }); it("does not show the found-local-clean affordance when detect returns empty", async () => { - const authFetch = makeRouteFetch([makeCut({ id: 1, cleanImagePath: null })], []); - render(); + const authFetch = makeRouteFetch( + [makeCut({ id: 1, cleanImagePath: null })], + [], + ); + render( + , + ); - await waitFor(() => expect(screen.getByText("Test scene")).toBeInTheDocument()); + await waitFor(() => + expect(screen.getByText("Test scene")).toBeInTheDocument(), + ); fireEvent.click(screen.getByText("Test scene")); // give detect a chance to resolve, then assert it is absent. - await waitFor(() => expect(screen.getByTestId("copy-prompt-1")).toBeInTheDocument()); + await waitFor(() => + expect(screen.getByTestId("copy-prompt-1")).toBeInTheDocument(), + ); expect(screen.queryByTestId("found-local-clean-1")).not.toBeInTheDocument(); }); it("clicking found-local-clean POSTs sync-clean-images and reloads cuts + detect", async () => { - const authFetch = makeRouteFetch([makeCut({ id: 1, cleanImagePath: null })], [1]); - render(); + const authFetch = makeRouteFetch( + [makeCut({ id: 1, cleanImagePath: null })], + [1], + ); + render( + , + ); - await waitFor(() => expect(screen.getByText("Test scene")).toBeInTheDocument()); + await waitFor(() => + expect(screen.getByText("Test scene")).toBeInTheDocument(), + ); fireEvent.click(screen.getByText("Test scene")); - await waitFor(() => expect(screen.getByTestId("found-local-clean-1")).toBeInTheDocument()); + await waitFor(() => + expect(screen.getByTestId("found-local-clean-1")).toBeInTheDocument(), + ); fireEvent.click(screen.getByTestId("found-local-clean-1")); @@ -770,8 +1570,13 @@ describe("CutListPanel", () => { await waitFor(() => { const urls = authFetch.mock.calls.map((c: [string]) => c[0]); // cuts reloaded and detect re-fetched after sync. - expect(urls.filter((u: string) => u === "/api/stories/story/cuts/plot-01").length).toBeGreaterThanOrEqual(2); - expect(urls.filter((u: string) => u.endsWith("/detect-clean-images")).length).toBeGreaterThanOrEqual(2); + expect( + urls.filter((u: string) => u === "/api/stories/story/cuts/plot-01") + .length, + ).toBeGreaterThanOrEqual(2); + expect( + urls.filter((u: string) => u.endsWith("/detect-clean-images")).length, + ).toBeGreaterThanOrEqual(2); }); }); @@ -780,95 +1585,257 @@ describe("CutListPanel", () => { describe("clean-assets-ready done state (#311)", () => { it("shows the done banner when every cut has a present clean image", async () => { const cutsData = { - version: 1, plotFile: "plot-01", + version: 1, + plotFile: "plot-01", cuts: [ - makeCut({ id: 1, cleanImagePath: "assets/plot-01/cut-01-clean.webp" }), - makeCut({ id: 2, cleanImagePath: "assets/plot-01/cut-02-clean.webp" }), + makeCut({ + id: 1, + cleanImagePath: "assets/plot-01/cut-01-clean.webp", + }), + makeCut({ + id: 2, + cleanImagePath: "assets/plot-01/cut-02-clean.webp", + }), ], }; const authFetch = mockAuthFetch({ ok: true, data: cutsData }); - render(); - await waitFor(() => expect(screen.getByTestId("clean-assets-ready")).toBeInTheDocument()); - expect(screen.getByTestId("clean-assets-ready")).toHaveTextContent("All 2 clean images present"); + render( + , + ); + await waitFor(() => + expect(screen.getByTestId("clean-assets-ready")).toBeInTheDocument(), + ); + expect(screen.getByTestId("clean-assets-ready")).toHaveTextContent( + "All 2 clean images present", + ); }); it("does not show the done banner while a cut is still missing its clean image", async () => { const cutsData = { - version: 1, plotFile: "plot-01", + version: 1, + plotFile: "plot-01", cuts: [ - makeCut({ id: 1, cleanImagePath: "assets/plot-01/cut-01-clean.webp" }), - makeCut({ id: 2, description: "Still missing", cleanImagePath: null }), + makeCut({ + id: 1, + cleanImagePath: "assets/plot-01/cut-01-clean.webp", + }), + makeCut({ + id: 2, + description: "Still missing", + cleanImagePath: null, + }), ], }; const authFetch = mockAuthFetch({ ok: true, data: cutsData }); - render(); - await waitFor(() => expect(screen.getByText("Still missing")).toBeInTheDocument()); - expect(screen.queryByTestId("clean-assets-ready")).not.toBeInTheDocument(); + render( + , + ); + await waitFor(() => + expect(screen.getByText("Still missing")).toBeInTheDocument(), + ); + expect( + screen.queryByTestId("clean-assets-ready"), + ).not.toBeInTheDocument(); }); it("does not show the done banner while detect-clean-images is still pending (#311 re1)", async () => { const cutsData = { - version: 1, plotFile: "plot-01", - cuts: [makeCut({ id: 1, description: "Pending verify", cleanImagePath: "assets/plot-01/cut-01-clean.webp" })], + version: 1, + plotFile: "plot-01", + cuts: [ + makeCut({ + id: 1, + description: "Pending verify", + cleanImagePath: "assets/plot-01/cut-01-clean.webp", + }), + ], }; const authFetch = vi.fn((url: string) => { if (url.includes("/detect-clean-images")) return new Promise(() => {}); // never resolves - if (url.includes("/asset/")) return Promise.resolve({ ok: true, status: 200, blob: () => Promise.resolve(new Blob(["x"], { type: "image/webp" })) }); - return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(cutsData) }); + if (url.includes("/asset/")) + return Promise.resolve({ + ok: true, + status: 200, + blob: () => + Promise.resolve(new Blob(["x"], { type: "image/webp" })), + }); + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve(cutsData), + }); }); - render(); - await waitFor(() => expect(screen.getByText("Pending verify")).toBeInTheDocument()); + render( + , + ); + await waitFor(() => + expect(screen.getByText("Pending verify")).toBeInTheDocument(), + ); // Cut-plan fields say "clean" but detection has not confirmed disk state yet. - expect(screen.queryByTestId("clean-assets-ready")).not.toBeInTheDocument(); + expect( + screen.queryByTestId("clean-assets-ready"), + ).not.toBeInTheDocument(); }); it("does not show the done banner when detect-clean-images fails (#311 re1)", async () => { const cutsData = { - version: 1, plotFile: "plot-01", - cuts: [makeCut({ id: 1, description: "Detect failed", cleanImagePath: "assets/plot-01/cut-01-clean.webp" })], + version: 1, + plotFile: "plot-01", + cuts: [ + makeCut({ + id: 1, + description: "Detect failed", + cleanImagePath: "assets/plot-01/cut-01-clean.webp", + }), + ], }; const authFetch = vi.fn((url: string) => { - if (url.includes("/detect-clean-images")) return Promise.resolve({ ok: false, status: 500, json: () => Promise.resolve({ error: "boom" }) }); - if (url.includes("/asset/")) return Promise.resolve({ ok: true, status: 200, blob: () => Promise.resolve(new Blob(["x"], { type: "image/webp" })) }); - return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(cutsData) }); + if (url.includes("/detect-clean-images")) + return Promise.resolve({ + ok: false, + status: 500, + json: () => Promise.resolve({ error: "boom" }), + }); + if (url.includes("/asset/")) + return Promise.resolve({ + ok: true, + status: 200, + blob: () => + Promise.resolve(new Blob(["x"], { type: "image/webp" })), + }); + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve(cutsData), + }); }); - render(); - await waitFor(() => expect(screen.getByText("Detect failed")).toBeInTheDocument()); + render( + , + ); + await waitFor(() => + expect(screen.getByText("Detect failed")).toBeInTheDocument(), + ); // Detection failed → unverified → no false "complete" signal. - expect(screen.queryByTestId("clean-assets-ready")).not.toBeInTheDocument(); + expect( + screen.queryByTestId("clean-assets-ready"), + ).not.toBeInTheDocument(); }); it("does not show the done banner when a recorded clean path is stale/missing on disk", async () => { const cutsData = { - version: 1, plotFile: "plot-01", - cuts: [makeCut({ id: 1, description: "Has recorded path", cleanImagePath: "assets/plot-01/cut-01-clean.webp" })], + version: 1, + plotFile: "plot-01", + cuts: [ + makeCut({ + id: 1, + description: "Has recorded path", + cleanImagePath: "assets/plot-01/cut-01-clean.webp", + }), + ], }; const authFetch = vi.fn((url: string) => { if (url.includes("/detect-clean-images")) { - return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ detected: [], stale: [{ cutId: 1, field: "cleanImagePath", path: "assets/plot-01/cut-01-clean.webp", message: "missing" }] }) }); + return Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + detected: [], + stale: [ + { + cutId: 1, + field: "cleanImagePath", + path: "assets/plot-01/cut-01-clean.webp", + message: "missing", + }, + ], + }), + }); } - if (url.includes("/asset/")) return Promise.resolve({ ok: true, status: 200, blob: () => Promise.resolve(new Blob(["x"], { type: "image/webp" })) }); - return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(cutsData) }); + if (url.includes("/asset/")) + return Promise.resolve({ + ok: true, + status: 200, + blob: () => + Promise.resolve(new Blob(["x"], { type: "image/webp" })), + }); + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve(cutsData), + }); }); - render(); - await waitFor(() => expect(screen.getByText("Has recorded path")).toBeInTheDocument()); + render( + , + ); + await waitFor(() => + expect(screen.getByText("Has recorded path")).toBeInTheDocument(), + ); // Stale recorded path reads as "Needs image" on the card (#440); the precise // repair stays under Open details. The done banner must not show. - await waitFor(() => expect(screen.getByTestId("cut-card-status-1")).toHaveTextContent("Needs image")); - expect(screen.queryByTestId("clean-assets-ready")).not.toBeInTheDocument(); + await waitFor(() => + expect(screen.getByTestId("cut-card-status-1")).toHaveTextContent( + "Needs image", + ), + ); + expect( + screen.queryByTestId("clean-assets-ready"), + ).not.toBeInTheDocument(); }); }); }); describe("CutListPanel stale bubble-renderer warning (#381)", () => { - const tailedSpeech = { id: "ov1", type: "speech", x: 0.1, y: 0.1, width: 0.3, height: 0.15, text: "Hi", tailAnchor: { x: 0.5, y: 1.2 } }; + const tailedSpeech = { + id: "ov1", + type: "speech", + x: 0.1, + y: 0.1, + width: 0.3, + height: 0.15, + text: "Hi", + tailAnchor: { x: 0.5, y: 1.2 }, + }; it("warns to re-export a tailed-bubble final image lettered by an older renderer", async () => { const cutsData = { - version: 1, plotFile: "plot-01", - cuts: [makeCut({ id: 1, finalImagePath: "assets/plot-01/cut-01-final.webp", overlays: [tailedSpeech] })], // no version stamp → stale + version: 1, + plotFile: "plot-01", + cuts: [ + makeCut({ + id: 1, + finalImagePath: "assets/plot-01/cut-01-final.webp", + overlays: [tailedSpeech], + }), + ], // no version stamp → stale }; - render(); + render( + , + ); const warn = await screen.findByTestId("stale-bubble-export-warning"); expect(warn).toHaveTextContent(/Cut 1/); expect(warn).toHaveTextContent(/re-export/i); @@ -876,44 +1843,99 @@ describe("CutListPanel stale bubble-renderer warning (#381)", () => { it("does NOT warn when the final image was exported by the current renderer", async () => { const cutsData = { - version: 1, plotFile: "plot-01", - cuts: [makeCut({ id: 1, finalImagePath: "x.webp", overlays: [tailedSpeech], finalRendererVersion: CARTOON_BUBBLE_RENDERER_VERSION })], + version: 1, + plotFile: "plot-01", + cuts: [ + makeCut({ + id: 1, + finalImagePath: "x.webp", + overlays: [tailedSpeech], + finalRendererVersion: CARTOON_BUBBLE_RENDERER_VERSION, + }), + ], }; - render(); + render( + , + ); await screen.findByTestId("cartoon-workflow-help"); - expect(screen.queryByTestId("stale-bubble-export-warning")).not.toBeInTheDocument(); + expect( + screen.queryByTestId("stale-bubble-export-warning"), + ).not.toBeInTheDocument(); }); it("does NOT warn for a tailless bubble even if unstamped", async () => { const noTail = { ...tailedSpeech, tailAnchor: { x: 0.5, y: 0.5 } }; // tip inside → no visible tail const cutsData = { - version: 1, plotFile: "plot-01", + version: 1, + plotFile: "plot-01", cuts: [makeCut({ id: 1, finalImagePath: "x.webp", overlays: [noTail] })], }; - render(); + render( + , + ); await screen.findByTestId("cartoon-workflow-help"); - expect(screen.queryByTestId("stale-bubble-export-warning")).not.toBeInTheDocument(); + expect( + screen.queryByTestId("stale-bubble-export-warning"), + ).not.toBeInTheDocument(); }); }); describe("CutListPanel asset diagnostics + Refresh assets (#427)", () => { // Route-aware authFetch: cuts file, detect (none), and asset-diagnostics with a // missing cut. The `.md` GET (episode state) and anything else returns {}. - function diagAuthFetch(diagnostics: unknown[], summary: Record) { + function diagAuthFetch( + diagnostics: unknown[], + summary: Record, + ) { return vi.fn((url: string) => { if (url.includes("/asset-diagnostics")) { - return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ diagnostics, summary }) }); + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ diagnostics, summary }), + }); } if (url.includes("/detect-clean-images")) { - return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ detected: [], stale: [] }) }); + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ detected: [], stale: [] }), + }); } if (url.includes("/cuts/")) { - return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ version: 1, plotFile: "genesis", cuts: [ - makeCut({ id: 1, cleanImagePath: "assets/genesis/cut-01-clean.webp" }), - makeCut({ id: 2, cleanImagePath: "assets/genesis/cut-02-clean.webp" }), - ] }) }); + return Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + version: 1, + plotFile: "genesis", + cuts: [ + makeCut({ + id: 1, + cleanImagePath: "assets/genesis/cut-01-clean.webp", + }), + makeCut({ + id: 2, + cleanImagePath: "assets/genesis/cut-02-clean.webp", + }), + ], + }), + }); } - return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({}) }); + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({}), + }); }); } @@ -921,24 +1943,46 @@ describe("CutListPanel asset diagnostics + Refresh assets (#427)", () => { const fn = diagAuthFetch( [ { cutId: 1, kind: "image", state: "clean-ready", issue: null }, - { cutId: 2, kind: "image", state: "missing", issue: 'Cut 2: clean image "assets/genesis/cut-02-clean.webp" — the file is missing' }, + { + cutId: 2, + kind: "image", + state: "missing", + issue: + 'Cut 2: clean image "assets/genesis/cut-02-clean.webp" — the file is missing', + }, ], { planned: 0, missing: 1, cleanReady: 1, finalReady: 0, uploaded: 0 }, ); - render(); + render( + , + ); const diag = await screen.findByTestId("asset-diagnostics"); - expect(screen.getByTestId("asset-diag-summary")).toHaveTextContent("1 clean"); - expect(screen.getByTestId("asset-diag-summary")).toHaveTextContent("1 missing"); + expect(screen.getByTestId("asset-diag-summary")).toHaveTextContent( + "1 clean", + ); + expect(screen.getByTestId("asset-diag-summary")).toHaveTextContent( + "1 missing", + ); // Precise per-cut reason, not a generic publish warning. - expect(screen.getByTestId("asset-diag-issues")).toHaveTextContent(/Cut 2: clean image .* the file is missing/); + expect(screen.getByTestId("asset-diag-issues")).toHaveTextContent( + /Cut 2: clean image .* the file is missing/, + ); expect(diag).toBeInTheDocument(); // The read-only Refresh assets action re-runs the rescan (asset-diagnostics fetched again). - const before = fn.mock.calls.filter((c) => String(c[0]).includes("/asset-diagnostics")).length; + const before = fn.mock.calls.filter((c) => + String(c[0]).includes("/asset-diagnostics"), + ).length; fireEvent.click(screen.getByTestId("refresh-assets-btn")); await waitFor(() => { - const after = fn.mock.calls.filter((c) => String(c[0]).includes("/asset-diagnostics")).length; + const after = fn.mock.calls.filter((c) => + String(c[0]).includes("/asset-diagnostics"), + ).length; expect(after).toBeGreaterThan(before); }); }); @@ -947,36 +1991,100 @@ describe("CutListPanel asset diagnostics + Refresh assets (#427)", () => { it("shows a Convert artwork step for PNG clean images instead of red unsupported-extension errors", async () => { const fn = vi.fn((url: string) => { if (url.includes("/asset-diagnostics")) { - return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ - diagnostics: [ - { cutId: 1, kind: "image", state: "needs-conversion", issue: 'Cut 1: clean image "assets/genesis/cut-01-clean.png" — Unsupported extension .png', convertiblePng: "assets/genesis/cut-01-clean.png" }, - { cutId: 2, kind: "image", state: "clean-ready", issue: null, convertiblePng: null }, - ], - summary: { planned: 0, needsConversion: 1, missing: 0, cleanReady: 1, finalReady: 0, uploaded: 0 }, - }) }); + return Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + diagnostics: [ + { + cutId: 1, + kind: "image", + state: "needs-conversion", + issue: + 'Cut 1: clean image "assets/genesis/cut-01-clean.png" — Unsupported extension .png', + convertiblePng: "assets/genesis/cut-01-clean.png", + }, + { + cutId: 2, + kind: "image", + state: "clean-ready", + issue: null, + convertiblePng: null, + }, + ], + summary: { + planned: 0, + needsConversion: 1, + missing: 0, + cleanReady: 1, + finalReady: 0, + uploaded: 0, + }, + }), + }); } - if (url.includes("/detect-clean-images")) return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ detected: [], stale: [] }) }); - if (url.includes("/cuts/")) return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ version: 1, plotFile: "genesis", cuts: [ - makeCut({ id: 1, cleanImagePath: "assets/genesis/cut-01-clean.png" }), - makeCut({ id: 2, cleanImagePath: "assets/genesis/cut-02-clean.webp" }), - ] }) }); - return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({}) }); + if (url.includes("/detect-clean-images")) + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ detected: [], stale: [] }), + }); + if (url.includes("/cuts/")) + return Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + version: 1, + plotFile: "genesis", + cuts: [ + makeCut({ + id: 1, + cleanImagePath: "assets/genesis/cut-01-clean.png", + }), + makeCut({ + id: 2, + cleanImagePath: "assets/genesis/cut-02-clean.webp", + }), + ], + }), + }); + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({}), + }); }); - render(); + render( + , + ); // Friendly banner with a count + batch CTA, not a red dump. const banner = await screen.findByTestId("convert-artwork"); - expect(within(banner).getByTestId("convert-artwork-count")).toHaveTextContent("1 PNG image found"); + expect( + within(banner).getByTestId("convert-artwork-count"), + ).toHaveTextContent("1 PNG image found"); expect(within(banner).getByTestId("convert-all-btn")).toBeInTheDocument(); // The raw unsupported-extension reason is hidden under Technical details. - expect(within(banner).getByTestId("convert-technical-details")).toHaveTextContent(/Unsupported extension \.png/); + expect( + within(banner).getByTestId("convert-technical-details"), + ).toHaveTextContent(/Unsupported extension \.png/); // The summary counts it as needs-conversion, NOT missing. - expect(screen.getByTestId("asset-diag-summary")).toHaveTextContent("1 needs conversion"); + expect(screen.getByTestId("asset-diag-summary")).toHaveTextContent( + "1 needs conversion", + ); expect(screen.queryByTestId("asset-diag-issues")).not.toBeInTheDocument(); // Per-cut card: a "Needs conversion" status + a primary "Convert image" // action (#440 card head), never "Image missing". - expect(screen.getByTestId("cut-card-status-1")).toHaveTextContent("Needs conversion"); + expect(screen.getByTestId("cut-card-status-1")).toHaveTextContent( + "Needs conversion", + ); expect(screen.getByTestId("card-convert-1")).toBeInTheDocument(); // Opening details exposes the per-cut convert box; a PNG cut must NOT show the // red "Clear stale path" repair box. @@ -992,40 +2100,126 @@ describe("CutListPanel asset diagnostics + Refresh assets (#427)", () => { it("renders an episode header, progress summary, per-cut card statuses, and collapses technical controls", async () => { const fn = vi.fn((url: string) => { if (url.includes("/asset-diagnostics")) { - return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ - diagnostics: [ - { cutId: 1, kind: "image", state: "needs-conversion", issue: "Cut 1: … Unsupported extension .png", convertiblePng: "assets/genesis/cut-01-clean.png" }, - { cutId: 2, kind: "image", state: "clean-ready", issue: null, convertiblePng: null }, - { cutId: 3, kind: "image", state: "planned", issue: null, convertiblePng: null }, - ], - summary: { planned: 1, needsConversion: 1, missing: 0, cleanReady: 1, finalReady: 0, uploaded: 0 }, - }) }); + return Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + diagnostics: [ + { + cutId: 1, + kind: "image", + state: "needs-conversion", + issue: "Cut 1: … Unsupported extension .png", + convertiblePng: "assets/genesis/cut-01-clean.png", + }, + { + cutId: 2, + kind: "image", + state: "clean-ready", + issue: null, + convertiblePng: null, + }, + { + cutId: 3, + kind: "image", + state: "planned", + issue: null, + convertiblePng: null, + }, + ], + summary: { + planned: 1, + needsConversion: 1, + missing: 0, + cleanReady: 1, + finalReady: 0, + uploaded: 0, + }, + }), + }); } - if (url.includes("/detect-clean-images")) return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ detected: [], stale: [] }) }); - if (url.includes("/cuts/")) return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ version: 1, plotFile: "genesis", title: "열네 개의 점", cuts: [ - makeCut({ id: 1, shotType: "wide", cleanImagePath: "assets/genesis/cut-01-clean.png", description: "A cold CERN room" }), - makeCut({ id: 2, shotType: "medium", cleanImagePath: "assets/genesis/cut-02-clean.webp", description: "Sarah at a desk" }), - makeCut({ id: 3, shotType: "close-up", description: "A single dot" }), - ] }) }); - return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({}) }); + if (url.includes("/detect-clean-images")) + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ detected: [], stale: [] }), + }); + if (url.includes("/cuts/")) + return Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + version: 1, + plotFile: "genesis", + title: "열네 개의 점", + cuts: [ + makeCut({ + id: 1, + shotType: "wide", + cleanImagePath: "assets/genesis/cut-01-clean.png", + description: "A cold CERN room", + }), + makeCut({ + id: 2, + shotType: "medium", + cleanImagePath: "assets/genesis/cut-02-clean.webp", + description: "Sarah at a desk", + }), + makeCut({ + id: 3, + shotType: "close-up", + description: "A single dot", + }), + ], + }), + }); + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({}), + }); }); - render(); + render( + , + ); // Episode header + creator-facing progress summary. const header = await screen.findByTestId("cut-board-header"); expect(header).toHaveTextContent("Genesis / Episode 1"); expect(header).toHaveTextContent("열네 개의 점"); - await waitFor(() => expect(screen.getByTestId("cut-board-summary")).toHaveTextContent("3 cuts · 2 artwork found · 1 converted · 0 lettered · 0 uploaded")); + await waitFor(() => + expect(screen.getByTestId("cut-board-summary")).toHaveTextContent( + "3 cuts · 2 artwork found · 1 converted · 0 lettered · 0 uploaded", + ), + ); // Per-cut cards with creator-facing status + the right primary action. - expect(screen.getByTestId("cut-card-status-1")).toHaveTextContent("Needs conversion"); + expect(screen.getByTestId("cut-card-status-1")).toHaveTextContent( + "Needs conversion", + ); expect(screen.getByTestId("card-convert-1")).toBeInTheDocument(); - expect(screen.getByTestId("cut-card-status-2")).toHaveTextContent("Ready for lettering"); - expect(screen.getByTestId("lettering-review-state-2")).toHaveTextContent("Unlettered"); - expect(screen.getByTestId("add-bubbles-2")).toHaveTextContent("Open focused editor"); - expect(screen.getByTestId("cut-card-status-3")).toHaveTextContent("Needs image"); + expect(screen.getByTestId("cut-card-status-2")).toHaveTextContent( + "Ready for lettering", + ); + expect(screen.getByTestId("lettering-review-state-2")).toHaveTextContent( + "Unlettered", + ); + expect(screen.getByTestId("add-bubbles-2")).toHaveTextContent( + "Open focused editor", + ); + expect(screen.getByTestId("cut-card-status-3")).toHaveTextContent( + "Needs image", + ); expect(screen.getByTestId("lettering-review-board")).toBeInTheDocument(); - expect(screen.getByTestId("between-scene-slot-1")).toHaveTextContent("Between-scene lettering"); + expect(screen.getByTestId("between-scene-slot-1")).toHaveTextContent( + "Between-scene lettering", + ); // Technical controls live under a collapsed-by-default Details disclosure. const advanced = screen.getByTestId("cut-advanced"); @@ -1036,52 +2230,230 @@ describe("CutListPanel asset diagnostics + Refresh assets (#427)", () => { expect(help.tagName.toLowerCase()).toBe("details"); expect(help).not.toHaveAttribute("open"); expect(within(help).getByText("Cut workflow help")).toBeInTheDocument(); - expect(screen.getByTestId("finish-episode-details")).not.toHaveAttribute("open"); + expect(screen.getByTestId("finish-episode-details")).not.toHaveAttribute( + "open", + ); }); // #488: AI drafting is scoped to the selected target inside the focused // editor, while the review board stays focused on opening/editing targets. it("opens a focused editor with scoped AI draft assistance and keeps review state visible", async () => { - const overlay = { id: "o1", type: "speech", x: 0.1, y: 0.1, width: 0.3, height: 0.15, text: "Hi", speaker: "Sera" }; + const overlay = { + id: "o1", + type: "speech", + x: 0.1, + y: 0.1, + width: 0.3, + height: 0.15, + text: "Hi", + speaker: "Sera", + }; const fn = vi.fn((url: string) => { if (url.includes("/asset-diagnostics")) { - return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ - diagnostics: [ - { cutId: 1, kind: "image", state: "clean-ready", issue: null, convertiblePng: null }, - { cutId: 2, kind: "image", state: "clean-ready", issue: null, convertiblePng: null }, - ], - summary: { planned: 0, needsConversion: 0, missing: 0, cleanReady: 2, finalReady: 0, uploaded: 0 }, - }) }); + return Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + diagnostics: [ + { + cutId: 1, + kind: "image", + state: "clean-ready", + issue: null, + convertiblePng: null, + }, + { + cutId: 2, + kind: "image", + state: "clean-ready", + issue: null, + convertiblePng: null, + }, + ], + summary: { + planned: 0, + needsConversion: 0, + missing: 0, + cleanReady: 2, + finalReady: 0, + uploaded: 0, + }, + }), + }); } - if (url.includes("/detect-clean-images")) return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ detected: [], stale: [] }) }); - if (url.includes("/cuts/")) return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ version: 1, plotFile: "genesis", cuts: [ - makeCut({ id: 1, shotType: "close-up", cleanImagePath: "assets/genesis/cut-01-clean.webp", dialogue: [{ speaker: "Sera", text: "그거 따라한 거야" }], description: "Close shot" }), - makeCut({ id: 2, shotType: "wide", cleanImagePath: "assets/genesis/cut-02-clean.webp", overlays: [overlay], description: "Wide shot" }), - ] }) }); - return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({}) }); + if (url.includes("/detect-clean-images")) + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ detected: [], stale: [] }), + }); + if (url.includes("/cuts/")) + return Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + version: 1, + plotFile: "genesis", + cuts: [ + makeCut({ + id: 1, + shotType: "close-up", + cleanImagePath: "assets/genesis/cut-01-clean.webp", + dialogue: [{ speaker: "Sera", text: "그거 따라한 거야" }], + description: "Close shot", + }), + makeCut({ + id: 2, + shotType: "wide", + cleanImagePath: "assets/genesis/cut-02-clean.webp", + overlays: [overlay], + description: "Wide shot", + }), + ], + }), + }); + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({}), + }); }); - render(); + render( + , + ); await screen.findByTestId("cut-list-panel"); // Cut 1 (no bubbles): review opens the focused editor. - expect(await screen.findByTestId("lettering-review-state-1")).toHaveTextContent("Unlettered"); + expect( + await screen.findByTestId("lettering-review-state-1"), + ).toHaveTextContent("Unlettered"); fireEvent.click(screen.getByTestId("add-bubbles-1")); - expect(await screen.findByTestId("focused-lettering-editor")).toBeInTheDocument(); - expect(screen.getByTestId("ai-draft-current-target")).toHaveTextContent("Cut 01"); + expect( + await screen.findByTestId("focused-lettering-editor"), + ).toBeInTheDocument(); + expect(screen.getByTestId("ai-draft-current-target")).toHaveTextContent( + "Cut 01", + ); // AI draft is scoped to the focused target. fireEvent.click(screen.getByTestId("copy-ai-lettering-current")); expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1); - const copied = (navigator.clipboard.writeText as ReturnType).mock.calls[0][0] as string; - expect(copied).toContain("Draft the speech bubbles and captions for cut 1 of genesis"); + const copied = (navigator.clipboard.writeText as ReturnType) + .mock.calls[0][0] as string; + expect(copied).toContain( + "Draft the speech bubbles and captions for cut 1 of genesis", + ); expect(copied).toMatch(/do NOT export or upload/i); fireEvent.click(screen.getByTestId("cancel-lettering-btn")); // Cut 2 (bubbles already placed): status "Needs review" + a draft-saved state. - expect(screen.getByTestId("cut-card-status-2")).toHaveTextContent("Needs review"); - expect(screen.getByTestId("lettering-review-state-2")).toHaveTextContent("Draft saved"); - expect(screen.getByTestId("add-bubbles-2")).toHaveTextContent("Review lettering"); + expect(screen.getByTestId("cut-card-status-2")).toHaveTextContent( + "Needs review", + ); + expect(screen.getByTestId("lettering-review-state-2")).toHaveTextContent( + "Draft saved", + ); + expect(screen.getByTestId("add-bubbles-2")).toHaveTextContent( + "Review lettering", + ); + }); + + it("enters focused lettering mode, exposes the work-area toggle, and restores review state on close (#493)", async () => { + const onFocusedLetteringModeChange = vi.fn(); + const onWorkspaceVisibleChange = vi.fn(); + const fn = vi.fn((url: string) => { + if (url.includes("/asset-diagnostics")) { + return Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + diagnostics: [ + { + cutId: 1, + kind: "image", + state: "clean-ready", + issue: null, + convertiblePng: null, + }, + ], + summary: { + planned: 0, + needsConversion: 0, + missing: 0, + cleanReady: 1, + finalReady: 0, + uploaded: 0, + }, + }), + }); + } + if (url.includes("/detect-clean-images")) + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ detected: [], stale: [] }), + }); + if (url.includes("/cuts/plot-01")) + return Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + version: 1, + plotFile: "plot-01", + cuts: [ + makeCut({ + id: 1, + shotType: "close-up", + cleanImagePath: "assets/plot-01/cut-01-clean.webp", + description: "Close shot", + }), + ], + }), + }); + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({}), + }); + }); + + render( + , + ); + + await screen.findByTestId("cut-list-panel"); + fireEvent.click(await screen.findByTestId("add-bubbles-1")); + + expect( + await screen.findByTestId("focused-lettering-editor"), + ).toBeInTheDocument(); + expect(onFocusedLetteringModeChange).toHaveBeenCalledWith(true); + expect(screen.getByTestId("toggle-work-area-btn")).toHaveTextContent( + "Show work area", + ); + + fireEvent.click(screen.getByTestId("toggle-work-area-btn")); + expect(onWorkspaceVisibleChange).toHaveBeenCalledWith(true); + + fireEvent.click(screen.getByTestId("return-to-cut-review-btn")); + await screen.findByTestId("cut-list-panel"); + expect(onFocusedLetteringModeChange).toHaveBeenLastCalledWith(false); }); it("lets a between-scene slot create a focused text-card editor and Save returns to review (#488)", async () => { @@ -1091,53 +2463,159 @@ describe("CutListPanel asset diagnostics + Refresh assets (#427)", () => { ]; const fn = vi.fn((url: string, opts?: RequestInit) => { if (url.includes("/asset-diagnostics")) { - return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ - diagnostics: cuts.map((c) => ({ cutId: c.id, kind: c.kind === "text" ? "text" : "image", state: c.kind === "text" ? "planned" : "clean-ready", issue: null, convertiblePng: null })), - summary: { planned: 0, needsConversion: 0, missing: 0, cleanReady: 2, finalReady: 0, uploaded: 0 }, - }) }); + return Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + diagnostics: cuts.map((c) => ({ + cutId: c.id, + kind: c.kind === "text" ? "text" : "image", + state: c.kind === "text" ? "planned" : "clean-ready", + issue: null, + convertiblePng: null, + })), + summary: { + planned: 0, + needsConversion: 0, + missing: 0, + cleanReady: 2, + finalReady: 0, + uploaded: 0, + }, + }), + }); } - if (url.includes("/detect-clean-images")) return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ detected: [], stale: [] }) }); + if (url.includes("/detect-clean-images")) + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ detected: [], stale: [] }), + }); if (url.endsWith("/cuts/genesis") && opts?.method === "PUT") { cuts = JSON.parse(opts.body as string).cuts; - return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ ok: true }) }); + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ ok: true }), + }); } - if (url.includes("/cuts/genesis")) return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ version: 1, plotFile: "genesis", cuts }) }); - return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({}) }); + if (url.includes("/cuts/genesis")) + return Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ version: 1, plotFile: "genesis", cuts }), + }); + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({}), + }); }); - render(); + render( + , + ); fireEvent.click(await screen.findByTestId("add-between-scene-1")); - expect(await screen.findByTestId("focused-lettering-editor")).toBeInTheDocument(); - expect(screen.getByTestId("focused-lettering-editor")).toHaveTextContent("Between-scene card 3"); + expect( + await screen.findByTestId("focused-lettering-editor"), + ).toBeInTheDocument(); + expect(screen.getByTestId("focused-lettering-editor")).toHaveTextContent( + "Between-scene card 3", + ); fireEvent.click(screen.getByTestId("add-narration")); fireEvent.click(screen.getByTestId("save-lettering-btn")); - await waitFor(() => expect(screen.getByTestId("lettering-review-board")).toBeInTheDocument()); - expect(screen.getByTestId("lettering-review-state-3")).toHaveTextContent("Draft saved"); + await waitFor(() => + expect(screen.getByTestId("lettering-review-board")).toBeInTheDocument(), + ); + expect(screen.getByTestId("lettering-review-state-3")).toHaveTextContent( + "Draft saved", + ); }); it("clears the stale diagnostics banner on a file switch whose diagnostics request fails (@re1)", async () => { const fn = vi.fn((url: string) => { if (url.includes("/asset-diagnostics")) { if (url.includes("/cuts/genesis/")) { - return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ - diagnostics: [{ cutId: 2, kind: "image", state: "missing", issue: "Cut 2: clean image missing" }], - summary: { planned: 0, missing: 1, cleanReady: 0, finalReady: 0, uploaded: 0 }, - }) }); + return Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + diagnostics: [ + { + cutId: 2, + kind: "image", + state: "missing", + issue: "Cut 2: clean image missing", + }, + ], + summary: { + planned: 0, + missing: 1, + cleanReady: 0, + finalReady: 0, + uploaded: 0, + }, + }), + }); } - return Promise.resolve({ ok: false, status: 500, json: () => Promise.resolve({}) }); // plot-01 fails + return Promise.resolve({ + ok: false, + status: 500, + json: () => Promise.resolve({}), + }); // plot-01 fails } - if (url.includes("/detect-clean-images")) return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ detected: [], stale: [] }) }); - if (url.includes("/cuts/")) return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ version: 1, plotFile: "x", cuts: [makeCut({ id: 2, cleanImagePath: "a.webp" })] }) }); - return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({}) }); + if (url.includes("/detect-clean-images")) + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ detected: [], stale: [] }), + }); + if (url.includes("/cuts/")) + return Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + version: 1, + plotFile: "x", + cuts: [makeCut({ id: 2, cleanImagePath: "a.webp" })], + }), + }); + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({}), + }); }); - const { rerender } = render(); + const { rerender } = render( + , + ); expect(await screen.findByTestId("asset-diagnostics")).toBeInTheDocument(); // Switch to a plot whose diagnostics request fails — the old banner must clear. - rerender(); - await waitFor(() => expect(screen.queryByTestId("asset-diagnostics")).not.toBeInTheDocument()); + rerender( + , + ); + await waitFor(() => + expect(screen.queryByTestId("asset-diagnostics")).not.toBeInTheDocument(), + ); }); }); diff --git a/app/web/components/CutListPanel.tsx b/app/web/components/CutListPanel.tsx index 5c93d20..5a80fdc 100644 --- a/app/web/components/CutListPanel.tsx +++ b/app/web/components/CutListPanel.tsx @@ -4,12 +4,25 @@ import { AssetImage, assetUrl } from "./asset-image"; import { buildCodexTaskPrompt } from "@app-lib/cartoon-prompt"; import type { Cut as LibCut } from "@app-lib/cuts"; import { isTextPanel, isStaleTailedExport } from "@app-lib/cuts"; -import { withRateLimitRetry, createUploadThrottle, type RetryDeps } from "../lib/upload-retry"; -import { importImageToCompliantBlob, isCompliantImage } from "../lib/import-image"; +import { + withRateLimitRetry, + createUploadThrottle, + type RetryDeps, +} from "../lib/upload-retry"; +import { + importImageToCompliantBlob, + isCompliantImage, +} from "../lib/import-image"; import { CodexImportPicker } from "./CodexImportPicker"; import { FinishEpisodePanel } from "./FinishEpisodePanel"; -import { cartoonChecklist, checkMarkdownReadiness } from "@app-lib/cartoon-readiness"; -import { summarizeAssetDiagnostics, type CutAssetDiagnostic } from "@app-lib/cut-asset-diagnostics"; +import { + cartoonChecklist, + checkMarkdownReadiness, +} from "@app-lib/cartoon-readiness"; +import { + summarizeAssetDiagnostics, + type CutAssetDiagnostic, +} from "@app-lib/cut-asset-diagnostics"; interface Overlay { id: string; @@ -83,6 +96,12 @@ interface CutListPanelProps { // is called once applied so the parent can clear the request. focusRequest?: { cutId: number; openEditor: boolean; seq: number } | null; onFocusHandled?: () => void; + /** Enter/leave the wider app's focused lettering mode (#493). */ + onFocusedLetteringModeChange?: (active: boolean) => void; + /** Whether the surrounding work area is currently restored while editing. */ + workspaceVisible?: boolean; + /** Restore/fold the wider app work area while staying in the editor. */ + onWorkspaceVisibleChange?: (visible: boolean) => void; } type CutStatus = "missing" | "clean" | "lettered" | "uploaded" | "text"; @@ -101,14 +120,31 @@ function getCutStatus(cut: Cut): CutStatus { // per cut + the single primary human action, instead of internal field names. type BoardTone = "muted" | "amber" | "green" | "accent"; const BOARD_TONE_TEXT: Record = { - muted: "text-muted", amber: "text-amber-700", green: "text-green-700", accent: "text-accent", + muted: "text-muted", + amber: "text-amber-700", + green: "text-green-700", + accent: "text-accent", }; const BOARD_TONE_DOT: Record = { - muted: "bg-muted/40", amber: "bg-amber-500", green: "bg-green-600", accent: "bg-accent", + muted: "bg-muted/40", + amber: "bg-amber-500", + green: "bg-green-600", + accent: "bg-accent", }; -type BoardStatusKey = "uploaded" | "exported" | "convert" | "text" | "review" | "letter" | "needs-image"; -interface BoardStatus { key: BoardStatusKey; label: string; tone: BoardTone } +type BoardStatusKey = + | "uploaded" + | "exported" + | "convert" + | "text" + | "review" + | "letter" + | "needs-image"; +interface BoardStatus { + key: BoardStatusKey; + label: string; + tone: BoardTone; +} /** * Map a cut's real state to one creator-facing board status (#440). `.png` clean @@ -117,11 +153,17 @@ interface BoardStatus { key: BoardStatusKey; label: string; tone: BoardTone } * Details. Precedence follows the pipeline: uploaded → exported → convert → * letter/review → needs image. */ -function boardStatus(cut: Cut, needsConversion: boolean, hasStale: boolean): BoardStatus { +function boardStatus( + cut: Cut, + needsConversion: boolean, + hasStale: boolean, +): BoardStatus { // Uploaded content lives on IPFS, so a missing LOCAL file is not a defect. - if (cut.uploadedCid || cut.uploadedUrl) return { key: "uploaded", label: "Uploaded", tone: "green" }; + if (cut.uploadedCid || cut.uploadedUrl) + return { key: "uploaded", label: "Uploaded", tone: "green" }; // PNG clean art is an actionable conversion step (#441). - if (needsConversion) return { key: "convert", label: "Needs conversion", tone: "amber" }; + if (needsConversion) + return { key: "convert", label: "Needs conversion", tone: "amber" }; // A recorded asset path that's broken on disk (#302) must NOT read as a // finished "Exported"/clean cut (#440 RE1): a recorded final needs // re-review/re-export; otherwise the clean art is gone → needs image. The @@ -131,8 +173,10 @@ function boardStatus(cut: Cut, needsConversion: boolean, hasStale: boolean): Boa ? { key: "review", label: "Needs review", tone: "amber" } : { key: "needs-image", label: "Needs image", tone: "muted" }; } - if (cut.finalImagePath) return { key: "exported", label: "Exported", tone: "green" }; - if (isTextPanel(cut)) return { key: "text", label: "Ready for captions", tone: "accent" }; + if (cut.finalImagePath) + return { key: "exported", label: "Exported", tone: "green" }; + if (isTextPanel(cut)) + return { key: "text", label: "Ready for captions", tone: "accent" }; if (cut.cleanImagePath) { return (cut.overlays?.length ?? 0) > 0 ? { key: "review", label: "Needs review", tone: "amber" } @@ -141,7 +185,11 @@ function boardStatus(cut: Cut, needsConversion: boolean, hasStale: boolean): Boa return { key: "needs-image", label: "Needs image", tone: "muted" }; } -function letteringReviewState(cut: Cut): { label: string; detail: string; tone: BoardTone } { +function letteringReviewState(cut: Cut): { + label: string; + detail: string; + tone: BoardTone; +} { if (cut.uploadedCid || cut.uploadedUrl) { return { label: "Complete", detail: "Final image uploaded", tone: "green" }; } @@ -149,15 +197,31 @@ function letteringReviewState(cut: Cut): { label: string; detail: string; tone: return { label: "Exported", detail: "Ready to upload", tone: "green" }; } if ((cut.overlays?.length ?? 0) > 0) { - return { label: "Draft saved", detail: `${cut.overlays.length} overlay${cut.overlays.length === 1 ? "" : "s"} placed`, tone: "amber" }; + return { + label: "Draft saved", + detail: `${cut.overlays.length} overlay${cut.overlays.length === 1 ? "" : "s"} placed`, + tone: "amber", + }; } if (isTextPanel(cut)) { - return { label: "Between-scene card", detail: "Open to add narration or title text", tone: "accent" }; + return { + label: "Between-scene card", + detail: "Open to add narration or title text", + tone: "accent", + }; } if (cut.cleanImagePath) { - return { label: "Unlettered", detail: "Clean art ready for bubble placement", tone: "muted" }; + return { + label: "Unlettered", + detail: "Clean art ready for bubble placement", + tone: "muted", + }; } - return { label: "Needs artwork", detail: "Add or sync clean art first", tone: "muted" }; + return { + label: "Needs artwork", + detail: "Add or sync clean art first", + tone: "muted", + }; } function CutRow({ @@ -228,46 +292,54 @@ function CutRow({ // Returns true on a successful upload so callers (e.g. the Codex import picker) // can close themselves only when the clean image was actually recorded. - const handleUpload = useCallback(async (file: File): Promise => { - setUploading(true); - setUploadError(null); - try { - // Accept Codex-generated images (e.g. large PNG) by converting/compressing - // them to a compliant WebP/JPEG <=1MB in the browser first (#301). An - // already-compliant WebP/JPEG is passed through untouched, so the manual - // upload behavior is unchanged. A source that cannot be decoded or - // compressed under 1MB surfaces a clear error instead of saving anything. - let upload: Blob = file; - if (!isCompliantImage(file)) { - try { - upload = await importImageToCompliantBlob(file); - } catch (err) { - setUploadError(err instanceof Error ? err.message : "Could not import image"); - return false; + const handleUpload = useCallback( + async (file: File): Promise => { + setUploading(true); + setUploadError(null); + try { + // Accept Codex-generated images (e.g. large PNG) by converting/compressing + // them to a compliant WebP/JPEG <=1MB in the browser first (#301). An + // already-compliant WebP/JPEG is passed through untouched, so the manual + // upload behavior is unchanged. A source that cannot be decoded or + // compressed under 1MB surfaces a clear error instead of saving anything. + let upload: Blob = file; + if (!isCompliantImage(file)) { + try { + upload = await importImageToCompliantBlob(file); + } catch (err) { + setUploadError( + err instanceof Error ? err.message : "Could not import image", + ); + return false; + } } - } - const ext = upload.type === "image/jpeg" ? "jpg" : "webp"; - const formData = new FormData(); - formData.append("file", new File([upload], `clean.${ext}`, { type: upload.type })); - const res = await authFetch( - `/api/stories/${storyName}/cuts/${plotFile}/upload-clean/${cut.id}`, - { method: "POST", body: formData }, - ); - if (!res.ok) { - const data = await res.json(); - setUploadError(data.error || "Upload failed"); + const ext = upload.type === "image/jpeg" ? "jpg" : "webp"; + const formData = new FormData(); + formData.append( + "file", + new File([upload], `clean.${ext}`, { type: upload.type }), + ); + const res = await authFetch( + `/api/stories/${storyName}/cuts/${plotFile}/upload-clean/${cut.id}`, + { method: "POST", body: formData }, + ); + if (!res.ok) { + const data = await res.json(); + setUploadError(data.error || "Upload failed"); + return false; + } + onUpdated(); + return true; + } catch { + setUploadError("Upload failed"); return false; + } finally { + setUploading(false); } - onUpdated(); - return true; - } catch { - setUploadError("Upload failed"); - return false; - } finally { - setUploading(false); - } - }, [authFetch, storyName, plotFile, cut.id, onUpdated]); + }, + [authFetch, storyName, plotFile, cut.id, onUpdated], + ); // Creator-facing board status + the single primary action for this cut (#440). const board = boardStatus(cut, needsConversion, hasStale); @@ -279,15 +351,40 @@ function CutRow({ // first-class Manual/AI-draft lettering choice instead of a single button. const bubblesPlaced = cut.overlays?.length ?? 0; const atLetteringStage = - !isTextPanel(cut) && !!cut.cleanImagePath && !cut.finalImagePath && - !cut.uploadedCid && !cut.uploadedUrl && !hasStale && !needsConversion; + !isTextPanel(cut) && + !!cut.cleanImagePath && + !cut.finalImagePath && + !cut.uploadedCid && + !cut.uploadedUrl && + !hasStale && + !needsConversion; const primary: { label: string; onClick: () => void; testid: string } | null = - board.key === "convert" ? { label: convertingThis ? "Converting…" : "Convert image", onClick: handleConvertThis, testid: `card-convert-${cut.id}` } - : board.key === "review" ? { label: "Review cut", onClick: onOpenEditor, testid: `card-review-${cut.id}` } - : board.key === "text" ? { label: "Add captions", onClick: onOpenEditor, testid: `card-letter-${cut.id}` } - : board.key === "needs-image" ? { label: "Add artwork", onClick: onToggle, testid: `card-addart-${cut.id}` } - : null; // exported / uploaded — the next action is the episode-level upload/publish + board.key === "convert" + ? { + label: convertingThis ? "Converting…" : "Convert image", + onClick: handleConvertThis, + testid: `card-convert-${cut.id}`, + } + : board.key === "review" + ? { + label: "Review cut", + onClick: onOpenEditor, + testid: `card-review-${cut.id}`, + } + : board.key === "text" + ? { + label: "Add captions", + onClick: onOpenEditor, + testid: `card-letter-${cut.id}`, + } + : board.key === "needs-image" + ? { + label: "Add artwork", + onClick: onToggle, + testid: `card-addart-${cut.id}`, + } + : null; // exported / uploaded — the next action is the episode-level upload/publish const reviewState = letteringReviewState(cut); return ( @@ -300,10 +397,19 @@ function CutRow({ action, plus an "Open details" toggle for the technical controls (#440). */}
- - Cut {String(cut.id).padStart(2, "0")} - · {cut.shotType} - + + + Cut {String(cut.id).padStart(2, "0")} + + + · {cut.shotType} + + {board.label}
@@ -316,8 +422,13 @@ function CutRow({ className="w-full max-h-[32rem] object-contain rounded border border-border bg-white" /> ) : ( -
- {isTextPanel(cut) ? "Text panel — no artwork needed" : "No artwork yet"} +
+ {isTextPanel(cut) + ? "Text panel — no artwork needed" + : "No artwork yet"}
)}
@@ -374,9 +487,13 @@ function CutRow({ draft art). The raw unsupported-extension reason stays hidden in the Convert artwork banner's technical details. */} {needsConversion && ( -
+

- This cut’s artwork is a PNG. Convert it to WebP so it can be lettered and published. + This cut’s artwork is a PNG. Convert it to WebP so it can be + lettered and published.

- - { - const file = e.target.files?.[0]; - if (file) handleUpload(file); - e.target.value = ""; - }} - /> -
- - {/* #403: import a Codex-generated PNG straight from its cache, so a - writer never has to hunt through ~/.codex/generated_images in an - OS file dialog. Same in-browser PNG→WebP conversion + upload. */} +
-
- {showCodexPicker && ( - { - const ok = await handleUpload(file); - if (ok) setShowCodexPicker(false); + + { + const file = e.target.files?.[0]; + if (file) handleUpload(file); + e.target.value = ""; }} - onClose={() => setShowCodexPicker(false)} /> - )} - {!cut.cleanImagePath && ( -

- Generate this cut in Codex, then import the cached PNG with “Import from Codex” — or - upload an image manually. Letter it next. -

- )} - {status === "missing" && ( -
-

Generate this cut in Codex

-

- Copy the task below and paste it into Codex. Codex usually saves a PNG to its - image cache — bring it into this cut with “Import from Codex” above (the PNG - becomes a WebP automatically). If Codex instead writes a WebP/JPEG at{" "} - assets/{plotFile}/cut-{String(cut.id).padStart(2, "0")}-clean.webp, - it’s picked up by “Sync clean images”. -

+
+ {/* #403: import a Codex-generated PNG straight from its cache, so a + writer never has to hunt through ~/.codex/generated_images in an + OS file dialog. Same in-browser PNG→WebP conversion + upload. */} +
- )} - {status === "missing" && detectedLocalClean && ( - - )} - {uploadError && ( -

{uploadError}

- )} -
+ {showCodexPicker && ( + { + const ok = await handleUpload(file); + if (ok) setShowCodexPicker(false); + }} + onClose={() => setShowCodexPicker(false)} + /> + )} + {!cut.cleanImagePath && ( +

+ Generate this cut in Codex, then import the cached PNG with + “Import from Codex” — or upload an image manually. + Letter it next. +

+ )} + {status === "missing" && ( +
+

+ Generate this cut in Codex +

+

+ Copy the task below and paste it into Codex. Codex usually + saves a PNG to its image cache — bring it into this cut with + “Import from Codex” above (the PNG becomes a + WebP automatically). If Codex instead writes a WebP/JPEG at{" "} + + assets/{plotFile}/cut-{String(cut.id).padStart(2, "0")} + -clean.webp + + , it’s picked up by “Sync clean images”. +

+ +
+ )} + {status === "missing" && detectedLocalClean && ( + + )} + {uploadError && ( +

{uploadError}

+ )} +
)} {/* Open editor — image cuts, narration cuts, and text panels (#351) */} - {(cut.cleanImagePath || cut.narration || cut.dialogue.length > 0 || isTextPanel(cut)) && ( + {(cut.cleanImagePath || + cut.narration || + cut.dialogue.length > 0 || + isTextPanel(cut)) && ( +
); } @@ -970,12 +1272,17 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR return (

No cuts yet

-

Ask Claude to create a cut plan for this episode.

+

+ Ask Claude to create a cut plan for this episode. +

); } - const editingCut = editingCutId !== null ? cutsFile.cuts.find((c) => c.id === editingCutId) : null; + const editingCut = + editingCutId !== null + ? cutsFile.cuts.find((c) => c.id === editingCutId) + : null; if (editingCut) { return ( @@ -985,22 +1292,43 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR plotFile={plotFile} language={language} authFetch={authFetch} - targetLabel={isTextPanel(editingCut) ? `Between-scene card ${editingCut.id}` : `Cut ${String(editingCut.id).padStart(2, "0")}`} + targetLabel={ + isTextPanel(editingCut) + ? `Between-scene card ${editingCut.id}` + : `Cut ${String(editingCut.id).padStart(2, "0")}` + } returnOnSave + workspaceVisible={workspaceVisible} + onToggleWorkspaceVisible={ + onWorkspaceVisibleChange + ? () => onWorkspaceVisibleChange(!workspaceVisible) + : undefined + } onSave={async (overlays: Overlay[]) => { - const updated = { ...cutsFile, cuts: cutsFile.cuts.map((c) => c.id === editingCutId ? { ...c, overlays } : c) }; - const res = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(updated), - }); + const updated = { + ...cutsFile, + cuts: cutsFile.cuts.map((c) => + c.id === editingCutId ? { ...c, overlays } : c, + ), + }; + const res = await authFetch( + `/api/stories/${storyName}/cuts/${plotFile}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(updated), + }, + ); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || "Failed to save overlays"); } }} onExported={() => loadCuts()} - onClose={() => { setEditingCutId(null); loadCuts(); }} + onClose={() => { + setEditingCutId(null); + loadCuts(); + }} /> ); } @@ -1011,21 +1339,30 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR acc[s]++; return acc; }, - { missing: 0, clean: 0, lettered: 0, uploaded: 0, text: 0 } as Record, + { missing: 0, clean: 0, lettered: 0, uploaded: 0, text: 0 } as Record< + CutStatus, + number + >, ); // Text/interstitial panels need no clean image (#351), so the clean-assets // banner/claims reason about IMAGE cuts only — never the total cut count. const imageCutCount = cutsFile.cuts.filter((c) => !isTextPanel(c)).length; // #381: final images lettered by an older bubble renderer (separate-tail seam) // must be re-exported before publish. Only tailed speech bubbles are affected. - const staleTailIds = cutsFile.cuts.filter((c) => isStaleTailedExport(c)).map((c) => c.id); + const staleTailIds = cutsFile.cuts + .filter((c) => isStaleTailedExport(c)) + .map((c) => c.id); // Guided "Finish episode" state (#414). The checklist's publish step reflects the // real on-chain status; markdownReady distinguishes uploaded-but-not-prepared from // a prepared/ready-to-publish episode. canFinish = something the Finish action can // still do: a final to upload, or uploads done but the sequence not yet prepared. - const finishChecklist = cartoonChecklist({ cuts: cutsFile.cuts, published: episodeState.published }); - const uploadStepDone = finishChecklist.steps.find((s) => s.key === "upload")?.status === "done"; + const finishChecklist = cartoonChecklist({ + cuts: cutsFile.cuts, + published: episodeState.published, + }); + const uploadStepDone = + finishChecklist.steps.find((s) => s.key === "upload")?.status === "done"; const canFinish = cutsFile.cuts.some((ct) => ct.finalImagePath && !ct.uploadedCid) || (uploadStepDone && !episodeState.markdownReady); @@ -1035,7 +1372,9 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR const conversionJobs = (assetDiagnostics ?? []) .filter((d) => d.state === "needs-conversion" && d.convertiblePng) .map((d) => ({ cutId: d.cutId, pngPath: d.convertiblePng as string })); - const conversionByCut = new Map(conversionJobs.map((j) => [j.cutId, j.pngPath])); + const conversionByCut = new Map( + conversionJobs.map((j) => [j.cutId, j.pngPath]), + ); const conversionIssues = (assetDiagnostics ?? []) .filter((d) => d.state === "needs-conversion" && d.issue) .map((d) => d.issue as string); @@ -1044,99 +1383,152 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR // milestones, not internal fields: artwork found (any clean image incl. a draft // PNG), converted (publishable WebP/JPEG), lettered (bubbles placed/exported), // uploaded. PNG-only cuts read as "artwork found" but not yet "converted". - const episodeLabel = fileName === "genesis.md" - ? "Genesis / Episode 1" - : `Episode ${parseInt(plotFile.match(/\d+/)?.[0] ?? "0", 10) + 1}`; - const episodeTitle = typeof (cutsFile as { title?: unknown }).title === "string" ? (cutsFile as { title?: string }).title : null; + const episodeLabel = + fileName === "genesis.md" + ? "Genesis / Episode 1" + : `Episode ${parseInt(plotFile.match(/\d+/)?.[0] ?? "0", 10) + 1}`; + const episodeTitle = + typeof (cutsFile as { title?: unknown }).title === "string" + ? (cutsFile as { title?: string }).title + : null; const imageCuts = cutsFile.cuts.filter((c) => !isTextPanel(c)); const boardSummary = { cuts: cutsFile.cuts.length, - artwork: imageCuts.filter((c) => c.cleanImagePath || conversionByCut.has(c.id)).length, - converted: imageCuts.filter((c) => c.cleanImagePath && /\.(webp|jpe?g)$/i.test(c.cleanImagePath)).length, - lettered: cutsFile.cuts.filter((c) => (c.overlays?.length ?? 0) > 0 || !!c.finalImagePath).length, - uploaded: cutsFile.cuts.filter((c) => c.uploadedCid || c.uploadedUrl).length, + artwork: imageCuts.filter( + (c) => c.cleanImagePath || conversionByCut.has(c.id), + ).length, + converted: imageCuts.filter( + (c) => c.cleanImagePath && /\.(webp|jpe?g)$/i.test(c.cleanImagePath), + ).length, + lettered: cutsFile.cuts.filter( + (c) => (c.overlays?.length ?? 0) > 0 || !!c.finalImagePath, + ).length, + uploaded: cutsFile.cuts.filter((c) => c.uploadedCid || c.uploadedUrl) + .length, }; return ( -
+
{/* Episode header + creator-facing progress summary (#440). */} -
+
- {episodeLabel} - {episodeTitle && · {episodeTitle}} + + {episodeLabel} + + {episodeTitle && ( + · {episodeTitle} + )}
-
- {boardSummary.cuts} cuts · {boardSummary.artwork} artwork found · {boardSummary.converted} converted · {boardSummary.lettered} lettered · {boardSummary.uploaded} uploaded +
+ {boardSummary.cuts} cuts · {boardSummary.artwork} artwork found ·{" "} + {boardSummary.converted} converted · {boardSummary.lettered} lettered + · {boardSummary.uploaded} uploaded
{/* Lower-level / manual controls, collapsed by default so the board stays focused on per-cut actions (#440). The guided Finish flow + per-cut primary actions are the main path; these stay for power users. */} -
- Technical details -
- {cutsFile.cuts.length} cuts - {stats.missing > 0 && {stats.missing} missing} - {stats.clean > 0 && {stats.clean} clean} - {stats.lettered > 0 && {stats.lettered} lettered} - {stats.uploaded > 0 && {stats.uploaded} uploaded} - {stats.text > 0 && {stats.text} text {stats.text === 1 ? "panel" : "panels"}} - - - - - -
+ setGenerating(false); + }} + disabled={generating} + className="ml-auto px-2 py-0.5 border border-accent/30 text-accent rounded hover:bg-accent/5 disabled:opacity-50" + data-testid="generate-markdown-btn" + title="Build the publish-ready episode from the uploaded cut images" + > + {generating ? "Preparing…" : "Prepare episode for publish"} + + + + + +
{/* Plain-language workflow + text-panel explainer (#360) so a non-technical writer understands the order of operations and what a text panel is. */} @@ -1149,16 +1541,26 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
- 1. Letter + + 1. Letter + - 2. Export + + 2. Export + - 3. Upload + + 3. Upload + - 4. Prepare episode for publish + + 4. Prepare episode for publish +
- Use Add narration/text panel for a narration or title card. It becomes a solid card exported as a final image. + Use Add narration/text panel{" "} + for a narration or title card. It becomes a solid card exported as a + final image.
@@ -1171,23 +1573,38 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR className="px-3 py-1.5 border-b border-amber-500/40 bg-amber-500/10 text-[10px] text-amber-700 flex-shrink-0" data-testid="stale-bubble-export-warning" > - {staleTailIds.length === 1 ? "Cut" : "Cuts"} {staleTailIds.join(", ")} {staleTailIds.length === 1 ? "was" : "were"} lettered with an older speech-bubble style whose tail can show a visible seam. Re-export {staleTailIds.length === 1 ? "it" : "them"} (open lettering → Export) and re-upload before publishing so the bubble tails are seamless. + {staleTailIds.length === 1 ? "Cut" : "Cuts"} {staleTailIds.join(", ")}{" "} + {staleTailIds.length === 1 ? "was" : "were"} lettered with an older + speech-bubble style whose tail can show a visible seam. Re-export{" "} + {staleTailIds.length === 1 ? "it" : "them"} (open lettering → Export) + and re-upload before publishing so the bubble tails are seamless.
)} {/* Clean-asset generation done-state (#311): when every cut has a present, valid clean image, surface a clear "done" signal so the operator knows Codex generation is complete even if the terminal session is still connected — no more guessing whether it is still Working. */} - {detectConfirmed && imageCutCount > 0 && stats.missing === 0 && staleByCut.size === 0 && ( -
- - - All {imageCutCount} clean image{imageCutCount === 1 ? "" : "s"} present — clean-asset generation is complete. Ready for lettering in OWS. - -
- )} + {detectConfirmed && + imageCutCount > 0 && + stats.missing === 0 && + staleByCut.size === 0 && ( +
+ + + All {imageCutCount} clean image{imageCutCount === 1 ? "" : "s"}{" "} + present — clean-asset generation is complete. Ready for lettering + in OWS. + +
+ )} {syncResult && ( -
+
{syncResult}
)} @@ -1196,10 +1613,17 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR than red "Unsupported extension" errors. The raw reasons stay available under a collapsed "Technical details" disclosure. */} {conversionJobs.length > 0 && ( -
+
- - {conversionJobs.length} PNG image{conversionJobs.length === 1 ? "" : "s"} found + + {conversionJobs.length} PNG image + {conversionJobs.length === 1 ? "" : "s"} found

- PNG artwork is fine while drafting. Convert it before lettering/export so PlotLink can publish it safely. + PNG artwork is fine while drafting. Convert it before + lettering/export so PlotLink can publish it safely.

- {convertResult &&

{convertResult}

} + {convertResult && ( +

+ {convertResult} +

+ )} {conversionIssues.length > 0 && (
- Technical details + + Technical details +
    - {conversionIssues.map((m, i) =>
  • {m}
  • )} + {conversionIssues.map((m, i) => ( +
  • {m}
  • + ))}
)} @@ -1228,22 +1664,37 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR state tally + a precise per-cut reason when a recorded path is broken, so "files exist but aren't shown" / a typoed path is a clear diagnostic rather than a generic publish warning. */} - {assetDiagnostics && assetDiagnostics.length > 0 && (() => { - const s = summarizeAssetDiagnostics(assetDiagnostics); - const missing = assetDiagnostics.filter((d) => d.state === "missing"); - return ( -
- - Assets: {s.uploaded} uploaded · {s.finalReady} final · {s.cleanReady} clean · {s.planned} planned{s.needsConversion > 0 ? ` · ${s.needsConversion} needs conversion` : ""}{s.missing > 0 ? ` · ${s.missing} missing` : ""} - - {missing.length > 0 && ( -
    - {missing.map((d) =>
  • {d.issue}
  • )} -
- )} -
- ); - })()} + {assetDiagnostics && + assetDiagnostics.length > 0 && + (() => { + const s = summarizeAssetDiagnostics(assetDiagnostics); + const missing = assetDiagnostics.filter((d) => d.state === "missing"); + return ( +
+ + Assets: {s.uploaded} uploaded · {s.finalReady} final ·{" "} + {s.cleanReady} clean · {s.planned} planned + {s.needsConversion > 0 + ? ` · ${s.needsConversion} needs conversion` + : ""} + {s.missing > 0 ? ` · ${s.missing} missing` : ""} + + {missing.length > 0 && ( +
    + {missing.map((d) => ( +
  • {d.issue}
  • + ))} +
+ )} +
+ ); + })()} {/* Guided Finish-episode flow (#414): writer-language step status, one primary "Finish episode" action that uploads finals then prepares the publish markdown in order, and any blockers grouped by the step that fixes them — @@ -1262,12 +1713,19 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR {/* Full cut review (#488): all clean cuts are shown vertically first, with explicit between-scene slots for narration/title cards. */} -
+
{cutsFile.cuts.map((cut, index) => ( addTextPanelAt(index)} @@ -1277,9 +1735,15 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR storyName={storyName} plotFile={plotFile} expanded={expandedCut === cut.id} - onToggle={() => setExpandedCut(expandedCut === cut.id ? null : cut.id)} + onToggle={() => + setExpandedCut(expandedCut === cut.id ? null : cut.id) + } authFetch={authFetch} - onUpdated={() => { loadCuts(); loadDetect(); loadDiagnostics(); }} + onUpdated={() => { + loadCuts(); + loadDetect(); + loadDiagnostics(); + }} onOpenEditor={() => setEditingCutId(cut.id)} detectedLocalClean={detected.has(cut.id)} onSyncClean={syncCleanImages} @@ -1290,7 +1754,10 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR conversionPng={conversionByCut.get(cut.id) ?? null} onConvert={convertCut} converting={converting} - rowRef={(el) => { if (el) rowRefs.current.set(cut.id, el); else rowRefs.current.delete(cut.id); }} + rowRef={(el) => { + if (el) rowRefs.current.set(cut.id, el); + else rowRefs.current.delete(cut.id); + }} /> ))} @@ -1325,8 +1792,12 @@ function BetweenSceneSlot({ data-testid={`between-scene-slot-${index}`} > - Between-scene lettering - {beforeLabel} · {afterLabel} + + Between-scene lettering + + + {beforeLabel} · {afterLabel} + + {onToggleWorkspaceVisible && ( + + )}
- - - + + +
- {exportError && {exportError}} - {saveError && {saveError}} - - - + +
{invalidOverlayCount > 0 && !acknowledgedInvalid ? ( -
+
- {invalidOverlayCount} overlay{invalidOverlayCount === 1 ? "" : "s"} from the cut plan {invalidOverlayCount === 1 ? "has" : "have"} no usable position and cannot be exported. Re-place {invalidOverlayCount === 1 ? "it" : "them"}, or + {invalidOverlayCount} overlay{invalidOverlayCount === 1 ? "" : "s"}{" "} + from the cut plan {invalidOverlayCount === 1 ? "has" : "have"} no + usable position and cannot be exported. Re-place{" "} + {invalidOverlayCount === 1 ? "it" : "them"}, or
) : invalidOverlayCount > 0 ? ( -
- Discarded {invalidOverlayCount} unplaceable overlay{invalidOverlayCount === 1 ? "" : "s"} — the export will not include {invalidOverlayCount === 1 ? "it" : "them"}. +
+ Discarded {invalidOverlayCount} unplaceable overlay + {invalidOverlayCount === 1 ? "" : "s"} — the export will not include{" "} + {invalidOverlayCount === 1 ? "it" : "them"}.
) : autoPlacedOverlays ? ( -
- Auto-placed overlays from the cut plan — review their positions before exporting. +
+ Auto-placed overlays from the cut plan — review their positions before + exporting.
) : null} @@ -592,9 +840,14 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE className="px-3 py-1 border-b border-border bg-amber-500/10 text-[10px] text-amber-700" data-testid="overlay-overlap-warning" > - Cut #{cut.id}: {overlapPairs.length} bubble {overlapPairs.length === 1 ? "pair overlaps" : "pairs overlap"} and may be hard to read —{" "} + Cut #{cut.id}: {overlapPairs.length} bubble{" "} + {overlapPairs.length === 1 ? "pair overlaps" : "pairs overlap"} and + may be hard to read —{" "} {overlapPairs - .map((p) => `#${p.indexA + 1} ${overlapLabel(overlays[p.indexA])} ↔ #${p.indexB + 1} ${overlapLabel(overlays[p.indexB])}`) + .map( + (p) => + `#${p.indexA + 1} ${overlapLabel(overlays[p.indexA])} ↔ #${p.indexB + 1} ${overlapLabel(overlays[p.indexB])}`, + ) .join("; ")} . Move them apart, or export as-is if the overlap is intended.
@@ -606,13 +859,19 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE className="px-3 py-1 border-b border-border flex items-center gap-3 flex-wrap text-[10px] text-muted" data-testid="lettering-checklist" > - {([ - ["clean-image", "Clean image", checklist.hasCleanImage], - ["script-text", "Script text", checklist.hasScriptText], - ["bubbles", `Bubbles placed${checklist.bubblesPlaced ? ` (${checklist.bubblesPlaced})` : ""}`, checklist.bubblesPlaced > 0], - ["exported", "Final exported", checklist.exported], - ["uploaded", "Uploaded", checklist.uploaded], - ] as [string, string, boolean][]).map(([key, label, done]) => ( + {( + [ + ["clean-image", "Clean image", checklist.hasCleanImage], + ["script-text", "Script text", checklist.hasScriptText], + [ + "bubbles", + `Bubbles placed${checklist.bubblesPlaced ? ` (${checklist.bubblesPlaced})` : ""}`, + checklist.bubblesPlaced > 0, + ], + ["exported", "Final exported", checklist.exported], + ["uploaded", "Uploaded", checklist.uploaded], + ] as [string, string, boolean][] + ).map(([key, label, done]) => ( - Bubbles changed since the last export — re-export this cut and upload the new final image before publishing. + Bubbles changed since the last export — re-export this cut and upload + the new final image before publishing.
)} @@ -644,11 +904,15 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE className="px-3 py-1 border-b border-border bg-amber-500/10 text-[10px] text-amber-700" data-testid="lettering-export-warning" > - {warningCount} bubble{warningCount === 1 ? "" : "s"} may not export cleanly:{" "} + {warningCount} bubble{warningCount === 1 ? "" : "s"} may not export + cleanly:{" "} {Object.entries(overlayWarnings) .map(([id, w]) => { const idx = overlays.findIndex((o) => o.id === id); - const problems = [w.outOfBounds ? "outside image" : null, w.overflow ? "text overflow" : null] + const problems = [ + w.outOfBounds ? "outside image" : null, + w.overflow ? "text overflow" : null, + ] .filter(Boolean) .join(", "); return `#${idx + 1} ${overlapLabel(overlays[idx])} (${problems})`; @@ -667,11 +931,17 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE data-testid="editor-surface" > {cut.cleanImagePath && cleanAsset.error ? ( -
+
Clean image not available
) : cut.cleanImagePath && !cleanAsset.url ? ( -
+
Loading clean image…
) : cut.cleanImagePath ? ( @@ -708,7 +978,12 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE if (el && imageBounds.width === 0) { const rect = el.getBoundingClientRect(); if (rect.width > 0) { - setImageBounds({ x: 0, y: 0, width: rect.width, height: rect.height }); + setImageBounds({ + x: 0, + y: 0, + width: rect.width, + height: rect.height, + }); } } }} @@ -725,15 +1000,22 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE Tailless speech (no tailAnchor, or a tip inside the bubble) traces a plain rounded rectangle. Tail-anchor edits update the path live. */} {imageBounds.width > 0 && ( - + {overlays.map((overlay) => { if (overlay.type !== "speech") return null; - const ox = imageBounds.x + toPixel(overlay.x, imageBounds.width); - const oy = imageBounds.y + toPixel(overlay.y, imageBounds.height); + const ox = + imageBounds.x + toPixel(overlay.x, imageBounds.width); + const oy = + imageBounds.y + toPixel(overlay.y, imageBounds.height); const ow = toPixel(overlay.width, imageBounds.width); const oh = toPixel(overlay.height, imageBounds.height); const radius = balloonRadiusForOverlay(overlay, ow, oh); - const tail = overlay.tailAnchor ? speechTailPoints(ox, oy, ow, oh, overlay.tailAnchor, radius) : null; + const tail = overlay.tailAnchor + ? speechTailPoints(ox, oy, ow, oh, overlay.tailAnchor, radius) + : null; // Strong, clean near-black outline scaled to the preview size so // the bubble reads as a webtoon balloon (matching the export's // proportional stroke), not a faint UI box (#363). @@ -753,115 +1035,153 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE )} - {imageBounds.width > 0 && overlays.map((overlay) => { - const left = imageBounds.x + toPixel(overlay.x, imageBounds.width); - const top = imageBounds.y + toPixel(overlay.y, imageBounds.height); - const width = toPixel(overlay.width, imageBounds.width); - const height = toPixel(overlay.height, imageBounds.height); - const isSelected = overlay.id === selectedId; - // Speech bubbles draw no body border here — the integrated balloon - // in the layer below is their outline, so a box border would - // re-introduce the body/tail seam (#327). Their selection cue is the - // path's accent stroke (plus the resize handle). Narration/SFX keep - // their bordered box + selection ring as before. - const isSpeech = overlay.type === "speech"; - // Narration reads as an intentional parchment caption card (rounded, - // filled), mirroring the export, instead of an empty bordered box (#363). - const isNarration = overlay.type === "narration"; - const warned = !!overlayWarnings[overlay.id]; - - return ( -
handleOverlayClick(e, overlay.id)} - onMouseDown={(e) => handleMouseDown(e, overlay.id, "move")} - className={`absolute rounded cursor-move select-none ${ - isSpeech ? "" : `border-2 ${TYPE_BORDER[overlay.type]}` - } ${isNarration ? "bg-[#f4efe6]/85 rounded-md" : ""} ${ - isSelected && !isSpeech ? "ring-2 ring-accent" : "" - } ${warned ? "ring-2 ring-amber-500" : ""}`} - style={{ left, top, width, height }} - > - {(() => { - const fontFamily = overlay.type === "sfx" ? displayFontFamily : bodyFontFamily; - if (!overlay.text) { - return ( - - {TYPE_LABEL[overlay.type]} - + {imageBounds.width > 0 && + overlays.map((overlay) => { + const left = + imageBounds.x + toPixel(overlay.x, imageBounds.width); + const top = + imageBounds.y + toPixel(overlay.y, imageBounds.height); + const width = toPixel(overlay.width, imageBounds.width); + const height = toPixel(overlay.height, imageBounds.height); + const isSelected = overlay.id === selectedId; + // Speech bubbles draw no body border here — the integrated balloon + // in the layer below is their outline, so a box border would + // re-introduce the body/tail seam (#327). Their selection cue is the + // path's accent stroke (plus the resize handle). Narration/SFX keep + // their bordered box + selection ring as before. + const isSpeech = overlay.type === "speech"; + // Narration reads as an intentional parchment caption card (rounded, + // filled), mirroring the export, instead of an empty bordered box (#363). + const isNarration = overlay.type === "narration"; + const warned = !!overlayWarnings[overlay.id]; + + return ( +
handleOverlayClick(e, overlay.id)} + onMouseDown={(e) => handleMouseDown(e, overlay.id, "move")} + className={`absolute rounded cursor-move select-none ${ + isSpeech ? "" : `border-2 ${TYPE_BORDER[overlay.type]}` + } ${isNarration ? "bg-[#f4efe6]/85 rounded-md" : ""} ${ + isSelected && !isSpeech ? "ring-2 ring-accent" : "" + } ${warned ? "ring-2 ring-amber-500" : ""}`} + style={{ left, top, width, height }} + > + {(() => { + const fontFamily = + overlay.type === "sfx" + ? displayFontFamily + : bodyFontFamily; + if (!overlay.text) { + return ( + + {TYPE_LABEL[overlay.type]} + + ); + } + const hasSpeaker = + overlay.type !== "sfx" && !!overlay.speaker; + if (!fontsReady) { + // Until the web font's metrics are available, don't freeze + // canvas-measured line breaks from fallback metrics (they + // would diverge from export). Show a CSS-wrapped transient; + // the exact layout computes once fonts are ready (#310, re1). + return ( +
+ {hasSpeaker + ? `${overlay.speaker}: ${overlay.text}` + : overlay.text} +
+ ); + } + const layout = layoutBubbleText( + measureWidth(fontFamily), + overlay.text, + width, + height, + bubbleLayoutOptionsForOverlay( + overlay, + imageBounds.height, + width, + height, + ), ); - } - const hasSpeaker = overlay.type !== "sfx" && !!overlay.speaker; - if (!fontsReady) { - // Until the web font's metrics are available, don't freeze - // canvas-measured line breaks from fallback metrics (they - // would diverge from export). Show a CSS-wrapped transient; - // the exact layout computes once fonts are ready (#310, re1). return (
- {hasSpeaker ? `${overlay.speaker}: ${overlay.text}` : overlay.text} + {hasSpeaker && ( + + {overlay.speaker} + + )} + + {layout.lines.map((line, i) => ( + + {line} + + ))} +
); - } - const layout = layoutBubbleText( - measureWidth(fontFamily), - overlay.text, - width, - height, - bubbleLayoutOptionsForOverlay(overlay, imageBounds.height, width, height), - ); - return ( + })()} + {isSelected && (
- {hasSpeaker && ( - - {overlay.speaker} - - )} - - {layout.lines.map((line, i) => ( - {line} - ))} - -
- ); - })()} - {isSelected && ( -
{ e.stopPropagation(); handleMouseDown(e, overlay.id, "resize"); }} - className="absolute bottom-0 right-0 w-2 h-2 bg-accent cursor-se-resize" - data-testid={`resize-${overlay.id}`} - /> - )} -
- ); - })} + onMouseDown={(e) => { + e.stopPropagation(); + handleMouseDown(e, overlay.id, "resize"); + }} + className="absolute bottom-0 right-0 w-2 h-2 bg-accent cursor-se-resize" + data-testid={`resize-${overlay.id}`} + /> + )} +
+ ); + })}
{/* Inspector panel */}
-
-

AI draft assist

+
+

+ AI draft assist +

- Copy a prompt scoped to {targetLabel ?? `cut ${cut.id}`}. Review and edit any drafted bubbles here before saving. + Copy a prompt scoped to {targetLabel ?? `cut ${cut.id}`}. Review + and edit any drafted bubbles here before saving.

))} @@ -898,14 +1224,22 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE )} {selectedOverlay ? (
-

{TYPE_LABEL[selectedOverlay.type]}

+

+ {TYPE_LABEL[selectedOverlay.type]} +

{selectedOverlay.speaker !== undefined && (