plotlink-ows 1.0.32 → 1.2.94

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/README.md +4 -0
  2. package/app/lib/agent-command.ts +85 -0
  3. package/app/lib/agent-readiness.ts +133 -0
  4. package/app/lib/apply-schema.ts +55 -0
  5. package/app/lib/bubble-text.ts +160 -0
  6. package/app/lib/cartoon-coach.ts +198 -0
  7. package/app/lib/cartoon-markdown.ts +83 -0
  8. package/app/lib/cartoon-prompt.ts +122 -0
  9. package/app/lib/cartoon-readiness.ts +811 -0
  10. package/app/lib/clean-image-sync.ts +245 -0
  11. package/app/lib/codex-images.ts +152 -0
  12. package/app/lib/cut-asset-diagnostics.ts +120 -0
  13. package/app/lib/cuts.ts +302 -0
  14. package/app/lib/fonts.ts +109 -0
  15. package/app/lib/generate-claude-md.ts +10 -3
  16. package/app/lib/generate-story-instructions.ts +731 -0
  17. package/app/lib/image-asset-validate.ts +123 -0
  18. package/app/lib/lettering-status.ts +133 -0
  19. package/app/lib/overlays.ts +637 -0
  20. package/app/lib/paths.ts +10 -0
  21. package/app/lib/public-title.ts +65 -0
  22. package/app/lib/publish.ts +16 -2
  23. package/app/lib/story-progress.ts +243 -0
  24. package/app/lib/terminal-protocol.ts +16 -0
  25. package/app/lib/terminal-redact.ts +50 -0
  26. package/app/prisma/schema.sql +25 -0
  27. package/app/routes/agent.ts +42 -0
  28. package/app/routes/codex-images.ts +67 -0
  29. package/app/routes/publish.ts +209 -28
  30. package/app/routes/stories.ts +961 -5
  31. package/app/routes/terminal.ts +383 -31
  32. package/app/server.ts +47 -12
  33. package/app/vite.config.ts +6 -0
  34. package/app/web/components/CartoonPreview.tsx +267 -0
  35. package/app/web/components/CartoonPublishPage.tsx +407 -0
  36. package/app/web/components/CartoonPublishPreview.tsx +121 -0
  37. package/app/web/components/CartoonStepGuide.tsx +90 -0
  38. package/app/web/components/CartoonWorkflowNav.tsx +68 -0
  39. package/app/web/components/CodexImportPicker.tsx +230 -0
  40. package/app/web/components/CutListPanel.tsx +1299 -0
  41. package/app/web/components/EpisodesPage.tsx +80 -0
  42. package/app/web/components/FinishEpisodePanel.tsx +151 -0
  43. package/app/web/components/Layout.tsx +7 -4
  44. package/app/web/components/LetteringEditor.tsx +1141 -0
  45. package/app/web/components/PreviewPanel.tsx +1017 -144
  46. package/app/web/components/Settings.tsx +63 -0
  47. package/app/web/components/StoriesPage.tsx +710 -33
  48. package/app/web/components/StoryBrowser.tsx +22 -14
  49. package/app/web/components/StoryInfoPage.tsx +266 -0
  50. package/app/web/components/StoryProgressPanel.tsx +516 -0
  51. package/app/web/components/TerminalPanel.tsx +233 -11
  52. package/app/web/components/WorkflowCoach.tsx +128 -0
  53. package/app/web/components/asset-image.tsx +114 -0
  54. package/app/web/components/asset-test-utils.ts +44 -0
  55. package/app/web/components/export-cut.ts +320 -0
  56. package/app/web/dist/assets/export-cut-nKQ_n2-J.js +1 -0
  57. package/app/web/dist/assets/index-BAZGwVwj.js +143 -0
  58. package/app/web/dist/assets/index-DoXH2OlP.css +32 -0
  59. package/app/web/dist/index.html +2 -2
  60. package/app/web/lib/cartoon-publish-summary.ts +43 -0
  61. package/app/web/lib/codex-import.ts +94 -0
  62. package/app/web/lib/image-compress.ts +53 -0
  63. package/app/web/lib/import-image.ts +58 -0
  64. package/app/web/lib/publish-helpers.ts +385 -0
  65. package/app/web/lib/upload-retry.ts +130 -0
  66. package/app/web/lib/verify-public-title.ts +105 -0
  67. package/app/web/styles.css +9 -0
  68. package/bin/plotlink-ows.js +53 -16
  69. package/bin/startup-plan.cjs +58 -0
  70. package/lib/genres.ts +92 -0
  71. package/package.json +60 -20
  72. package/scripts/gen-schema-sql.mjs +49 -0
  73. package/scripts/package-hygiene.mjs +116 -0
  74. package/scripts/preflight.mjs +173 -0
  75. package/scripts/start-smoke.mjs +128 -0
  76. package/app/node_modules/.prisma/local-client/client.d.ts +0 -1
  77. package/app/node_modules/.prisma/local-client/client.js +0 -5
  78. package/app/node_modules/.prisma/local-client/default.d.ts +0 -1
  79. package/app/node_modules/.prisma/local-client/default.js +0 -5
  80. package/app/node_modules/.prisma/local-client/edge.d.ts +0 -1
  81. package/app/node_modules/.prisma/local-client/edge.js +0 -184
  82. package/app/node_modules/.prisma/local-client/index-browser.js +0 -173
  83. package/app/node_modules/.prisma/local-client/index.d.ts +0 -3304
  84. package/app/node_modules/.prisma/local-client/index.js +0 -207
  85. package/app/node_modules/.prisma/local-client/libquery_engine-darwin-arm64.dylib.node +0 -0
  86. package/app/node_modules/.prisma/local-client/package.json +0 -183
  87. package/app/node_modules/.prisma/local-client/query_engine_bg.js +0 -2
  88. package/app/node_modules/.prisma/local-client/query_engine_bg.wasm +0 -0
  89. package/app/node_modules/.prisma/local-client/runtime/edge-esm.js +0 -35
  90. package/app/node_modules/.prisma/local-client/runtime/edge.js +0 -35
  91. package/app/node_modules/.prisma/local-client/runtime/index-browser.d.ts +0 -370
  92. package/app/node_modules/.prisma/local-client/runtime/index-browser.js +0 -17
  93. package/app/node_modules/.prisma/local-client/runtime/library.d.ts +0 -3982
  94. package/app/node_modules/.prisma/local-client/runtime/library.js +0 -147
  95. package/app/node_modules/.prisma/local-client/runtime/react-native.js +0 -84
  96. package/app/node_modules/.prisma/local-client/runtime/wasm-compiler-edge.js +0 -85
  97. package/app/node_modules/.prisma/local-client/runtime/wasm-engine-edge.js +0 -38
  98. package/app/node_modules/.prisma/local-client/schema.prisma +0 -21
  99. package/app/node_modules/.prisma/local-client/wasm-edge-light-loader.mjs +0 -5
  100. package/app/node_modules/.prisma/local-client/wasm-worker-loader.mjs +0 -5
  101. package/app/node_modules/.prisma/local-client/wasm.d.ts +0 -1
  102. package/app/node_modules/.prisma/local-client/wasm.js +0 -191
  103. package/app/web/dist/assets/index-B-2Ft7Yv.css +0 -32
  104. package/app/web/dist/assets/index-BFw-v-OZ.js +0 -134
  105. package/packages/cli/node_modules/commander/LICENSE +0 -22
  106. package/packages/cli/node_modules/commander/Readme.md +0 -1149
  107. package/packages/cli/node_modules/commander/esm.mjs +0 -16
  108. package/packages/cli/node_modules/commander/index.js +0 -24
  109. package/packages/cli/node_modules/commander/lib/argument.js +0 -149
  110. package/packages/cli/node_modules/commander/lib/command.js +0 -2662
  111. package/packages/cli/node_modules/commander/lib/error.js +0 -39
  112. package/packages/cli/node_modules/commander/lib/help.js +0 -709
  113. package/packages/cli/node_modules/commander/lib/option.js +0 -367
  114. package/packages/cli/node_modules/commander/lib/suggestSimilar.js +0 -101
  115. package/packages/cli/node_modules/commander/package-support.json +0 -16
  116. package/packages/cli/node_modules/commander/package.json +0 -82
  117. package/packages/cli/node_modules/commander/typings/esm.d.mts +0 -3
  118. package/packages/cli/node_modules/commander/typings/index.d.ts +0 -1045
  119. package/packages/cli/node_modules/resolve-from/index.d.ts +0 -31
  120. package/packages/cli/node_modules/resolve-from/index.js +0 -47
  121. package/packages/cli/node_modules/resolve-from/license +0 -9
  122. package/packages/cli/node_modules/resolve-from/package.json +0 -36
  123. package/packages/cli/node_modules/resolve-from/readme.md +0 -72
  124. package/packages/cli/node_modules/tsup/LICENSE +0 -21
  125. package/packages/cli/node_modules/tsup/README.md +0 -75
  126. package/packages/cli/node_modules/tsup/assets/cjs_shims.js +0 -13
  127. package/packages/cli/node_modules/tsup/assets/esm_shims.js +0 -9
  128. package/packages/cli/node_modules/tsup/assets/package.json +0 -3
  129. package/packages/cli/node_modules/tsup/dist/chunk-DI5BO6XE.js +0 -153
  130. package/packages/cli/node_modules/tsup/dist/chunk-JZ25TPTY.js +0 -42
  131. package/packages/cli/node_modules/tsup/dist/chunk-PEEXUWMS.js +0 -6
  132. package/packages/cli/node_modules/tsup/dist/chunk-TWFEYLU4.js +0 -352
  133. package/packages/cli/node_modules/tsup/dist/chunk-VGC3FXLU.js +0 -203
  134. package/packages/cli/node_modules/tsup/dist/cli-default.js +0 -12
  135. package/packages/cli/node_modules/tsup/dist/cli-main.js +0 -8
  136. package/packages/cli/node_modules/tsup/dist/cli-node.js +0 -14
  137. package/packages/cli/node_modules/tsup/dist/index.d.ts +0 -511
  138. package/packages/cli/node_modules/tsup/dist/index.js +0 -1711
  139. package/packages/cli/node_modules/tsup/dist/rollup.js +0 -6949
  140. package/packages/cli/node_modules/tsup/package.json +0 -99
  141. package/packages/cli/node_modules/tsup/schema.json +0 -362
  142. package/public/screenshot-1.png +0 -0
  143. package/public/screenshot-2.png +0 -0
  144. package/public/screenshot-3.png +0 -0
  145. package/scripts/e2e-verify.ts +0 -1100
