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
109 changes: 109 additions & 0 deletions app/lib/generate-story-instructions.test.ts
Original file line number Diff line number Diff line change
@@ -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("<!-- plotlink-ows:story-instructions:cartoon -->");
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/");
});
});
224 changes: 224 additions & 0 deletions app/lib/generate-story-instructions.ts
Original file line number Diff line number Diff line change
@@ -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 = "<!-- plotlink-ows:story-instructions:";

function marker(contentType: string): string {
return `${MARKER_PREFIX}${contentType} -->`;
}

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");
}
2 changes: 2 additions & 0 deletions app/routes/stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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 });
});
Expand Down
6 changes: 6 additions & 0 deletions app/routes/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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
Expand Down
Loading