Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions app/lib/cartoon-coach.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
2 changes: 1 addition & 1 deletion app/lib/cartoon-coach.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
24 changes: 20 additions & 4 deletions app/lib/cartoon-readiness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand All @@ -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", () => {
Expand All @@ -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)", () => {
Expand Down
22 changes: 12 additions & 10 deletions app/lib/cartoon-readiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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++;
Expand Down Expand Up @@ -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;
Expand All @@ -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<CartoonStepKey, string | null> = {
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,
Expand Down Expand Up @@ -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) {
Expand Down
5 changes: 2 additions & 3 deletions app/lib/story-progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down
2 changes: 1 addition & 1 deletion app/web/components/CartoonPublishPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
69 changes: 53 additions & 16 deletions app/web/components/CutListPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ describe("CutListPanel", () => {
render(<CutListPanel storyName="story" fileName="plot-01.md" authFetch={authFetch} />);

await waitFor(() => {
expect(screen.getByText("Exported")).toBeInTheDocument();
expect(screen.getByTestId("cut-card-status-1")).toHaveTextContent("Exported");
expect(screen.getByText("1 lettered")).toBeInTheDocument();
});
});
Expand Down Expand Up @@ -150,7 +150,7 @@ describe("CutListPanel", () => {
const authFetch = mockAuthFetch({ ok: true, data: cutsData });
render(<CutListPanel storyName="coupon-crush" fileName="plot-01.md" authFetch={authFetch} />);

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"));
Expand Down Expand Up @@ -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");
Expand All @@ -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")) {
Expand All @@ -1057,24 +1058,60 @@ describe("CutListPanel asset diagnostics + Refresh assets (#427)", () => {
render(<CutListPanel storyName="god-cell" fileName="genesis.md" authFetch={fn} />);
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<typeof vi.fn>).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(<CutListPanel storyName="god-cell" fileName="genesis.md" authFetch={fn} />);

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")) {
Expand Down
Loading
Loading