@@ -2,6 +2,15 @@ import { Hono } from "hono";
2
2
  import fs from "fs";
3
3
  import path from "path";
4
4
  import { STORIES_DIR } from "../lib/paths";
5
+ import { writeStoryInstructions } from "../lib/generate-story-instructions";
6
+ import { readCutsFile, writeCutsFile, validateCutsFile } from "../lib/cuts";
7
+ import { buildStoryProgress } from "../lib/story-progress";
8
+ import { deriveCartoonCoach } from "../lib/cartoon-coach";
9
+ import { diagnoseCutAssets, summarizeAssetDiagnostics } from "../lib/cut-asset-diagnostics";
10
+ import { CARTOON_BUBBLE_RENDERER_VERSION } from "../lib/overlays";
11
+ import { mergeCartoonMarkdown } from "../lib/cartoon-markdown";
12
+ import { syncCleanImages, cleanImageCandidates, sniffImageType, cleanImageBytesMatchMime, findStaleAssetPaths, clearStaleAssetPaths, type SniffedType } from "../lib/clean-image-sync";
13
+ import { imageAssetIssue, isValidImageAsset, pngAssetExists, CLEAN_IMAGE_VALID_EXT } from "../lib/image-asset-validate";
5
14
 
6
15
  const stories = new Hono();
7
16
 
@@ -34,6 +43,18 @@ interface StoryInfo {
34
43
  hasGenesis: boolean;
35
44
  plotCount: number;
36
45
  publishedCount: number;
46
+ contentType: "fiction" | "cartoon";
47
+ // Publish metadata from .story.json, surfaced so the publish controls seed
48
+ // from the real story values (#424). Absent ⇒ could not be determined (no
49
+ // .story.json value, no structure.md hint, no script detection), so the client
50
+ // shows an explicit "Needs metadata" state instead of a misleading default
51
+ // (English/Romance). `genre` is the raw stored label; the client canonicalizes.
52
+ language?: string;
53
+ genre?: string;
54
+ isNsfw?: boolean;
55
+ // Optional. Absent ⇒ no provider recorded (legacy story ⇒ defaults to Claude
56
+ // at launch). Surfaced read-only so the client can offer a scoped repair.
57
+ agentProvider?: AgentProvider;
37
58
  }
38
59
 
