diff --git a/app/lib/cartoon-coach.test.ts b/app/lib/cartoon-coach.test.ts index 3fce3d7..5861bbb 100644 --- a/app/lib/cartoon-coach.test.ts +++ b/app/lib/cartoon-coach.test.ts @@ -125,12 +125,20 @@ describe("deriveCartoonCoach (#429)", () => { expect(c.stageLabel).toBe("Ready to publish"); }); - it("text panels never gate the clean/letter stage (#350)", () => { - // One bare text panel: needClean === 0, so the coach skips the clean and - // lettering stages and goes straight to exporting the panel's final image. + it("text panels skip clean but still gate the letter stage (#350/#488)", () => { + // One bare text panel: needClean === 0, so the coach skips clean images, but + // still keeps the creator in the focused lettering workflow before export. const c = deriveCartoonCoach(cartoon([ep({ file: "plot-01.md", cuts: [textPanel(1)] })]))!; expect(c.uiAction).toBe("open-lettering"); - expect(c.action).toMatch(/export/i); + expect(c.action).toMatch(/lettering/i); + expect(c.stageLabel).toBe("Clean images ready"); + }); + + it("empty text panels keep the coach at lettering after image cuts are lettered (#488 re2)", () => { + const c = deriveCartoonCoach(cartoon([ep({ file: "plot-01.md", cuts: [lettered(1), textPanel(2)] })]))!; + expect(c.uiAction).toBe("open-lettering"); + expect(c.action).toMatch(/lettering/i); + expect(c.action).not.toMatch(/export/i); }); it("focus on an unfinished episode overrides the story's active episode", () => { diff --git a/app/lib/cartoon-coach.ts b/app/lib/cartoon-coach.ts index 8b1743b..61be8ed 100644 --- a/app/lib/cartoon-coach.ts +++ b/app/lib/cartoon-coach.ts @@ -168,7 +168,7 @@ function coachForEpisode(ep: EpisodeProgress, undetectedClean: number): CartoonC } // 2) Lettering — place speech bubbles & captions in the cut workspace. - if (c.withText < c.needClean) { + if (c.withText < c.total) { return ui("Clean images ready", "Review cuts and start lettering", "open-lettering", file); } diff --git a/app/lib/cartoon-readiness.test.ts b/app/lib/cartoon-readiness.test.ts index 4d468c9..da61229 100644 --- a/app/lib/cartoon-readiness.test.ts +++ b/app/lib/cartoon-readiness.test.ts @@ -592,6 +592,7 @@ describe("text panels (#350)", () => { expect(p.total).toBe(2); expect(p.needClean).toBe(1); // only the image cut expect(p.withClean).toBe(1); + expect(p.withText).toBe(1); // the image cut is lettered; the empty text panel is not expect(p.exported).toBe(2); // both panels exported expect(p.uploaded).toBe(2); }); @@ -610,14 +611,15 @@ describe("text panels (#350)", () => { expect(checkCartoonReadiness(cuts).ready).toBe(true); }); - it("cartoonChecklist: an all-text episode skips clean/letter and points at export", () => { + it("cartoonChecklist: an all-text episode skips clean but still requires text-card lettering", () => { const r = cartoonChecklist({ cuts: [makeCut({ id: 1, kind: "text" })] }); const statusOf = (k: string) => r.steps.find((s) => s.key === k)!.status; expect(statusOf("clean")).toBe("done"); // no image cuts to clean - expect(statusOf("letter")).toBe("done"); - expect(statusOf("export")).toBe("current"); + expect(statusOf("letter")).toBe("current"); + expect(statusOf("export")).toBe("todo"); expect(r.steps.find((s) => s.key === "clean")!.detail).toBe("no image cuts"); - expect(r.nextStep).toMatch(/export/i); + expect(r.steps.find((s) => s.key === "letter")!.detail).toBe("0 / 1 cut"); + expect(r.nextStep).toMatch(/lettering editor|speech bubbles/i); }); it("cartoonChecklist: a mixed plan still gates clean on the image cut", () => { @@ -627,6 +629,20 @@ describe("text panels (#350)", () => { expect(statusOf("clean")).toBe("current"); expect(r.steps.find((s) => s.key === "clean")!.detail).toBe("0 / 1 cut"); }); + + it("cartoonChecklist: an empty text panel keeps lettering current even after image cuts are lettered (#488 re2)", () => { + const cuts = [ + makeCut(imageDone(1)), + makeCut({ id: 2, kind: "text", overlays: [] }), + ]; + const r = cartoonChecklist({ cuts }); + const statusOf = (k: string) => r.steps.find((s) => s.key === k)!.status; + expect(statusOf("clean")).toBe("done"); + expect(statusOf("letter")).toBe("current"); + expect(statusOf("export")).toBe("todo"); + expect(r.steps.find((s) => s.key === "letter")!.detail).toBe("1 / 2 cuts"); + expect(r.nextStep).toMatch(/lettering editor|speech bubbles/i); + }); }); describe("cartoonGenesisReadiness (#359, hardened in #400)", () => { diff --git a/app/lib/cartoon-readiness.ts b/app/lib/cartoon-readiness.ts index 7ed5850..3104a6f 100644 --- a/app/lib/cartoon-readiness.ts +++ b/app/lib/cartoon-readiness.ts @@ -492,7 +492,7 @@ export interface CartoonCutProgress { needClean: number; /** Of `needClean`, how many have a clean image recorded. */ withClean: number; - /** Of the clean-image cuts, how many have text overlays placed. */ + /** Cuts with lettering overlays placed. Image cuts still require clean art first; text panels are first-class lettering targets. */ withText: number; /** Cuts (any kind) with an exported final image. */ exported: number; @@ -517,8 +517,8 @@ export function summarizeCutProgress(cuts: Cut[]): CartoonCutProgress { let uploaded = 0; for (const cut of cuts) { // Image cuts need a clean image → lettering; text/interstitial panels (#350) - // do not (they're text on a styled background). Every panel still exports + - // uploads a final image, so those are counted for both kinds. + // do not (they're text on a styled background). Text panels still require + // lettering overlays before the shared workflow can advance to export (#488). if (!isTextPanel(cut)) { needClean++; // A PNG clean image is a draft intermediate, not a finished clean asset @@ -531,6 +531,8 @@ export function summarizeCutProgress(cuts: Cut[]): CartoonCutProgress { // every cut-list render now (#414), so a bad persisted cut must not crash it. if ((cut.overlays?.length ?? 0) > 0) withText++; } + } else if ((cut.overlays?.length ?? 0) > 0) { + withText++; } if (cut.finalImagePath && cut.exportedAt) exported++; if (cut.uploadedUrl) uploaded++; @@ -583,12 +585,12 @@ export function cartoonChecklist(input: { cuts: Cut[]; published?: boolean }): C const p = summarizeCutProgress(cuts); if (p.total === 0) return { steps: [], nextStep: null }; - // Clean + letter gate only IMAGE cuts (needClean); export + upload gate EVERY - // cut including text panels (total). For an all-image story needClean === total - // so this is unchanged from before (#350). + // Clean gates only IMAGE cuts (needClean); lettering/export/upload gate EVERY + // cut including text panels. Text panels need no clean art, but they are still + // editable lettering targets before export (#488). const planDone = p.total > 0; const cleanDone = planDone && p.withClean === p.needClean; - const letterDone = cleanDone && p.withText === p.needClean; + const letterDone = cleanDone && p.withText === p.total; const exportDone = letterDone && p.exported === p.total; const uploadDone = exportDone && p.uploaded === p.total; const publishDone = uploadDone && published; @@ -604,13 +606,13 @@ export function cartoonChecklist(input: { cuts: Cut[]; published?: boolean }): C const order: CartoonStepKey[] = ["plan", "clean", "letter", "export", "upload", "publish"]; const currentIdx = order.findIndex((k) => !complete[k]); - // Clean/letter count image cuts (needClean); export/upload count every cut + // Clean counts image cuts (needClean); lettering/export/upload count every cut // (total). An all-text-panel episode has needClean === 0 → "no image cuts". const imageDetail = (done: number) => (p.needClean > 0 ? fraction(done, p.needClean) : "no image cuts"); const detail: Record = { plan: fraction(p.total, p.total), clean: imageDetail(p.withClean), - letter: imageDetail(p.withText), + letter: fraction(p.withText, p.total), export: fraction(p.exported, p.total), upload: fraction(p.uploaded, p.total), publish: null, @@ -702,7 +704,7 @@ export function previewFooterGuidance(ctx: PreviewFooterContext): string | null if (p.withClean < p.needClean) { return "Genesis has a cut plan — generate the clean images for its cuts next."; } - if (p.withText < p.needClean) { + if (p.withText < p.total) { return "Genesis clean art is ready — review the cuts and add speech bubbles & captions next."; } if (p.exported < p.total) { diff --git a/app/lib/story-progress.ts b/app/lib/story-progress.ts index 6531af9..6aea998 100644 --- a/app/lib/story-progress.ts +++ b/app/lib/story-progress.ts @@ -38,9 +38,8 @@ export interface EpisodeProgress { summary: string; published: boolean; /** - * Cartoon cut progress; null for fiction. `needClean`/`withText` count IMAGE - * cuts only (text panels are excluded), so the workflow coach (#429) can tell - * the clean-image stage from the lettering stage. + * Cartoon cut progress; null for fiction. `needClean`/`withClean` count image + * cuts only; `withText`, export, and upload count every cut including text panels. */ cuts: { total: number; needClean: number; withClean: number; withText: number; exported: number; uploaded: number } | null; /** diff --git a/app/web/components/CartoonPublishPage.tsx b/app/web/components/CartoonPublishPage.tsx index 22f9c2f..0ce8f43 100644 --- a/app/web/components/CartoonPublishPage.tsx +++ b/app/web/components/CartoonPublishPage.tsx @@ -178,7 +178,7 @@ export function CartoonPublishPage({ storyName, authFetch, onOpenFile, onOpenSto { label: "Opening text ready", status: "done" }, // the episode exists once it appears here { label: "Cut plan", status: c && c.total > 0 ? "done" : "todo", detail: c ? `${c.total} cut${c.total === 1 ? "" : "s"} planned` : "not started" }, { label: "Clean images converted", status: c && c.needClean > 0 && c.withClean === c.needClean ? "done" : "todo", detail: c ? `${c.withClean} / ${c.needClean}` : null }, - { label: "Cuts lettered", status: c && c.needClean > 0 && c.withText === c.needClean ? "done" : "todo", detail: c ? `${c.withText} / ${c.needClean}` : null }, + { label: "Cuts lettered", status: c && c.total > 0 && c.withText === c.total ? "done" : "todo", detail: c ? `${c.withText} / ${c.total}` : null }, { label: "Final images exported", status: c && c.total > 0 && c.exported === c.total ? "done" : "todo", detail: c ? `${c.exported} / ${c.total}` : null }, { label: "Final images uploaded", status: c && c.total > 0 && c.uploaded === c.total ? "done" : "todo", detail: c ? `${c.uploaded} / ${c.total}` : null }, { label: "Cover image", status: coverDone ? "done" : "todo", detail: coverDone ? null : "recommended before publishing" }, diff --git a/app/web/components/CutListPanel.test.tsx b/app/web/components/CutListPanel.test.tsx index 24ad877..4346bb9 100644 --- a/app/web/components/CutListPanel.test.tsx +++ b/app/web/components/CutListPanel.test.tsx @@ -100,7 +100,7 @@ describe("CutListPanel", () => { render(); await waitFor(() => { - expect(screen.getByText("Exported")).toBeInTheDocument(); + expect(screen.getByTestId("cut-card-status-1")).toHaveTextContent("Exported"); expect(screen.getByText("1 lettered")).toBeInTheDocument(); }); }); @@ -150,7 +150,7 @@ describe("CutListPanel", () => { const authFetch = mockAuthFetch({ ok: true, data: cutsData }); render(); - const scroll = await screen.findByTestId("cut-list-scroll"); + const scroll = await screen.findByTestId("lettering-review-board"); expect(scroll).toHaveClass("min-h-56"); fireEvent.click(screen.getByText("Cut 1 scene")); @@ -1021,10 +1021,11 @@ describe("CutListPanel asset diagnostics + Refresh assets (#427)", () => { 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"); - // A clean, un-lettered cut shows the first-class lettering choice (#442). - expect(screen.getByTestId("lettering-2")).toBeInTheDocument(); - expect(screen.getByTestId("add-bubbles-2")).toBeInTheDocument(); + 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"); // Technical controls live under a collapsed-by-default Details disclosure. const advanced = screen.getByTestId("cut-advanced"); @@ -1033,9 +1034,9 @@ describe("CutListPanel asset diagnostics + Refresh assets (#427)", () => { expect(within(advanced).getByTestId("sync-clean-btn")).toBeInTheDocument(); }); - // #442: lettering is a first-class, visible step with an intentional - // Manual vs AI-draft choice, and "Review lettering" once bubbles exist. - it("offers a Manual/AI-draft lettering choice on a clean cut and 'Review lettering' once bubbles exist", async () => { + // #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 fn = vi.fn((url: string) => { if (url.includes("/asset-diagnostics")) { @@ -1057,24 +1058,60 @@ describe("CutListPanel asset diagnostics + Refresh assets (#427)", () => { render(); await screen.findByTestId("cut-list-panel"); - // Cut 1 (no bubbles): the lettering choice + "Add speech bubbles" (Manual is default). - const lettering = await screen.findByTestId("lettering-1"); - expect(within(lettering).getByTestId("lettering-mode-manual-1")).toBeChecked(); - expect(screen.getByTestId("add-bubbles-1")).toHaveTextContent("Add speech bubbles"); + // Cut 1 (no bubbles): review opens the focused editor. + 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"); - // Switch to AI draft → the copy-prompt CTA appears and copies the lettering prompt. - fireEvent.click(screen.getByTestId("lettering-mode-ai-1")); - fireEvent.click(screen.getByTestId("copy-lettering-1")); + // 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"); expect(copied).toMatch(/do NOT export or upload/i); - // Cut 2 (bubbles already placed): status "Needs review" + a "Review lettering" CTA. + 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"); }); + it("lets a between-scene slot create a focused text-card editor and Save returns to review (#488)", async () => { + let cuts = [ + makeCut({ id: 1, cleanImagePath: "assets/genesis/cut-01-clean.webp" }), + makeCut({ id: 2, cleanImagePath: "assets/genesis/cut-02-clean.webp" }), + ]; + 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 }, + }) }); + } + 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 }) }); + } + 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(); + + 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"); + + 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"); + }); + 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")) { diff --git a/app/web/components/CutListPanel.tsx b/app/web/components/CutListPanel.tsx index 6741a93..be16e20 100644 --- a/app/web/components/CutListPanel.tsx +++ b/app/web/components/CutListPanel.tsx @@ -1,7 +1,7 @@ -import { useState, useEffect, useCallback, useRef } from "react"; +import { Fragment, useState, useEffect, useCallback, useRef } from "react"; import { LetteringEditor } from "./LetteringEditor"; import { AssetImage, assetUrl } from "./asset-image"; -import { buildCodexTaskPrompt, buildLetteringPrompt } from "@app-lib/cartoon-prompt"; +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"; @@ -141,6 +141,25 @@ 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 } { + if (cut.uploadedCid || cut.uploadedUrl) { + return { label: "Complete", detail: "Final image uploaded", tone: "green" }; + } + if (cut.finalImagePath || cut.exportedAt) { + 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" }; + } + if (isTextPanel(cut)) { + 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: "Needs artwork", detail: "Add or sync clean art first", tone: "muted" }; +} + function CutRow({ cut, storyName, @@ -190,10 +209,6 @@ function CutRow({ // generated PNG into this cut without hunting through a hidden folder. const [showCodexPicker, setShowCodexPicker] = useState(false); const [convertingThis, setConvertingThis] = useState(false); - // Lettering is a first-class step (#442): an intentional Manual vs AI-draft - // choice per cut, surfaced on the card (not hidden under Edit). - const [letteringMode, setLetteringMode] = useState<"manual" | "ai">("manual"); - const [letteringCopied, setLetteringCopied] = useState(false); const status = getCutStatus(cut); // A recorded cleanImagePath/finalImagePath whose file is missing/invalid (#302): // surface it precisely rather than letting the field-based status claim the cut @@ -267,18 +282,13 @@ function CutRow({ !isTextPanel(cut) && !!cut.cleanImagePath && !cut.finalImagePath && !cut.uploadedCid && !cut.uploadedUrl && !hasStale && !needsConversion; - const copyLetteringPrompt = useCallback(() => { - navigator.clipboard?.writeText(buildLetteringPrompt(cut as unknown as LibCut, plotFile)); - setLetteringCopied(true); - setTimeout(() => setLetteringCopied(false), 2000); - }, [cut, plotFile]); - 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 + const reviewState = letteringReviewState(cut); return (
) : ( -
+
{isTextPanel(cut) ? "Text panel — no artwork needed" : "No artwork yet"}
)} +
+ {reviewState.label} + · {reviewState.detail} +
- {/* Lettering is a first-class, visible step (#442): an intentional - Manual vs AI-draft choice on the card, then the matching CTA. */} - {atLetteringStage && ( -
-
Lettering
- - - {letteringMode === "ai" && ( -

- Paste it to your agent, then review the draft bubbles here and export the final cut. -

- )} -
- )}
{atLetteringStage ? ( - letteringMode === "manual" ? ( - - ) : ( - - ) + ) : primary ? ( +
+ ); +} diff --git a/app/web/components/LetteringEditor.test.tsx b/app/web/components/LetteringEditor.test.tsx index fc22603..a355616 100644 --- a/app/web/components/LetteringEditor.test.tsx +++ b/app/web/components/LetteringEditor.test.tsx @@ -644,7 +644,7 @@ describe("LetteringEditor", () => { expect(screen.getByTestId("inspector-font")).toHaveTextContent("Noto Sans KR"); }); - it("calls onClose when Close button is clicked", () => { + it("calls onClose when Cancel button is clicked", () => { const onClose = vi.fn(); render( { />, ); - fireEvent.click(screen.getByText("Close")); + fireEvent.click(screen.getByText("Cancel")); + expect(onClose).toHaveBeenCalled(); + }); + + it("offers scoped AI draft assistance and can return to review after Save (#488)", async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const onClose = vi.fn(); + Object.assign(navigator, { clipboard: { writeText: vi.fn() } }); + render( + , + ); + + expect(screen.getByTestId("focused-lettering-editor")).toHaveTextContent("Focused lettering editor"); + fireEvent.click(screen.getByTestId("copy-ai-lettering-current")); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(expect.stringContaining("cut 1 of plot-01")); + + fireEvent.click(screen.getByTestId("add-speech")); + fireEvent.click(screen.getByTestId("save-lettering-btn")); + await waitFor(() => expect(onSave).toHaveBeenCalled()); expect(onClose).toHaveBeenCalled(); }); diff --git a/app/web/components/LetteringEditor.tsx b/app/web/components/LetteringEditor.tsx index b5bdb1f..e99fc2c 100644 --- a/app/web/components/LetteringEditor.tsx +++ b/app/web/components/LetteringEditor.tsx @@ -22,6 +22,8 @@ import { import { layoutBubbleText } from "@app-lib/bubble-text"; import { cutLetteringChecklist, cutScriptLines, isExportStale, overlaysSignature, type ScriptLine } from "@app-lib/lettering-status"; import { textPanelDimensions } from "@app-lib/cuts"; +import { buildLetteringPrompt } from "@app-lib/cartoon-prompt"; +import type { Cut as LibCut } from "@app-lib/cuts"; import { useAuthedAsset } from "./asset-image"; function toPixel(norm: number, size: number): number { @@ -72,6 +74,10 @@ interface LetteringEditorProps { onExported?: () => void; language?: string; authFetch: (url: string, opts?: RequestInit) => Promise; + /** Focused-editor header label supplied by the review board (#488). */ + targetLabel?: string; + /** When true, the Save button returns to the review board after persisting. */ + returnOnSave?: boolean; } const TYPE_LABEL: Record = { @@ -106,7 +112,7 @@ function clamp(v: number, min: number, max: number): number { return Math.min(max, Math.max(min, v)); } -export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onExported, language = "English", authFetch }: LetteringEditorProps) { +export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onExported, language = "English", authFetch, targetLabel, returnOnSave = false }: LetteringEditorProps) { const bodyFont = getDefaultFont(language); const displayFont = getDisplayFont(); const bodyFontFamily = getFontFamily(bodyFont); @@ -176,6 +182,8 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE const [confirmDelete, setConfirmDelete] = useState(false); const [exporting, setExporting] = useState(false); const [exportError, setExportError] = useState(null); + const [saveError, setSaveError] = useState(null); + const [aiCopied, setAiCopied] = useState(false); const [imageBounds, setImageBounds] = useState({ x: 0, y: 0, width: 0, height: 0 }); const containerRef = useRef(null); const imgRef = useRef(null); @@ -355,9 +363,21 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE }; }, [imageBounds, updateOverlay]); - const handleSave = useCallback(() => { - onSave(overlays); - }, [overlays, onSave]); + const handleSave = useCallback(async () => { + setSaveError(null); + try { + await onSave(overlays); + if (returnOnSave) onClose(); + } catch (err) { + setSaveError(err instanceof Error ? err.message : "Failed to save overlays"); + } + }, [overlays, onSave, onClose, returnOnSave]); + + const copyAiDraftPrompt = useCallback(() => { + navigator.clipboard?.writeText(buildLetteringPrompt(cut as unknown as LibCut, plotFile)); + setAiCopied(true); + setTimeout(() => setAiCopied(false), 2000); + }, [cut, plotFile]); const handleExport = useCallback(async () => { // Block export when the cut plan contained overlays that could not be placed @@ -515,25 +535,32 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE } return ( -
+
{/* Toolbar */} -
-
- Cut #{cut.id} +
+
+
+ Focused lettering editor + {targetLabel ?? `Cut #${cut.id}`} +
+

+ Place bubbles, captions, SFX, or between-scene card text, then save back to the full cut review. +

{overlays.length} overlays +
+
-
-
{exportError && {exportError}} + {saveError && {saveError}} - - + +
@@ -635,7 +662,7 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
@@ -830,7 +857,21 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
{/* Inspector panel */} -
+
+
+

AI draft assist

+

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

+ +
{/* Insert-from-script (#336): drop the cut's planned dialogue/narration/ SFX straight into a prefilled overlay — no copy/paste out of JSON. */} {scriptLines.length > 0 && ( diff --git a/app/web/components/PreviewPanel.tsx b/app/web/components/PreviewPanel.tsx index 2d7a7ac..b9e35d0 100644 --- a/app/web/components/PreviewPanel.tsx +++ b/app/web/components/PreviewPanel.tsx @@ -1335,7 +1335,7 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
Cuts: {cartoonCutProgress.total} Clean: {cartoonCutProgress.withClean}/{cartoonCutProgress.needClean} - Lettered: {cartoonCutProgress.withText}/{cartoonCutProgress.needClean} + Lettered: {cartoonCutProgress.withText}/{cartoonCutProgress.total} Uploaded: {cartoonCutProgress.uploaded}/{cartoonCutProgress.total} {onViewProgress && (