diff --git a/app/lib/generate-story-instructions.test.ts b/app/lib/generate-story-instructions.test.ts new file mode 100644 index 0000000..1b8526e --- /dev/null +++ b/app/lib/generate-story-instructions.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "fs"; +import path from "path"; +import os from "os"; +import { generateStoryInstructions, writeStoryInstructions } from "./generate-story-instructions"; + +describe("generateStoryInstructions", () => { + it("fiction output contains expected sections", () => { + const out = generateStoryInstructions("fiction"); + expect(out).toContain("structure.md"); + expect(out).toContain("genesis.md"); + expect(out).toContain("plot-"); + expect(out).toContain("Core Concept"); + expect(out).toContain("Main Characters"); + expect(out).toContain("Story Arc"); + expect(out).toContain("10,000 characters"); + }); + + it("fiction output does not contain cartoon terms", () => { + const out = generateStoryInstructions("fiction"); + expect(out).not.toContain("cuts.json"); + expect(out).not.toContain("clean image"); + expect(out).not.toContain("lettering"); + expect(out).not.toContain("Character Bible"); + expect(out).not.toContain("speech bubble"); + }); + + it("cartoon output contains expected sections", () => { + const out = generateStoryInstructions("cartoon"); + expect(out).toContain("cuts.json"); + expect(out).toContain("Character Bible"); + expect(out).toContain("Visual Style Guide"); + expect(out).toContain("Lettering"); + expect(out).toContain("Bubble"); + expect(out).toContain("assets/"); + expect(out).toContain("cut-XX-clean"); + expect(out).toContain("10K characters"); + }); + + it("cartoon output prohibits baking text into images", () => { + const out = generateStoryInstructions("cartoon"); + expect(out).toContain("Do NOT bake dialogue"); + expect(out).toContain("No speech bubbles"); + expect(out).toContain("No text overlays"); + }); + + it("fiction and cartoon outputs are different", () => { + const fiction = generateStoryInstructions("fiction"); + const cartoon = generateStoryInstructions("cartoon"); + expect(fiction).not.toBe(cartoon); + }); +}); + +describe("writeStoryInstructions", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "plotlink-instructions-")); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("creates CLAUDE.md with correct marker", () => { + writeStoryInstructions(tmpDir, "cartoon"); + const content = fs.readFileSync(path.join(tmpDir, "CLAUDE.md"), "utf-8"); + expect(content.split("\n")[0]).toBe(""); + expect(content).toContain("Character Bible"); + }); + + it("skips write when marker matches", () => { + writeStoryInstructions(tmpDir, "fiction"); + const content1 = fs.readFileSync(path.join(tmpDir, "CLAUDE.md"), "utf-8"); + + writeStoryInstructions(tmpDir, "fiction"); + const content2 = fs.readFileSync(path.join(tmpDir, "CLAUDE.md"), "utf-8"); + + expect(content1).toBe(content2); + }); + + it("regenerates when contentType changes", () => { + writeStoryInstructions(tmpDir, "fiction"); + const fictionContent = fs.readFileSync(path.join(tmpDir, "CLAUDE.md"), "utf-8"); + expect(fictionContent).toContain("story-instructions:fiction"); + expect(fictionContent).not.toContain("cuts.json"); + + writeStoryInstructions(tmpDir, "cartoon"); + const cartoonContent = fs.readFileSync(path.join(tmpDir, "CLAUDE.md"), "utf-8"); + expect(cartoonContent).toContain("story-instructions:cartoon"); + expect(cartoonContent).toContain("cuts.json"); + }); + + it("preserves user-owned unmarked CLAUDE.md", () => { + const userContent = "# My custom writing notes\n\nDo not overwrite this.\n"; + fs.writeFileSync(path.join(tmpDir, "CLAUDE.md"), userContent); + + writeStoryInstructions(tmpDir, "cartoon"); + + const after = fs.readFileSync(path.join(tmpDir, "CLAUDE.md"), "utf-8"); + expect(after).toBe(userContent); + }); + + it("fiction CLAUDE.md does not contain API endpoint tables", () => { + writeStoryInstructions(tmpDir, "fiction"); + const content = fs.readFileSync(path.join(tmpDir, "CLAUDE.md"), "utf-8"); + expect(content).not.toContain("/api/"); + }); +}); diff --git a/app/lib/generate-story-instructions.ts b/app/lib/generate-story-instructions.ts new file mode 100644 index 0000000..b52dff9 --- /dev/null +++ b/app/lib/generate-story-instructions.ts @@ -0,0 +1,224 @@ +import fs from "fs"; +import path from "path"; + +function fictionInstructions(): string { + return `# Writing Instructions — Fiction + +> Auto-generated by PlotLink OWS. Do not edit manually. +> For API endpoints and publish details, see ~/.plotlink-ows/CLAUDE.md. + +## Story File Structure + +\`\`\` +structure.md — Story outline, characters, and arc +genesis.md — Synopsis hook (~1000 chars) +plot-01.md — Chapter 1 (max 10K chars) +plot-02.md — Chapter 2 +... +\`\`\` + +## structure.md Format + +### Required Sections + +1. **Core Concept** — One paragraph describing the premise +2. **Main Characters** — For each character: + - Age, Personality, Flaw, Arc +3. **Story Arc** — Beginning, Middle, End +4. **Chapter Plan** — Numbered list of planned chapters with one-line descriptions +5. **Progress Log** — Track what has been written + +## genesis.md Format + +The genesis is the story's hook — the first thing readers see on-chain. + +- ~1000 characters (hard limit for on-chain genesis) +- Create immediate intrigue or emotional hook +- Introduce the core premise without spoilers +- End with a question or tension that pulls readers forward + +## plot-NN.md Format + +Each chapter is a self-contained prose section: + +- Maximum 10,000 characters per file +- Number sequentially: plot-01.md, plot-02.md, etc. +- Each chapter should advance the story meaningfully +- Illustrations: upload via API, embed as \`![description](url)\` in markdown + +## Publishing Rules + +- Content is **immutable after publish** — verify everything in Preview first +- Genesis publishes via \`createStoryline\` (one-time per story) +- Plots publish via \`chainPlot\` (one per chapter) +- Always check character count before publishing (500–10,000 chars) +`; +} + +function cartoonInstructions(): string { + return `# Writing Instructions — Cartoon + +> Auto-generated by PlotLink OWS. Do not edit manually. +> For API endpoints and publish details, see ~/.plotlink-ows/CLAUDE.md. + +## Story File Structure + +\`\`\` +.story.json — { "contentType": "cartoon" } +structure.md — Style guide, character bible, episode format +genesis.md — Synopsis hook (~1000 chars, text only) +plot-NN.cuts.json — Cut plan for episode NN +plot-NN.md — Episode publish markdown (image sequence) +assets/ + plot-NN/ + cut-01-clean.webp — Clean image (no text or bubbles) + cut-01-final.webp — Final lettered version + ... +\`\`\` + +## structure.md Format (Cartoon) + +### Required Sections + +1. **Visual Style Guide** + - Art style (manga, Franco-Belgian, webcomic, American comic, etc.) + - Color palette (full color, limited palette, monochrome + spot color) + - Line weight and inking style + - Panel/cut layout preferences (grid, dynamic, full-bleed) + +2. **Character Bible** + For each character, describe their VISUAL identity: + - Physical features: hair color/style, eye color, build, height + - Signature outfit and accessories + - Distinguishing visual features (scars, glasses, tattoos) + - Expression notes (resting expression, characteristic gestures) + +3. **Episode Format** + - Target cuts per episode (typical: 4–12) + - Pacing rhythm: how to alternate wide/medium/close-up shots + - Aspect ratio preference for cuts + +4. **Bubble and Lettering Conventions** + - Speech bubble style (round, angular, cloud-like) + - Thought bubble style + - Narration/caption box style + - Sound effect (SFX) conventions + - Font or lettering style preferences + +5. **Cut Planning Rules** + - Shot progression guidelines (e.g., establish → act → react) + - When to use wide vs. close-up shots + - Transition conventions between scenes + +## genesis.md Format + +Same as fiction: a prose synopsis hook, ~1000 characters. This is text only — no images. + +- Create immediate intrigue or emotional hook +- Introduce the visual world and core premise +- End with tension that pulls readers forward + +## Cut Planning — plot-NN.cuts.json + +Before generating images for an episode, create the cut plan first. + +Each cut entry specifies: +- Cut number and shot type (wide, medium, close-up, extreme-close-up) +- Visual scene description (what is shown in the image) +- Characters present in the cut +- Dialogue lines (kept separate from the image) +- Narration or caption text (also kept separate) +- SFX text if any + +The cuts.json is the single source of truth for the episode's visual sequence. + +## CRITICAL: Clean-Image-First Workflow + +**Do NOT bake dialogue, speech bubbles, sound effects, or any text into generated images.** + +Generate clean images only: +- No speech bubbles +- No text overlays +- No sound effect text +- No narration captions +- No lettering of any kind + +Save clean images as: \`assets/plot-NN/cut-XX-clean.webp\` + +**Only exception:** Include text in images when it is part of the physical scene +(a sign on a building, text on a screen, a letter being read) AND the writer +has explicitly requested it. + +## Character Consistency + +- Reference the character bible from structure.md for EVERY image generation +- Maintain consistent visual traits across all cuts and all episodes +- Same hair, same eye color, same outfit unless the story dictates a change +- Note any intentional appearance changes in the cuts.json + +## Lettering Handoff + +After clean images are generated and approved by the writer: + +1. The writer uses the OWS lettering editor to add speech bubbles and text +2. Lettered versions are saved as: \`assets/plot-NN/cut-XX-final.webp\` +3. Do NOT attempt to add bubbles or text to images — only the writer controls lettering +4. The publish markdown references final lettered images, not clean images + +## Publish Markdown — plot-NN.md + +The publish file for cartoon episodes is a sequence of images: + +1. Upload each final (lettered) image via the upload-plot-image API +2. Compose plot-NN.md as a sequence of image references: + \`![Cut 1 — Scene description](uploaded-image-url)\` +3. Optional: brief caption text between images for pacing +4. Each image must be WebP or JPEG, under 1MB +5. Total markdown content must be under 10K characters + +## Publishing Rules + +- Content is **immutable after publish** — verify all images in Preview first +- Genesis publishes via \`createStoryline\` (one-time, text-only synopsis) +- Episodes publish via \`chainPlot\` (per episode, image-sequence markdown) +- Once published, images cannot be replaced or edited + +## Episode Workflow + +1. **Plan** — Create plot-NN.cuts.json with shot-by-shot breakdown +2. **Generate** — Create clean images for each cut (no text in images) +3. **Review** — Writer reviews clean images, requests adjustments +4. **Letter** — Writer adds speech bubbles and text via lettering editor +5. **Upload** — Upload final lettered images to get IPFS URLs +6. **Compose** — Build plot-NN.md with image sequence +7. **Preview** — Verify all images render correctly +8. **Publish** — Chain the episode (immutable after this step) +`; +} + +export function generateStoryInstructions(contentType: "fiction" | "cartoon"): string { + if (contentType === "cartoon") return cartoonInstructions(); + return fictionInstructions(); +} + +const MARKER_PREFIX = "`; +} + +export function writeStoryInstructions(storyDir: string, contentType: "fiction" | "cartoon"): void { + const claudeMdPath = path.join(storyDir, "CLAUDE.md"); + const expectedMarker = marker(contentType); + + if (fs.existsSync(claudeMdPath)) { + try { + const firstLine = fs.readFileSync(claudeMdPath, "utf-8").split("\n")[0]; + if (firstLine === expectedMarker) return; + if (!firstLine.startsWith(MARKER_PREFIX)) return; + } catch { /* regenerate on error */ } + } + + const content = expectedMarker + "\n" + generateStoryInstructions(contentType); + fs.writeFileSync(claudeMdPath, content, "utf-8"); +} diff --git a/app/routes/stories.ts b/app/routes/stories.ts index 3dd4011..914a91c 100644 --- a/app/routes/stories.ts +++ b/app/routes/stories.ts @@ -2,6 +2,7 @@ import { Hono } from "hono"; import fs from "fs"; import path from "path"; import { STORIES_DIR } from "../lib/paths"; +import { writeStoryInstructions } from "../lib/generate-story-instructions"; const stories = new Hono(); @@ -219,6 +220,7 @@ stories.post("/:name/metadata", async (c) => { const existing = readStoryMeta(storyDir); const meta: StoryMeta = { ...existing, contentType: body.contentType }; writeStoryMeta(storyDir, meta); + writeStoryInstructions(storyDir, meta.contentType); return c.json({ ok: true }); }); diff --git a/app/routes/terminal.ts b/app/routes/terminal.ts index 87c2956..e4733f9 100644 --- a/app/routes/terminal.ts +++ b/app/routes/terminal.ts @@ -4,6 +4,8 @@ import path from "path"; import fs from "fs"; import { randomUUID } from "crypto"; import { STORIES_DIR, DATA_DIR } from "../lib/paths"; +import { readStoryMeta } from "./stories"; +import { writeStoryInstructions } from "../lib/generate-story-instructions"; const MAX_SESSIONS = 5; const SESSION_FILE = path.join(DATA_DIR, "terminal-sessions.json"); @@ -47,6 +49,10 @@ function spawnPty(storyName: string, opts?: { sessionId?: string; resume?: boole const isNewStory = storyName.startsWith("_new_"); const storyDir = isNewStory ? STORIES_DIR : path.join(STORIES_DIR, storyName); if (!fs.existsSync(storyDir)) fs.mkdirSync(storyDir, { recursive: true }); + if (!isNewStory) { + const { contentType } = readStoryMeta(storyDir); + writeStoryInstructions(storyDir, contentType); + } const shell = process.env.SHELL || "/bin/zsh"; // Determine session ID