39
60
  function readPublishStatus(storyDir: string): Record<string, FileStatus> {
@@ -51,8 +72,73 @@ function writePublishStatus(storyDir: string, status: Record<string, FileStatus>
51
72
  fs.writeFileSync(statusFile, JSON.stringify(status, null, 2) + "\n");
52
73
  }
53
74
 
75
+ export type AgentProvider = "claude" | "codex";
76
+
77
+ interface StoryMeta {
78
+ contentType: "fiction" | "cartoon";
79
+ // Publish metadata authored in .story.json. Surfaced so the publish controls
80
+ // initialize from the story's real values instead of falling back to the
81
+ // first-in-list defaults (Romance / English) — see #424.
82
+ title?: string;
83
+ description?: string;
84
+ language?: string;
85
+ genre?: string;
86
+ isNsfw?: boolean;
87
+ agentMode?: "normal" | "bypass";
88
+ // Optional. Absent ⇒ Claude (no migration). "claude" | "codex".
89
+ agentProvider?: AgentProvider;
90
+ }
91
+
92
+ function readStoryMeta(storyDir: string): StoryMeta {
93
+ const metaFile = path.join(storyDir, ".story.json");
94
+ try {
95
+ if (fs.existsSync(metaFile)) {
96
+ const raw = JSON.parse(fs.readFileSync(metaFile, "utf-8"));
97
+ if (raw.contentType === "fiction" || raw.contentType === "cartoon") {
98
+ // Accept both camelCase `isNsfw` and snake_case `is_nsfw` on read; we
99
+ // always persist canonical `isNsfw` (see writeStoryMeta).
100
+ const isNsfw = typeof raw.isNsfw === "boolean" ? raw.isNsfw
101
+ : typeof raw.is_nsfw === "boolean" ? raw.is_nsfw
102
+ : undefined;
103
+ return {
104
+ contentType: raw.contentType,
105
+ ...(typeof raw.title === "string" ? { title: raw.title } : {}),
106
+ ...(typeof raw.description === "string" ? { description: raw.description } : {}),
107
+ ...(typeof raw.language === "string" ? { language: raw.language } : {}),
108
+ ...(typeof raw.genre === "string" ? { genre: raw.genre } : {}),
109
+ ...(isNsfw !== undefined ? { isNsfw } : {}),
110
+ ...(raw.agentMode === "bypass" || raw.agentMode === "normal" ? { agentMode: raw.agentMode } : {}),
111
+ ...(raw.agentProvider === "claude" || raw.agentProvider === "codex" ? { agentProvider: raw.agentProvider } : {}),
112
+ };
113
+ }
114
+ }
115
+ } catch { /* ignore */ }
116
+ return { contentType: "fiction" };
117
+ }
118
+
119
+ function writeStoryMeta(storyDir: string, meta: StoryMeta) {
120
+ const metaFile = path.join(storyDir, ".story.json");
121
+ fs.writeFileSync(metaFile, JSON.stringify(meta, null, 2) + "\n");
122
+ }
123
+
124
+ function parseLanguageMetadata(content: string): string | null {
125
+ const match = content.match(/^(?:\*\*)?Language(?:\*\*)?:\s*(?:\*\*)?([^*\n]+)/im);
126
+ if (match) return match[1].trim();
127
+ return null;
128
+ }
129
+
130
+ function detectLanguageFromScript(text: string): string | null {
131
+ if (/[가-힯]/.test(text)) return "Korean";
132
+ if (/[぀-ゟ゠-ヿ]/.test(text)) return "Japanese";
133
+ if (/[一-鿿]/.test(text)) return "Chinese";
134
+ if (/[ऀ-ॿ]/.test(text)) return "Hindi";
135
+ if (/[؀-ۿ]/.test(text)) return "Arabic";
136
+ return null;
137
+ }
138
+
54
139
  function scanStory(storyDir: string, name: string): StoryInfo {
55
140
  const publishStatus = readPublishStatus(storyDir);
141
+ const storyMeta = readStoryMeta(storyDir);
56
142
  const entries = fs.readdirSync(storyDir).filter((f) => f.endsWith(".md"));
57
143
 
58
144
  const files: FileStatus[] = entries.map((file) => {
@@ -68,14 +154,15 @@ function scanStory(storyDir: string, name: string): StoryInfo {
68
154
  const plotCount = entries.filter((f) => f.match(/^plot-\d+\.md$/)).length;
69
155
  const publishedCount = files.filter((f) => f.status === "published" || f.status === "published-not-indexed").length;
70
156
 
71
- // Extract title from structure.md or genesis.md
157
+ // Extract title and language hints from structure.md or genesis.md
72
158
  let title: string | null = null;
159
+ let structContent: string | null = null;
73
160
  try {
74
161
  const structPath = path.join(storyDir, "structure.md");
75
162
  const genesisPath = path.join(storyDir, "genesis.md");
76
163
  if (fs.existsSync(structPath)) {
77
- const content = fs.readFileSync(structPath, "utf-8");
78
- const match = content.match(/^#\s+(.+)$/m);
164
+ structContent = fs.readFileSync(structPath, "utf-8");
165
+ const match = structContent.match(/^#\s+(.+)$/m);
79
166
  if (match) title = match[1];
80
167
  } else if (fs.existsSync(genesisPath)) {
81
168
  const content = fs.readFileSync(genesisPath, "utf-8");
@@ -84,7 +171,40 @@ function scanStory(storyDir: string, name: string): StoryInfo {
84
171
  }
85
172
  } catch { /* best effort */ }
86
173
 
87
- return { name, title, files, hasStructure, hasGenesis, plotCount, publishedCount };
174
+ // Resolve language best-effort from explicit metadata structure.md hint →
175
+ // script detection. Do NOT blind-default to English (#424): when nothing
176
+ // determines it, leave it undefined so the client shows "Needs metadata"
177
+ // rather than silently publishing the wrong language.
178
+ let language: string | undefined = storyMeta.language;
179
+ if (!language) {
180
+ const fromMetadata = structContent ? parseLanguageMetadata(structContent) : null;
181
+ const fromScript = title ? detectLanguageFromScript(title) : null;
182
+ language = fromMetadata ?? fromScript ?? undefined;
183
+ }
184
+
185
+ return {
186
+ name,
187
+ // Prefer the explicit .story.json title when present (#424); fall back to
188
+ // the H1 parsed from structure.md / genesis.md.
189
+ title: storyMeta.title ?? title,
190
+ files,
191
+ hasStructure,
192
+ hasGenesis,
193
+ plotCount,
194
+ publishedCount,
195
+ contentType: storyMeta.contentType,
196
+ // Surfaced from .story.json/detection so the publish controls seed real
197
+ // values (#424); omitted when undetermined so the client shows "Needs
198
+ // metadata" instead of a misleading English default.
199
+ ...(language ? { language } : {}),
200
+ ...(storyMeta.genre ? { genre: storyMeta.genre } : {}),
201
+ ...(typeof storyMeta.description === "string" ? { description: storyMeta.description } : {}),
202
+ ...(storyMeta.isNsfw !== undefined ? { isNsfw: storyMeta.isNsfw } : {}),
203
+ // Read-only passthrough. Absent when the story has no provider recorded
204
+ // (legacy), so a legacy cartoon shows no provider and the client can offer
205
+ // the explicit repair affordance. Never written/migrated here.
206
+ ...(storyMeta.agentProvider ? { agentProvider: storyMeta.agentProvider } : {}),
207
+ };
88
208
  }
89
209
 
90
210
  /** GET /api/stories — list all stories */
@@ -177,6 +297,842 @@ stories.get("/:name", (c) => {
177
297
  return c.json({ ...info, files: filesWithContent });
178
298
  });
179
299
 
300
+ /** POST /api/stories/:name/metadata — write/update .story.json */
301
+ stories.post("/:name/metadata", async (c) => {
302
+ const name = safeName(c.req.param("name"));
303
+ if (!name) return c.json({ error: "Invalid story name" }, 400);
304
+ const storyDir = path.join(STORIES_DIR, name);
305
+
306
+ if (!fs.existsSync(storyDir) || !fs.statSync(storyDir).isDirectory()) {
307
+ return c.json({ error: "Story not found" }, 404);
308
+ }
309
+
310
+ const body = await c.req.json<{ contentType?: string; language?: string; agentMode?: string; agentProvider?: string }>();
311
+ if (body.contentType !== "fiction" && body.contentType !== "cartoon") {
312
+ return c.json({ error: "contentType must be 'fiction' or 'cartoon'" }, 400);
313
+ }
314
+
315
+ const existing = readStoryMeta(storyDir);
316
+ const meta: StoryMeta = {
317
+ ...existing,
318
+ contentType: body.contentType,
319
+ ...(typeof body.language === "string" ? { language: body.language } : {}),
320
+ ...(body.agentMode === "bypass" || body.agentMode === "normal" ? { agentMode: body.agentMode } : {}),
321
+ ...(body.agentProvider === "claude" || body.agentProvider === "codex" ? { agentProvider: body.agentProvider } : {}),
322
+ };
323
+ writeStoryMeta(storyDir, meta);
324
+ // Provider-aware so a legacy-cartoon repair (agentProvider → codex) rewrites
325
+ // CLAUDE.md with the Codex file-creation contract; absent ⇒ Claude/manual.
326
+ writeStoryInstructions(storyDir, meta.contentType, meta.agentProvider);
327
+
328
+ return c.json({ ok: true });
329
+ });
330
+
331
+ /**
332
+ * POST /api/stories/:name/publish-metadata — persist publish controls back to
333
+ * .story.json (#424).
334
+ *
335
+ * Lets a writer's genre/language/is-NSFW selections in the publish panel stick
336
+ * across refresh, keeping the controls in sync with story metadata. Unlike the
337
+ * /metadata route this does NOT change contentType or rewrite CLAUDE.md — it
338
+ * only updates publish fields, so fiction/agent behavior is untouched. Each
339
+ * field is optional; omitted fields are left as-is (so a single control edit
340
+ * never clobbers the others).
341
+ */
342
+ stories.post("/:name/publish-metadata", async (c) => {
343
+ const name = safeName(c.req.param("name"));
344
+ if (!name) return c.json({ error: "Invalid story name" }, 400);
345
+ const storyDir = path.join(STORIES_DIR, name);
346
+
347
+ if (!fs.existsSync(storyDir) || !fs.statSync(storyDir).isDirectory()) {
348
+ return c.json({ error: "Story not found" }, 404);
349
+ }
350
+
351
+ const body = await c.req.json<{ title?: string; description?: string; language?: string; genre?: string; isNsfw?: boolean }>();
352
+
353
+ const existing = readStoryMeta(storyDir);
354
+ const meta: StoryMeta = {
355
+ ...existing,
356
+ ...(typeof body.title === "string" ? { title: body.title } : {}),
357
+ ...(typeof body.description === "string" ? { description: body.description } : {}),
358
+ ...(typeof body.language === "string" ? { language: body.language } : {}),
359
+ ...(typeof body.genre === "string" ? { genre: body.genre } : {}),
360
+ ...(typeof body.isNsfw === "boolean" ? { isNsfw: body.isNsfw } : {}),
361
+ };
362
+ writeStoryMeta(storyDir, meta);
363
+
364
+ return c.json({ ok: true });
365
+ });
366
+
367
+ /** Derive a filesystem-safe slug from a title (#423). Non-Latin titles (e.g.
368
+ * Korean) reduce to empty → fall back to "story"; the real title is kept in
369
+ * .story.json and is what the UI displays. */
370
+ function slugifyTitle(title: string): string {
371
+ const base = title.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
372
+ return base || "story";
373
+ }
374
+
375
+ /**
376
+ * POST /api/stories/create — guided New Story setup (#423).
377
+ *
378
+ * Creates the story folder + .story.json + CLAUDE.md from the user's chosen
379
+ * title/metadata UP FRONT, so a normal user no longer has to prompt the agent to
380
+ * rename an "Untitled" project. The (possibly non-Latin) title is stored in
381
+ * .story.json and shown in the UI; the folder slug is an ASCII fallback. Cartoon
382
+ * always uses Codex (the clean-image step needs image generation).
383
+ */
384
+ stories.post("/create", async (c) => {
385
+ const body = await c.req.json<{ title?: string; description?: string; language?: string; genre?: string; contentType?: string; agentMode?: string; agentProvider?: string }>();
386
+ const title = (body.title ?? "").trim();
387
+ if (!title) return c.json({ error: "Title is required" }, 400);
388
+
389
+ const contentType = body.contentType === "cartoon" ? "cartoon" : "fiction";
390
+ const agentProvider: AgentProvider = contentType === "cartoon" ? "codex" : (body.agentProvider === "codex" ? "codex" : "claude");
391
+ const agentMode = body.agentMode === "bypass" ? "bypass" : "normal";
392
+
393
+ if (!fs.existsSync(STORIES_DIR)) fs.mkdirSync(STORIES_DIR, { recursive: true });
394
+ const base = slugifyTitle(title);
395
+ let slug = base;
396
+ for (let i = 2; fs.existsSync(path.join(STORIES_DIR, slug)); i++) slug = `${base}-${i}`;
397
+ const storyDir = path.join(STORIES_DIR, slug);
398
+ fs.mkdirSync(storyDir, { recursive: true });
399
+
400
+ const meta: StoryMeta = {
401
+ contentType,
402
+ title,
403
+ ...(typeof body.description === "string" && body.description.trim() ? { description: body.description.trim() } : {}),
404
+ ...(typeof body.language === "string" && body.language ? { language: body.language } : {}),
405
+ ...(typeof body.genre === "string" && body.genre ? { genre: body.genre } : {}),
406
+ agentMode,
407
+ agentProvider,
408
+ };
409
+ writeStoryMeta(storyDir, meta);
410
+ writeStoryInstructions(storyDir, contentType, agentProvider);
411
+
412
+ return c.json({ name: slug, title, contentType });
413
+ });
414
+
415
+ /** GET /api/stories/:name/cuts/:plotFile — read cuts.json for a plot */
416
+ stories.get("/:name/cuts/:plotFile", (c) => {
417
+ const name = safeName(c.req.param("name"));
418
+ const plotFile = safeName(c.req.param("plotFile"));
419
+ if (!name || !plotFile) return c.json({ error: "Invalid path" }, 400);
420
+ const storyDir = path.join(STORIES_DIR, name);
421
+
422
+ if (!fs.existsSync(storyDir) || !fs.statSync(storyDir).isDirectory()) {
423
+ return c.json({ error: "Story not found" }, 404);
424
+ }
425
+
426
+ try {
427
+ const cutsFile = readCutsFile(storyDir, plotFile);
428
+ if (!cutsFile) return c.json({ error: "Cuts file not found" }, 404);
429
+ return c.json(cutsFile);
430
+ } catch (err) {
431
+ return c.json({ error: (err as Error).message }, 400);
432
+ }
433
+ });
434
+
435
+ /** PUT /api/stories/:name/cuts/:plotFile — update cuts.json */
436
+ stories.put("/:name/cuts/:plotFile", async (c) => {
437
+ const name = safeName(c.req.param("name"));
438
+ const plotFile = safeName(c.req.param("plotFile"));
439
+ if (!name || !plotFile) return c.json({ error: "Invalid path" }, 400);
440
+ const storyDir = path.join(STORIES_DIR, name);
441
+
442
+ if (!fs.existsSync(storyDir) || !fs.statSync(storyDir).isDirectory()) {
443
+ return c.json({ error: "Story not found" }, 404);
444
+ }
445
+
446
+ const body = await c.req.json();
447
+ const validation = validateCutsFile(body);
448
+ if (!validation.valid) {
449
+ return c.json({ error: validation.error }, 400);
450
+ }
451
+
452
+ writeCutsFile(storyDir, plotFile, body);
453
+ return c.json({ ok: true });
454
+ });
455
+
456
+ /** POST /api/stories/:name/cuts/:plotFile/upload-clean/:cutId — upload clean image for a cut */
457
+ stories.post("/:name/cuts/:plotFile/upload-clean/:cutId", async (c) => {
458
+ const name = safeName(c.req.param("name"));
459
+ const plotFile = safeName(c.req.param("plotFile"));
460
+ const cutIdStr = c.req.param("cutId");
461
+ if (!name || !plotFile || !cutIdStr) return c.json({ error: "Invalid path" }, 400);
462
+
463
+ const cutId = parseInt(cutIdStr, 10);
464
+ if (isNaN(cutId) || cutId < 1) return c.json({ error: "Invalid cut ID" }, 400);
465
+
466
+ const storyDir = path.join(STORIES_DIR, name);
467
+ if (!fs.existsSync(storyDir) || !fs.statSync(storyDir).isDirectory()) {
468
+ return c.json({ error: "Story not found" }, 404);
469
+ }
470
+
471
+ const cutsFile = readCutsFile(storyDir, plotFile);
472
+ if (!cutsFile) return c.json({ error: "Cuts file not found" }, 404);
473
+
474
+ const cut = cutsFile.cuts.find((c) => c.id === cutId);
475
+ if (!cut) return c.json({ error: `Cut ${cutId} not found` }, 404);
476
+
477
+ let formData: FormData;
478
+ try {
479
+ formData = await c.req.formData();
480
+ } catch {
481
+ return c.json({ error: "No file provided" }, 400);
482
+ }
483
+ const file = formData.get("file") as File | Blob | null;
484
+ if (!file || (typeof file === "string")) {
485
+ return c.json({ error: "No file provided" }, 400);
486
+ }
487
+
488
+ if (file.size > 1024 * 1024) {
489
+ return c.json({ error: "File must be under 1MB" }, 400);
490
+ }
491
+
492
+ const mime = file.type;
493
+ if (mime !== "image/webp" && mime !== "image/jpeg") {
494
+ return c.json({ error: "Only WebP and JPEG images are supported" }, 400);
495
+ }
496
+
497
+ // Validate by actual file bytes, not just the (spoofable) MIME label, so a
498
+ // renamed text/PNG file claiming image/webp cannot be recorded as a clean
499
+ // image. Mirrors the magic-byte check used by sync-clean-images (#256/#266).
500
+ const buffer = Buffer.from(await file.arrayBuffer());
501
+ if (!cleanImageBytesMatchMime(buffer, mime)) {
502
+ return c.json(
503
+ { error: "File content is not a valid WebP/JPEG image (bytes do not match the image type)" },
504
+ 400,
505
+ );
506
+ }
507
+
508
+ const ext = mime === "image/webp" ? "webp" : "jpg";
509
+ const padded = String(cutId).padStart(2, "0");
510
+ const assetDir = path.join(storyDir, "assets", plotFile);
511
+ fs.mkdirSync(assetDir, { recursive: true });
512
+
513
+ const fileName = `cut-${padded}-clean.${ext}`;
514
+ const filePath = path.join(assetDir, fileName);
515
+ fs.writeFileSync(filePath, buffer);
516
+
517
+ const cleanImagePath = `assets/${plotFile}/cut-${padded}-clean.${ext}`;
518
+ cut.cleanImagePath = cleanImagePath;
519
+ writeCutsFile(storyDir, plotFile, cutsFile);
520
+
521
+ return c.json({ ok: true, cleanImagePath });
522
+ });
523
+
524
+ function saveExportedCut(
525
+ storyDir: string,
526
+ plotFile: string,
527
+ cutId: number,
528
+ buffer: Buffer,
529
+ mime: string,
530
+ ): { finalImagePath: string } {
531
+ const ext = mime === "image/webp" ? "webp" : "jpg";
532
+ const padded = String(cutId).padStart(2, "0");
533
+ const assetDir = path.join(storyDir, "assets", plotFile);
534
+ fs.mkdirSync(assetDir, { recursive: true });
535
+
536
+ const fileName = `cut-${padded}-final.${ext}`;
537
+ fs.writeFileSync(path.join(assetDir, fileName), buffer);
538
+
539
+ const finalImagePath = `assets/${plotFile}/cut-${padded}-final.${ext}`;
540
+
541
+ const cutsFile = readCutsFile(storyDir, plotFile)!;
542
+ const cut = cutsFile.cuts.find((c) => c.id === cutId)!;
543
+ cut.finalImagePath = finalImagePath;
544
+ cut.exportedAt = new Date().toISOString();
545
+ // Stamp the bubble-renderer revision so a later renderer upgrade can flag this
546
+ // final image as stale (needing re-export) before publish (#381).
547
+ cut.finalRendererVersion = CARTOON_BUBBLE_RENDERER_VERSION;
548
+ // A NEW final image invalidates any prior upload (#381): the old PlotLink asset
549
+ // is the previous render (e.g. the stale separated-tail one). Clear the upload
550
+ // record so the cut becomes upload-eligible again — otherwise the bulk upload
551
+ // skips it (it filters out cuts that already have an uploadedCid) and the old
552
+ // image would keep publishing even after re-export.
553
+ cut.uploadedCid = null;
554
+ cut.uploadedUrl = null;
555
+ writeCutsFile(storyDir, plotFile, cutsFile);
556
+
557
+ return { finalImagePath };
558
+ }
559
+
560
+ /** POST /api/stories/:name/cuts/:plotFile/export-final/:cutId — save exported final image */
561
+ stories.post("/:name/cuts/:plotFile/export-final/:cutId", async (c) => {
562
+ const name = safeName(c.req.param("name"));
563
+ const plotFile = safeName(c.req.param("plotFile"));
564
+ const cutIdStr = c.req.param("cutId");
565
+ if (!name || !plotFile || !cutIdStr) return c.json({ error: "Invalid path" }, 400);
566
+
567
+ const cutId = parseInt(cutIdStr, 10);
568
+ if (isNaN(cutId) || cutId < 1) return c.json({ error: "Invalid cut ID" }, 400);
569
+
570
+ const storyDir = path.join(STORIES_DIR, name);
571
+ if (!fs.existsSync(storyDir) || !fs.statSync(storyDir).isDirectory()) {
572
+ return c.json({ error: "Story not found" }, 404);
573
+ }
574
+
575
+ const cutsFile = readCutsFile(storyDir, plotFile);
576
+ if (!cutsFile) return c.json({ error: "Cuts file not found" }, 404);
577
+
578
+ const cut = cutsFile.cuts.find((ct) => ct.id === cutId);
579
+ if (!cut) return c.json({ error: `Cut ${cutId} not found` }, 404);
580
+
581
+ let formData: FormData;
582
+ try {
583
+ formData = await c.req.formData();
584
+ } catch {
585
+ return c.json({ error: "No file provided" }, 400);
586
+ }
587
+ const file = formData.get("file") as File | Blob | null;
588
+ if (!file || (typeof file === "string")) {
589
+ return c.json({ error: "No file provided" }, 400);
590
+ }
591
+
592
+ if (file.size > 1024 * 1024) {
593
+ return c.json({ error: "File must be under 1MB" }, 400);
594
+ }
595
+
596
+ const mime = file.type;
597
+ if (mime !== "image/webp" && mime !== "image/jpeg") {
598
+ return c.json({ error: "Only WebP and JPEG images are supported" }, 400);
599
+ }
600
+
601
+ const buffer = Buffer.from(await file.arrayBuffer());
602
+ const result = saveExportedCut(storyDir, plotFile, cutId, buffer, mime);
603
+
604
+ return c.json({ ok: true, finalImagePath: result.finalImagePath });
605
+ });
606
+
607
+ /** POST /api/stories/:name/cuts/:plotFile/set-uploaded/:cutId — record upload CID/URL for a cut */
608
+ stories.post("/:name/cuts/:plotFile/set-uploaded/:cutId", async (c) => {
609
+ const name = safeName(c.req.param("name"));
610
+ const plotFile = safeName(c.req.param("plotFile"));
611
+ const cutIdStr = c.req.param("cutId");
612
+ if (!name || !plotFile || !cutIdStr) return c.json({ error: "Invalid path" }, 400);
613
+
614
+ const cutId = parseInt(cutIdStr, 10);
615
+ if (isNaN(cutId) || cutId < 1) return c.json({ error: "Invalid cut ID" }, 400);
616
+
617
+ const storyDir = path.join(STORIES_DIR, name);
618
+ if (!fs.existsSync(storyDir) || !fs.statSync(storyDir).isDirectory()) {
619
+ return c.json({ error: "Story not found" }, 404);
620
+ }
621
+
622
+ const cutsFile = readCutsFile(storyDir, plotFile);
623
+ if (!cutsFile) return c.json({ error: "Cuts file not found" }, 404);
624
+
625
+ const cut = cutsFile.cuts.find((ct) => ct.id === cutId);
626
+ if (!cut) return c.json({ error: `Cut ${cutId} not found` }, 404);
627
+
628
+ const body = await c.req.json<{ cid: string; url: string }>();
629
+ if (!body.cid || !body.url) return c.json({ error: "cid and url required" }, 400);
630
+
631
+ cut.uploadedCid = body.cid;
632
+ cut.uploadedUrl = body.url;
633
+ writeCutsFile(storyDir, plotFile, cutsFile);
634
+
635
+ return c.json({ ok: true });
636
+ });
637
+
638
+ /** POST /api/stories/:name/cuts/:plotFile/generate-markdown — generate/update plot markdown from cuts */
639
+ stories.post("/:name/cuts/:plotFile/generate-markdown", async (c) => {
640
+ const name = safeName(c.req.param("name"));
641
+ const plotFile = safeName(c.req.param("plotFile"));
642
+ if (!name || !plotFile) return c.json({ error: "Invalid path" }, 400);
643
+ const storyDir = path.join(STORIES_DIR, name);
644
+
645
+ if (!fs.existsSync(storyDir) || !fs.statSync(storyDir).isDirectory()) {
646
+ return c.json({ error: "Story not found" }, 404);
647
+ }
648
+
649
+ const cutsFile = readCutsFile(storyDir, plotFile);
650
+ if (!cutsFile) return c.json({ error: "Cuts file not found" }, 404);
651
+
652
+ const mdFile = path.join(storyDir, `${plotFile}.md`);
653
+ const existingMd = fs.existsSync(mdFile) ? fs.readFileSync(mdFile, "utf-8") : "";
654
+
655
+ const { markdown, warnings } = mergeCartoonMarkdown(existingMd, cutsFile.cuts);
656
+ fs.writeFileSync(mdFile, markdown, "utf-8");
657
+
658
+ return c.json({ ok: true, warnings });
659
+ });
660
+
661
+ /**
662
+ * POST /api/stories/:name/cuts/:plotFile/sync-clean-images — detect clean image
663
+ * files that exist on disk and record their path on the matching cut. Only
664
+ * records a path when a real, valid file exists (size ≤ 1MB, allowed extension);
665
+ * invalid/oversized files are reported as `rejected` and never recorded.
666
+ */
667
+ stories.post("/:name/cuts/:plotFile/sync-clean-images", (c) => {
668
+ const name = safeName(c.req.param("name"));
669
+ const plotFile = safeName(c.req.param("plotFile"));
670
+ if (!name || !plotFile) return c.json({ error: "Invalid path" }, 400);
671
+ const storyDir = path.join(STORIES_DIR, name);
672
+
673
+ if (!fs.existsSync(storyDir) || !fs.statSync(storyDir).isDirectory()) {
674
+ return c.json({ error: "Story not found" }, 404);
675
+ }
676
+
677
+ let cutsFile;
678
+ try {
679
+ cutsFile = readCutsFile(storyDir, plotFile);
680
+ } catch (err) {
681
+ return c.json({ error: (err as Error).message }, 400);
682
+ }
683
+ if (!cutsFile) return c.json({ error: "Cuts file not found" }, 404);
684
+
685
+ const rejectedMap = new Map<string, { cutId: number; reason: string }>();
686
+
687
+ // Validate a candidate relative path against the real filesystem (shared
688
+ // validator). Returns true ONLY when the file exists and is a valid WebP/JPEG
689
+ // ≤ 1MB. A present-but-invalid file (wrong extension, oversized, content
690
+ // mismatch) is recorded in `rejected` (deduped by path) so the writer learns
691
+ // why it was not recorded; a merely-absent file is silently "not found".
692
+ const fileExists = (relPath: string): boolean => {
693
+ const issue = imageAssetIssue(storyDir, relPath);
694
+ if (issue === null) return true;
695
+ if (issue === "missing") return false; // absent / non-file → not a rejection
696
+ const cutMatch = relPath.match(/cut-(\d+)-clean\./);
697
+ const cutId = cutMatch ? parseInt(cutMatch[1], 10) : 0;
698
+ rejectedMap.set(relPath, { cutId, reason: issue });
699
+ return false;
700
+ };
701
+
702
+ // Touch every canonical candidate so oversized/invalid files surface as
703
+ // rejections even when a valid one is also present for the same cut.
704
+ for (const cut of cutsFile.cuts) {
705
+ for (const rel of cleanImageCandidates(plotFile, cut.id)) {
706
+ fileExists(rel);
707
+ }
708
+ }
709
+
710
+ // Surface any on-disk clean image with a disallowed extension (e.g. .txt) so
711
+ // the writer learns why it was not recorded — these never become candidates.
712
+ const assetDir = path.join(storyDir, "assets", plotFile);
713
+ if (fs.existsSync(assetDir)) {
714
+ const knownCutIds = new Set(cutsFile.cuts.map((cut) => cut.id));
715
+ for (const entry of fs.readdirSync(assetDir)) {
716
+ const m = entry.match(/^cut-(\d+)-clean\.([A-Za-z0-9]+)$/);
717
+ if (!m) continue;
718
+ const ext = m[2].toLowerCase();
719
+ const cutId = parseInt(m[1], 10);
720
+ if (!knownCutIds.has(cutId)) continue;
721
+ const rel = `assets/${plotFile}/${entry}`;
722
+ if (!CLEAN_IMAGE_VALID_EXT.has(ext) && !rejectedMap.has(rel)) {
723
+ rejectedMap.set(rel, { cutId, reason: `Unsupported extension .${ext}` });
724
+ }
725
+ }
726
+ }
727
+
728
+ const result = syncCleanImages(cutsFile.cuts, plotFile, fileExists);
729
+ const rejected = Array.from(rejectedMap.values());
730
+ if (result.changed) {
731
+ writeCutsFile(storyDir, plotFile, { ...cutsFile, cuts: result.cuts });
732
+ }
733
+
734
+ return c.json({ ok: true, changed: result.changed, synced: result.synced, cleared: result.cleared, rejected });
735
+ });
736
+
737
+ /**
738
+ * GET /api/stories/:name/cuts/:plotFile/detect-clean-images — dry-run detection.
739
+ * Reports the cut ids that have a valid local clean image on disk (exists, ≤ 1MB,
740
+ * magic-byte-valid, extension matches content) AND whose cut currently has
741
+ * `cleanImagePath === null`. This mirrors the sync route's validation but NEVER
742
+ * writes cuts.json — it is read-only so the client can show a per-cut affordance.
743
+ */
744
+ stories.get("/:name/cuts/:plotFile/detect-clean-images", (c) => {
745
+ const name = safeName(c.req.param("name"));
746
+ const plotFile = safeName(c.req.param("plotFile"));
747
+ if (!name || !plotFile) return c.json({ error: "Invalid path" }, 400);
748
+ const storyDir = path.join(STORIES_DIR, name);
749
+
750
+ if (!fs.existsSync(storyDir) || !fs.statSync(storyDir).isDirectory()) {
751
+ return c.json({ error: "Story not found" }, 404);
752
+ }
753
+
754
+ let cutsFile;
755
+ try {
756
+ cutsFile = readCutsFile(storyDir, plotFile);
757
+ } catch (err) {
758
+ return c.json({ error: (err as Error).message }, 400);
759
+ }
760
+ if (!cutsFile) return c.json({ error: "Cuts file not found" }, 404);
761
+
762
+ // Read-only validation via the shared validator (exists + allowed extension +
763
+ // ≤ 1MB + magic-byte content match). Never records rejections or mutates cuts.
764
+ const detected: number[] = [];
765
+ for (const cut of cutsFile.cuts) {
766
+ if (cut.cleanImagePath !== null) continue;
767
+ const hasValid = cleanImageCandidates(plotFile, cut.id).some((rel) =>
768
+ isValidImageAsset(storyDir, rel),
769
+ );
770
+ if (hasValid) detected.push(cut.id);
771
+ }
772
+
773
+ // Also report recorded clean/final paths that no longer point to a valid local
774
+ // image (#302) so the client can show a precise per-cut error and offer the
775
+ // repair action instead of silently treating the cut as image-ready. Skip
776
+ // already-uploaded cuts: their content is on IPFS, so a missing LOCAL file is
777
+ // not a defect to surface.
778
+ const stale = findStaleAssetPaths(cutsFile.cuts, (rel) => isValidImageAsset(storyDir, rel)).filter(
779
+ (issue) => {
780
+ const cut = cutsFile!.cuts.find((ct) => ct.id === issue.cutId);
781
+ return !cut?.uploadedUrl;
782
+ },
783
+ );
784
+
785
+ return c.json({ detected, stale });
786
+ });
787
+
788
+ /**
789
+ * GET /api/stories/:name/cuts/:plotFile/asset-diagnostics — read-only per-cut
790
+ * asset state rescan (#427).
791
+ *
792
+ * Classifies each cut's REAL asset state against the local story folder —
793
+ * planned / missing / clean-ready / final-ready / uploaded — with a precise
794
+ * per-cut reason when a recorded path doesn't resolve (so "files exist but
795
+ * aren't displayed" or "a typoed path" become a clear diagnostic instead of a
796
+ * generic publish warning). Works for genesis.cuts.json and plot-NN.cuts.json
797
+ * equally. Never mutates, uploads, or publishes anything.
798
+ */
799
+ stories.get("/:name/cuts/:plotFile/asset-diagnostics", (c) => {
800
+ const name = safeName(c.req.param("name"));
801
+ const plotFile = safeName(c.req.param("plotFile"));
802
+ if (!name || !plotFile) return c.json({ error: "Invalid path" }, 400);
803
+ const storyDir = path.join(STORIES_DIR, name);
804
+
805
+ if (!fs.existsSync(storyDir) || !fs.statSync(storyDir).isDirectory()) {
806
+ return c.json({ error: "Story not found" }, 404);
807
+ }
808
+
809
+ let cutsFile;
810
+ try {
811
+ cutsFile = readCutsFile(storyDir, plotFile);
812
+ } catch (err) {
813
+ return c.json({ error: (err as Error).message }, 400);
814
+ }
815
+ if (!cutsFile) return c.json({ error: "Cuts file not found" }, 404);
816
+
817
+ // Resolve a convertible PNG clean image for a cut (#441): the recorded clean
818
+ // path when it's a real PNG, else an unrecorded `cut-NN-clean.png` on disk.
819
+ const pngClean = (cut: { id: number; cleanImagePath: string | null }): string | null => {
820
+ if (cut.cleanImagePath && /\.png$/i.test(cut.cleanImagePath) && pngAssetExists(storyDir, cut.cleanImagePath)) {
821
+ return cut.cleanImagePath;
822
+ }
823
+ const candidate = `assets/${plotFile}/cut-${String(cut.id).padStart(2, "0")}-clean.png`;
824
+ return pngAssetExists(storyDir, candidate) ? candidate : null;
825
+ };
826
+
827
+ const diagnostics = diagnoseCutAssets(cutsFile.cuts, (rel) => imageAssetIssue(storyDir, rel), pngClean);
828
+ const summary = summarizeAssetDiagnostics(diagnostics);
829
+ return c.json({ diagnostics, summary });
830
+ });
831
+
832
+ /**
833
+ * POST /api/stories/:name/cuts/:plotFile/repair-asset-paths — clear stale
834
+ * recorded asset paths (#302). Any cleanImagePath/finalImagePath that no longer
835
+ * points to a valid local image is reset to null; valid paths and already-
836
+ * uploaded cuts (uploadedCid/uploadedUrl) are preserved. This is the real repair
837
+ * behind the per-cut "Clear stale path" action and, unlike sync-clean-images,
838
+ * also repairs a stale finalImagePath.
839
+ */
840
+ stories.post("/:name/cuts/:plotFile/repair-asset-paths", (c) => {
841
+ const name = safeName(c.req.param("name"));
842
+ const plotFile = safeName(c.req.param("plotFile"));
843
+ if (!name || !plotFile) return c.json({ error: "Invalid path" }, 400);
844
+ const storyDir = path.join(STORIES_DIR, name);
845
+
846
+ if (!fs.existsSync(storyDir) || !fs.statSync(storyDir).isDirectory()) {
847
+ return c.json({ error: "Story not found" }, 404);
848
+ }
849
+
850
+ let cutsFile;
851
+ try {
852
+ cutsFile = readCutsFile(storyDir, plotFile);
853
+ } catch (err) {
854
+ return c.json({ error: (err as Error).message }, 400);
855
+ }
856
+ if (!cutsFile) return c.json({ error: "Cuts file not found" }, 404);
857
+
858
+ const result = clearStaleAssetPaths(cutsFile.cuts, (rel) => isValidImageAsset(storyDir, rel));
859
+ if (result.changed) {
860
+ writeCutsFile(storyDir, plotFile, { ...cutsFile, cuts: result.cuts });
861
+ }
862
+
863
+ return c.json({ ok: true, changed: result.changed, cleared: result.cleared });
864
+ });
865
+
866
+
867
+ const COVER_MAX_BYTES = 1024 * 1024;
868
+ // Candidate agent-created cover files, in preference order (#296). The agent
869
+ // writes a single cover under assets/; we never guess plot/cut images here.
870
+ const COVER_CANDIDATES = [
871
+ { rel: "assets/cover.webp", type: "image/webp", sniff: "webp" as const },
872
+ { rel: "assets/cover.jpg", type: "image/jpeg", sniff: "jpeg" as const },
873
+ { rel: "assets/cover.jpeg", type: "image/jpeg", sniff: "jpeg" as const },
874
+ ];
875
+
876
+ /**
877
+ * GET /api/stories/:name/cover-asset — detect an agent-created cover image so the
878
+ * genesis pre-publish UI can offer it as the default cover without a manual file
879
+ * pick (#296). Returns the FIRST candidate that exists, with a byte-validated
880
+ * `valid` flag (so an oversize or spoofed cover is surfaced as a warning and not
881
+ * offered/uploaded). `{ found: false }` when no candidate exists.
882
+ */
883
+ stories.get("/:name/cover-asset", (c) => {
884
+ const name = safeName(c.req.param("name"));
885
+ if (!name) return c.json({ error: "Invalid story name" }, 400);
886
+ const storyDir = path.join(STORIES_DIR, name);
887
+
888
+ for (const cand of COVER_CANDIDATES) {
889
+ const full = path.join(storyDir, cand.rel);
890
+ if (!fs.existsSync(full) || !fs.statSync(full).isFile()) continue;
891
+
892
+ const size = fs.statSync(full).size;
893
+ let sniffed: SniffedType = "unknown";
894
+ try {
895
+ const fd = fs.openSync(full, "r");
896
+ try {
897
+ const head = Buffer.alloc(16);
898
+ const read = fs.readSync(fd, head, 0, 16, 0);
899
+ sniffed = sniffImageType(head.subarray(0, read));
900
+ } finally {
901
+ fs.closeSync(fd);
902
+ }
903
+ } catch { /* treat as unreadable → invalid below */ }
904
+
905
+ if (size > COVER_MAX_BYTES) {
906
+ return c.json({ found: true, valid: false, path: cand.rel, type: cand.type, size, error: `${cand.rel} is ${(size / 1024).toFixed(0)}KB, exceeds the 1MB cover limit` });
907
+ }
908
+ if (sniffed !== cand.sniff) {
909
+ return c.json({ found: true, valid: false, path: cand.rel, type: cand.type, size, error: `${cand.rel} is not a valid ${cand.sniff.toUpperCase()} image (file contents do not match)` });
910
+ }
911
+ return c.json({ found: true, valid: true, path: cand.rel, type: cand.type, size });
912
+ }
913
+
914
+ return c.json({ found: false });
915
+ });
916
+
917
+ /** Cover state for the story progress overview (#418): present / invalid / missing. */
918
+ function detectCoverState(storyDir: string): "missing" | "present" | "invalid" {
919
+ for (const cand of COVER_CANDIDATES) {
920
+ const full = path.join(storyDir, cand.rel);
921
+ if (!fs.existsSync(full) || !fs.statSync(full).isFile()) continue;
922
+ const size = fs.statSync(full).size;
923
+ if (size > COVER_MAX_BYTES) return "invalid";
924
+ let sniffed: SniffedType = "unknown";
925
+ try {
926
+ const fd = fs.openSync(full, "r");
927
+ try {
928
+ const head = Buffer.alloc(16);
929
+ const read = fs.readSync(fd, head, 0, 16, 0);
930
+ sniffed = sniffImageType(head.subarray(0, read));
931
+ } finally {
932
+ fs.closeSync(fd);
933
+ }
934
+ } catch { return "invalid"; }
935
+ return sniffed === cand.sniff ? "present" : "invalid";
936
+ }
937
+ return "missing";
938
+ }
939
+
940
+ /**
941
+ * GET /api/stories/:name/progress — story-level production progress map (#418).
942
+ *
943
+ * Aggregates the story's metadata, setup, cover, and per-episode state into one
944
+ * workflow overview so a writer sees what's done and what's next without reading
945
+ * file names or terminal output. Cartoon episodes reuse the same readiness
946
+ * classifier as the per-file publish UI (so a placeholder plot reads as
947
+ * "placeholder", never publish-ready); fiction gets a simpler written/published
948
+ * view. Pure aggregation — no wallet/publish side effects.
949
+ */
950
+ stories.get("/:name/progress", (c) => {
951
+ const name = safeName(c.req.param("name"));
952
+ if (!name) return c.json({ error: "Invalid story name" }, 400);
953
+ const storyDir = path.join(STORIES_DIR, name);
954
+ if (!fs.existsSync(storyDir) || !fs.statSync(storyDir).isDirectory()) {
955
+ return c.json({ error: "Story not found" }, 404);
956
+ }
957
+
958
+ const info = scanStory(storyDir, name);
959
+ const statusByFile = new Map(info.files.map((f) => [f.file, f.status]));
960
+
961
+ // Episodes in reader order: Genesis (Episode 1) first, then plot-NN.
962
+ const episodeFiles = [
963
+ ...(info.hasGenesis ? ["genesis.md"] : []),
964
+ ...info.files
965
+ .map((f) => f.file)
966
+ .filter((f) => /^plot-\d+\.md$/.test(f))
967
+ .sort((a, b) => parseInt(a.match(/\d+/)![0], 10) - parseInt(b.match(/\d+/)![0], 10)),
968
+ ];
969
+
970
+ const episodes = episodeFiles.map((file) => {
971
+ const plotFile = file.replace(/\.md$/, "");
972
+ let markdown = "";
973
+ try { markdown = fs.readFileSync(path.join(storyDir, file), "utf-8"); } catch { /* missing */ }
974
+ let cuts = null;
975
+ let title: string | null = null;
976
+ try {
977
+ const cutsFile = readCutsFile(storyDir, plotFile);
978
+ if (cutsFile) { cuts = cutsFile.cuts; title = typeof cutsFile.title === "string" ? cutsFile.title : null; }
979
+ } catch { /* invalid cuts ⇒ treat as none */ }
980
+ return { file, status: statusByFile.get(file) ?? ("pending" as const), markdown, cuts, title };
981
+ });
982
+
983
+ const progress = buildStoryProgress({
984
+ name,
985
+ contentType: info.contentType,
986
+ title: info.title,
987
+ language: info.language ?? null,
988
+ genre: info.genre ?? null,
989
+ isNsfw: info.isNsfw ?? null,
990
+ hasStructure: info.hasStructure,
991
+ hasGenesis: info.hasGenesis,
992
+ cover: detectCoverState(storyDir),
993
+ episodes,
994
+ });
995
+
996
+ // Persistent workflow coach (#429): one next action for the current state.
997
+ // `?focus=<file>` makes it speak about the file the writer is viewing (so a
998
+ // future-episode placeholder reads as "plan this first"); without it the coach
999
+ // tracks the story's active episode. We also scan each cartoon episode for
1000
+ // clean images that are on disk but not yet recorded in cuts.json, so the
1001
+ // clean-image stage can offer "Refresh assets" instead of "Generate" (#427).
1002
+ let coach = null;
1003
+ if (info.contentType === "cartoon") {
1004
+ const focusFile = c.req.query("focus") || null;
1005
+ const undetectedCleanByFile: Record<string, number> = {};
1006
+ for (const ep of episodes) {
1007
+ if (!ep.cuts) continue;
1008
+ let undetected = 0;
1009
+ for (const cut of ep.cuts) {
1010
+ if (cut.cleanImagePath !== null) continue;
1011
+ const plotFile = ep.file.replace(/\.md$/, "");
1012
+ if (cleanImageCandidates(plotFile, cut.id).some((rel) => isValidImageAsset(storyDir, rel))) undetected++;
1013
+ }
1014
+ if (undetected > 0) undetectedCleanByFile[ep.file] = undetected;
1015
+ }
1016
+ coach = deriveCartoonCoach(progress, { focusFile, undetectedCleanByFile });
1017
+ }
1018
+
1019
+ return c.json({ ...progress, coach });
1020
+ });
1021
+
1022
+ /**
1023
+ * POST /api/stories/:name/import-cover — save a browser-converted cover image as
1024
+ * the deterministic local asset `assets/cover.webp` (or `.jpg`) so a
1025
+ * Codex-generated image can become a compliant cover without agent-side shell
1026
+ * image tools (#301). The browser canvas path (import-image.ts) does the
1027
+ * PNG→WebP conversion and size compression; this route only validates and
1028
+ * persists. Mirrors the upload-clean byte/size checks: WebP/JPEG only, <=1MB,
1029
+ * magic-byte validated so a renamed/oversize file cannot land as a cover.
1030
+ *
1031
+ * To keep #296 auto-detection unambiguous (it returns the FIRST existing
1032
+ * candidate in webp>jpg>jpeg order), any sibling cover.* files are removed so
1033
+ * exactly one cover asset remains after a successful import.
1034
+ */
1035
+ stories.post("/:name/import-cover", async (c) => {
1036
+ const name = safeName(c.req.param("name"));
1037
+ if (!name) return c.json({ error: "Invalid story name" }, 400);
1038
+
1039
+ const storyDir = path.join(STORIES_DIR, name);
1040
+ if (!fs.existsSync(storyDir) || !fs.statSync(storyDir).isDirectory()) {
1041
+ return c.json({ error: "Story not found" }, 404);
1042
+ }
1043
+
1044
+ let formData: FormData;
1045
+ try {
1046
+ formData = await c.req.formData();
1047
+ } catch {
1048
+ return c.json({ error: "No file provided" }, 400);
1049
+ }
1050
+ const file = formData.get("file") as File | Blob | null;
1051
+ if (!file || typeof file === "string") {
1052
+ return c.json({ error: "No file provided" }, 400);
1053
+ }
1054
+
1055
+ if (file.size > 1024 * 1024) {
1056
+ return c.json({ error: "File must be under 1MB" }, 400);
1057
+ }
1058
+
1059
+ const mime = file.type;
1060
+ if (mime !== "image/webp" && mime !== "image/jpeg") {
1061
+ return c.json({ error: "Only WebP and JPEG images are supported" }, 400);
1062
+ }
1063
+
1064
+ // Validate by actual bytes, not the spoofable MIME label, so an unconverted
1065
+ // PNG (or renamed text file) cannot be persisted as a cover. Mirrors
1066
+ // upload-clean (#266).
1067
+ const buffer = Buffer.from(await file.arrayBuffer());
1068
+ if (!cleanImageBytesMatchMime(buffer, mime)) {
1069
+ return c.json(
1070
+ { error: "File content is not a valid WebP/JPEG image (bytes do not match the image type)" },
1071
+ 400,
1072
+ );
1073
+ }
1074
+
1075
+ const assetDir = path.join(storyDir, "assets");
1076
+ fs.mkdirSync(assetDir, { recursive: true });
1077
+
1078
+ // Remove any existing cover.* so detection resolves to exactly this import.
1079
+ for (const cand of COVER_CANDIDATES) {
1080
+ const full = path.join(storyDir, cand.rel);
1081
+ if (fs.existsSync(full)) fs.rmSync(full, { force: true });
1082
+ }
1083
+
1084
+ const ext = mime === "image/webp" ? "webp" : "jpg";
1085
+ const coverPath = `assets/cover.${ext}`;
1086
+ fs.writeFileSync(path.join(storyDir, coverPath), buffer);
1087
+
1088
+ return c.json({ ok: true, path: coverPath, type: mime, size: buffer.length });
1089
+ });
1090
+
1091
+ /** GET /api/stories/:name/asset/:assetPath — serve story asset file (supports nested paths) */
1092
+ // NOTE: uses a regex splat param (`{.+}`) rather than a bare `*` wildcard.
1093
+ // Hono v4 does not populate `c.req.param("*")` for a mixed named/wildcard route
1094
+ // like `/:name/asset/*`, so the handler always saw an empty assetPath and
1095
+ // returned 400 — which surfaced as "Image not available" in the UI once the
1096
+ // clean-image loaders actually started sending the auth header (#278). The
1097
+ // regex param captures the remaining path, including slashes, and is readable
1098
+ // back by name.
1099
+ stories.get("/:name/asset/:assetPath{.+}", (c) => {
1100
+ const name = safeName(c.req.param("name"));
1101
+ if (!name) return c.json({ error: "Invalid story name" }, 400);
1102
+
1103
+ const assetPath = c.req.param("assetPath");
1104
+ if (!assetPath) return c.json({ error: "Invalid asset path" }, 400);
1105
+
1106
+ if (assetPath.includes("..") || assetPath.startsWith("/")) {
1107
+ return c.json({ error: "Invalid asset path" }, 400);
1108
+ }
1109
+
1110
+ const fullPath = path.join(STORIES_DIR, name, "assets", assetPath);
1111
+ const resolved = path.resolve(fullPath);
1112
+ const assetsRoot = path.resolve(path.join(STORIES_DIR, name, "assets"));
1113
+ if (!resolved.startsWith(assetsRoot + path.sep) && resolved !== assetsRoot) {
1114
+ return c.json({ error: "Invalid asset path" }, 400);
1115
+ }
1116
+
1117
+ if (!fs.existsSync(resolved)) {
1118
+ return c.json({ error: "Asset not found" }, 404);
1119
+ }
1120
+
1121
+ const ext = path.extname(resolved).toLowerCase();
1122
+ const mimeTypes: Record<string, string> = {
1123
+ ".webp": "image/webp",
1124
+ ".jpg": "image/jpeg",
1125
+ ".jpeg": "image/jpeg",
1126
+ ".png": "image/png",
1127
+ };
1128
+ const ct = mimeTypes[ext] || "application/octet-stream";
1129
+
1130
+ const data = fs.readFileSync(resolved);
1131
+ return new Response(data, {
1132
+ headers: { "Content-Type": ct, "Cache-Control": "no-cache" },
1133
+ });
1134
+ });
1135
+
180
1136
  /** GET /api/stories/:name/:file — single file content */
181
1137
  stories.get("/:name/:file", (c) => {
182
1138
  const name = safeName(c.req.param("name"));
@@ -290,4 +1246,4 @@ stories.post("/:name/:file/mark-not-indexed", async (c) => {
290
1246
  return c.json({ ok: true });
291
1247
  });
292
1248
 
293
- export { stories as storiesRoutes, readPublishStatus, STORIES_DIR };
1249
+ export { stories as storiesRoutes, readPublishStatus, readStoryMeta, writeStoryMeta, saveExportedCut, STORIES_DIR };