plotlink-ows 1.0.33 → 1.2.95

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 (152) hide show
  1. package/README.md +4 -0
  2. package/app/lib/active-wallet.ts +260 -0
  3. package/app/lib/agent-command.ts +85 -0
  4. package/app/lib/agent-readiness.ts +133 -0
  5. package/app/lib/apply-schema.ts +55 -0
  6. package/app/lib/bubble-text.ts +160 -0
  7. package/app/lib/cartoon-coach.ts +198 -0
  8. package/app/lib/cartoon-markdown.ts +83 -0
  9. package/app/lib/cartoon-prompt.ts +122 -0
  10. package/app/lib/cartoon-readiness.ts +813 -0
  11. package/app/lib/clean-image-sync.ts +245 -0
  12. package/app/lib/codex-images.ts +152 -0
  13. package/app/lib/cut-asset-diagnostics.ts +120 -0
  14. package/app/lib/cuts.ts +302 -0
  15. package/app/lib/fonts.ts +109 -0
  16. package/app/lib/generate-claude-md.ts +8 -1
  17. package/app/lib/generate-story-instructions.ts +731 -0
  18. package/app/lib/image-asset-validate.ts +123 -0
  19. package/app/lib/lettering-status.ts +133 -0
  20. package/app/lib/overlays.ts +637 -0
  21. package/app/lib/paths.ts +10 -0
  22. package/app/lib/public-title.ts +65 -0
  23. package/app/lib/publish.ts +16 -2
  24. package/app/lib/story-progress.ts +242 -0
  25. package/app/lib/terminal-protocol.ts +16 -0
  26. package/app/lib/terminal-redact.ts +50 -0
  27. package/app/prisma/schema.sql +25 -0
  28. package/app/routes/agent.ts +42 -0
  29. package/app/routes/codex-images.ts +67 -0
  30. package/app/routes/dashboard.ts +6 -4
  31. package/app/routes/publish.ts +259 -45
  32. package/app/routes/settings.ts +92 -37
  33. package/app/routes/stories.ts +961 -5
  34. package/app/routes/terminal.ts +383 -31
  35. package/app/routes/wallet.ts +58 -30
  36. package/app/server.ts +47 -12
  37. package/app/vite.config.ts +6 -0
  38. package/app/web/components/CartoonNextAction.tsx +145 -0
  39. package/app/web/components/CartoonPreview.tsx +267 -0
  40. package/app/web/components/CartoonPublishPage.tsx +407 -0
  41. package/app/web/components/CartoonPublishPreview.tsx +121 -0
  42. package/app/web/components/CartoonStepGuide.tsx +90 -0
  43. package/app/web/components/CartoonWorkflowNav.tsx +68 -0
  44. package/app/web/components/CodexImportPicker.tsx +230 -0
  45. package/app/web/components/CutListPanel.tsx +1337 -0
  46. package/app/web/components/Dashboard.tsx +15 -6
  47. package/app/web/components/EpisodesPage.tsx +80 -0
  48. package/app/web/components/FinishEpisodePanel.tsx +151 -0
  49. package/app/web/components/Layout.tsx +7 -4
  50. package/app/web/components/LetteringEditor.tsx +1182 -0
  51. package/app/web/components/PreviewPanel.tsx +952 -78
  52. package/app/web/components/Settings.tsx +63 -0
  53. package/app/web/components/StoriesPage.tsx +745 -33
  54. package/app/web/components/StoryBrowser.tsx +22 -14
  55. package/app/web/components/StoryInfoPage.tsx +266 -0
  56. package/app/web/components/StoryProgressPanel.tsx +446 -0
  57. package/app/web/components/TerminalPanel.tsx +233 -11
  58. package/app/web/components/WalletCard.tsx +110 -8
  59. package/app/web/components/WorkflowCoach.tsx +156 -0
  60. package/app/web/components/asset-image.tsx +114 -0
  61. package/app/web/components/asset-test-utils.ts +44 -0
  62. package/app/web/components/export-cut.ts +320 -0
  63. package/app/web/dist/assets/export-cut-che5mMWc.js +1 -0
  64. package/app/web/dist/assets/index-CcfChGEK.css +32 -0
  65. package/app/web/dist/assets/index-Dc2TQ3Ij.js +143 -0
  66. package/app/web/dist/index.html +2 -2
  67. package/app/web/lib/cartoon-publish-summary.ts +43 -0
  68. package/app/web/lib/codex-import.ts +94 -0
  69. package/app/web/lib/image-compress.ts +53 -0
  70. package/app/web/lib/import-image.ts +58 -0
  71. package/app/web/lib/publish-helpers.ts +385 -0
  72. package/app/web/lib/upload-retry.ts +130 -0
  73. package/app/web/lib/verify-public-title.ts +105 -0
  74. package/app/web/styles.css +9 -0
  75. package/bin/plotlink-ows.js +53 -16
  76. package/bin/startup-plan.cjs +58 -0
  77. package/lib/genres.ts +92 -0
  78. package/package.json +60 -20
  79. package/scripts/gen-schema-sql.mjs +49 -0
  80. package/scripts/package-hygiene.mjs +116 -0
  81. package/scripts/preflight.mjs +173 -0
  82. package/scripts/start-smoke.mjs +128 -0
  83. package/app/node_modules/.prisma/local-client/client.d.ts +0 -1
  84. package/app/node_modules/.prisma/local-client/client.js +0 -5
  85. package/app/node_modules/.prisma/local-client/default.d.ts +0 -1
  86. package/app/node_modules/.prisma/local-client/default.js +0 -5
  87. package/app/node_modules/.prisma/local-client/edge.d.ts +0 -1
  88. package/app/node_modules/.prisma/local-client/edge.js +0 -184
  89. package/app/node_modules/.prisma/local-client/index-browser.js +0 -173
  90. package/app/node_modules/.prisma/local-client/index.d.ts +0 -3304
  91. package/app/node_modules/.prisma/local-client/index.js +0 -207
  92. package/app/node_modules/.prisma/local-client/libquery_engine-darwin-arm64.dylib.node +0 -0
  93. package/app/node_modules/.prisma/local-client/package.json +0 -183
  94. package/app/node_modules/.prisma/local-client/query_engine_bg.js +0 -2
  95. package/app/node_modules/.prisma/local-client/query_engine_bg.wasm +0 -0
  96. package/app/node_modules/.prisma/local-client/runtime/edge-esm.js +0 -35
  97. package/app/node_modules/.prisma/local-client/runtime/edge.js +0 -35
  98. package/app/node_modules/.prisma/local-client/runtime/index-browser.d.ts +0 -370
  99. package/app/node_modules/.prisma/local-client/runtime/index-browser.js +0 -17
  100. package/app/node_modules/.prisma/local-client/runtime/library.d.ts +0 -3982
  101. package/app/node_modules/.prisma/local-client/runtime/library.js +0 -147
  102. package/app/node_modules/.prisma/local-client/runtime/react-native.js +0 -84
  103. package/app/node_modules/.prisma/local-client/runtime/wasm-compiler-edge.js +0 -85
  104. package/app/node_modules/.prisma/local-client/runtime/wasm-engine-edge.js +0 -38
  105. package/app/node_modules/.prisma/local-client/schema.prisma +0 -21
  106. package/app/node_modules/.prisma/local-client/wasm-edge-light-loader.mjs +0 -5
  107. package/app/node_modules/.prisma/local-client/wasm-worker-loader.mjs +0 -5
  108. package/app/node_modules/.prisma/local-client/wasm.d.ts +0 -1
  109. package/app/node_modules/.prisma/local-client/wasm.js +0 -191
  110. package/app/web/dist/assets/index-B-2Ft7Yv.css +0 -32
  111. package/app/web/dist/assets/index-DxATSk7X.js +0 -134
  112. package/packages/cli/node_modules/commander/LICENSE +0 -22
  113. package/packages/cli/node_modules/commander/Readme.md +0 -1149
  114. package/packages/cli/node_modules/commander/esm.mjs +0 -16
  115. package/packages/cli/node_modules/commander/index.js +0 -24
  116. package/packages/cli/node_modules/commander/lib/argument.js +0 -149
  117. package/packages/cli/node_modules/commander/lib/command.js +0 -2662
  118. package/packages/cli/node_modules/commander/lib/error.js +0 -39
  119. package/packages/cli/node_modules/commander/lib/help.js +0 -709
  120. package/packages/cli/node_modules/commander/lib/option.js +0 -367
  121. package/packages/cli/node_modules/commander/lib/suggestSimilar.js +0 -101
  122. package/packages/cli/node_modules/commander/package-support.json +0 -16
  123. package/packages/cli/node_modules/commander/package.json +0 -82
  124. package/packages/cli/node_modules/commander/typings/esm.d.mts +0 -3
  125. package/packages/cli/node_modules/commander/typings/index.d.ts +0 -1045
  126. package/packages/cli/node_modules/resolve-from/index.d.ts +0 -31
  127. package/packages/cli/node_modules/resolve-from/index.js +0 -47
  128. package/packages/cli/node_modules/resolve-from/license +0 -9
  129. package/packages/cli/node_modules/resolve-from/package.json +0 -36
  130. package/packages/cli/node_modules/resolve-from/readme.md +0 -72
  131. package/packages/cli/node_modules/tsup/LICENSE +0 -21
  132. package/packages/cli/node_modules/tsup/README.md +0 -75
  133. package/packages/cli/node_modules/tsup/assets/cjs_shims.js +0 -13
  134. package/packages/cli/node_modules/tsup/assets/esm_shims.js +0 -9
  135. package/packages/cli/node_modules/tsup/assets/package.json +0 -3
  136. package/packages/cli/node_modules/tsup/dist/chunk-DI5BO6XE.js +0 -153
  137. package/packages/cli/node_modules/tsup/dist/chunk-JZ25TPTY.js +0 -42
  138. package/packages/cli/node_modules/tsup/dist/chunk-PEEXUWMS.js +0 -6
  139. package/packages/cli/node_modules/tsup/dist/chunk-TWFEYLU4.js +0 -352
  140. package/packages/cli/node_modules/tsup/dist/chunk-VGC3FXLU.js +0 -203
  141. package/packages/cli/node_modules/tsup/dist/cli-default.js +0 -12
  142. package/packages/cli/node_modules/tsup/dist/cli-main.js +0 -8
  143. package/packages/cli/node_modules/tsup/dist/cli-node.js +0 -14
  144. package/packages/cli/node_modules/tsup/dist/index.d.ts +0 -511
  145. package/packages/cli/node_modules/tsup/dist/index.js +0 -1711
  146. package/packages/cli/node_modules/tsup/dist/rollup.js +0 -6949
  147. package/packages/cli/node_modules/tsup/package.json +0 -99
  148. package/packages/cli/node_modules/tsup/schema.json +0 -362
  149. package/public/screenshot-1.png +0 -0
  150. package/public/screenshot-2.png +0 -0
  151. package/public/screenshot-3.png +0 -0
  152. package/scripts/e2e-verify.ts +0 -1100
package/app/server.ts CHANGED
@@ -22,9 +22,11 @@ import { dashboardRoutes } from "./routes/dashboard";
22
22
  import { terminalRoutes, attachTerminalWs } from "./routes/terminal";
23
23
  import { storiesRoutes } from "./routes/stories";
24
24
  import { settingsRoutes } from "./routes/settings";
25
- import { initDb } from "./db";
25
+ import { agentRoutes } from "./routes/agent";
26
+ import { codexImagesRoutes } from "./routes/codex-images";
27
+ import { db, initDb } from "./db";
26
28
  import { generateClaudeMd } from "./lib/generate-claude-md";
27
- import { execSync } from "child_process";
29
+ import { loadSchemaStatements } from "./lib/apply-schema";
28
30
  import fs from "fs";
29
31
 
30
32
  const __dirname = __dirnamePre;
@@ -48,6 +50,10 @@ app.use("/api/stories/*", requireAuth);
48
50
  app.route("/api/stories", storiesRoutes);
49
51
  app.use("/api/settings/*", requireAuth);
50
52
  app.route("/api/settings", settingsRoutes);
53
+ app.use("/api/agent/*", requireAuth);
54
+ app.route("/api/agent", agentRoutes);
55
+ app.use("/api/codex/*", requireAuth);
56
+ app.route("/api/codex", codexImagesRoutes);
51
57
 
52
58
  // App version (read once at startup)
53
59
  const appVersion = (() => {
@@ -128,15 +134,36 @@ async function start() {
128
134
  // Generate/update ~/.plotlink-ows/CLAUDE.md for agent discovery
129
135
  generateClaudeMd();
130
136
 
131
- // Run Prisma db push to ensure schema is up to date
132
- const schemaPath = path.join(__dirname, "prisma", "schema.prisma");
133
- execSync(`npx prisma db push --schema ${schemaPath} --skip-generate`, {
134
- stdio: "inherit",
135
- env: { ...process.env, DATABASE_URL },
136
- });
137
-
138
- // Initialize database connection
139
- await initDb();
137
+ // Bring the local SQLite schema up to date WITHOUT the native Prisma
138
+ // schema-engine. `prisma db push` spawns a platform-specific schema-engine
139
+ // binary that fails to start in some packed prod-only installs (#484, EPIC
140
+ // #465: an empty "Schema engine error:" on macOS arm64). Instead we apply the
141
+ // committed DDL (app/prisma/schema.sql, generated from schema.prisma) through
142
+ // the Prisma client's library query engine — the same engine the app already
143
+ // uses for every query, so if the app runs at all, schema setup runs too.
144
+ // SQLite creates the DB file but NOT its parent dir, so ensure
145
+ // ~/.plotlink-ows/data exists first (a fresh prod-only install has none).
146
+ fs.mkdirSync(DATA_DIR, { recursive: true });
147
+ const schemaSqlPath = path.join(__dirname, "prisma", "schema.sql");
148
+ try {
149
+ await initDb(); // connect the client (library query engine; no schema-engine)
150
+ // Statements are CREATE TABLE/INDEX IF NOT EXISTS, so this is idempotent and
151
+ // safe to run on every startup against an existing database.
152
+ for (const statement of loadSchemaStatements(schemaSqlPath)) {
153
+ await db.$executeRawUnsafe(statement);
154
+ }
155
+ } catch (err) {
156
+ // Surface a useful diagnostic instead of a raw stack (#479/#484).
157
+ const home = os.homedir();
158
+ const redact = (s: string) => s.split(home).join("~");
159
+ console.error("\n ✗ Database setup failed (applying schema.sql).");
160
+ console.error(` schema: ${redact(schemaSqlPath)}`);
161
+ console.error(` database: ${redact(DATABASE_URL)}`);
162
+ console.error(` reason: ${err instanceof Error ? err.message : String(err)}`);
163
+ console.error(" This usually means a corrupted install (missing the generated Prisma");
164
+ console.error(" client/query engine or schema.sql). Reinstall with: npx plotlink-ows@latest\n");
165
+ process.exit(1);
166
+ }
140
167
 
141
168
  const port = Number(process.env.APP_PORT) || 7777;
142
169
  const server = serve({ fetch: app.fetch, port }, (info) => {
@@ -159,8 +186,16 @@ async function start() {
159
186
  if (!session || session.expiresAt < new Date()) { socket.destroy(); return; }
160
187
  const story = url.searchParams.get("story") || undefined;
161
188
  const resume = url.searchParams.get("resume") === "true";
189
+ const bypass = url.searchParams.get("bypass") === "true";
190
+ const provider = url.searchParams.get("provider");
162
191
  wss.handleUpgrade(req, socket, head, (ws) => {
163
- attachTerminalWs(ws as unknown as WebSocket, story, resume);
192
+ attachTerminalWs(
193
+ ws as unknown as WebSocket,
194
+ story,
195
+ resume,
196
+ bypass,
197
+ provider === "claude" || provider === "codex" ? provider : undefined,
198
+ );
164
199
  });
165
200
  }).catch(() => socket.destroy());
166
201
  }
@@ -1,10 +1,16 @@
1
1
  import { defineConfig } from "vite";
2
2
  import react from "@vitejs/plugin-react";
3
3
  import tailwindcss from "@tailwindcss/vite";
4
+ import path from "path";
4
5
 
5
6
  export default defineConfig({
6
7
  root: "app/web",
7
8
  plugins: [react(), tailwindcss()],
9
+ resolve: {
10
+ alias: {
11
+ "@app-lib": path.resolve(__dirname, "lib"),
12
+ },
13
+ },
8
14
  server: {
9
15
  port: 5173,
10
16
  proxy: {
@@ -0,0 +1,145 @@
1
+ import { useEffect, useState } from "react";
2
+ import type { StoryProgress } from "@app-lib/story-progress";
3
+ import type { CoachUiAction } from "@app-lib/cartoon-coach";
4
+ import { WorkflowCoachView } from "./WorkflowCoach";
5
+
6
+ export function storyInfoNextStep(progress: StoryProgress): string {
7
+ if (progress.cover !== "present") {
8
+ return progress.cover === "invalid"
9
+ ? "Replace the cover image - it must be a valid WebP or JPEG."
10
+ : "Add a cover image before publishing.";
11
+ }
12
+ const missing: string[] = [];
13
+ if (!progress.metadata.language) missing.push("language");
14
+ if (!progress.metadata.genre) missing.push("genre");
15
+ if (!progress.metadata.title) missing.push("title");
16
+ return `Add the story ${missing.join(" and ") || "details"} before publishing.`;
17
+ }
18
+
19
+ export function cartoonWorkflowActiveKey(progress: StoryProgress): string | null {
20
+ const coach = progress.coach ?? null;
21
+ const m = progress.metadata;
22
+ const hasStructure = progress.setup.hasStructure;
23
+ const hasGenesis = progress.setup.hasGenesis;
24
+ const coverDone = progress.cover === "present";
25
+ const metadataIncomplete = !m.title || !m.language || !m.genre;
26
+ const activeEp = progress.episodes.find((e) => !e.published) ?? null;
27
+ const productionPending = !!activeEp && activeEp.state !== "ready";
28
+
29
+ if (!hasStructure) return "whitepaper";
30
+ if (!hasGenesis) return "genesis.md";
31
+ if (metadataIncomplete) return "story-info";
32
+ if (productionPending && coach?.episodeFile) return coach.episodeFile;
33
+ if (!coverDone) return "story-info";
34
+ return coach?.episodeFile ?? null;
35
+ }
36
+
37
+ export function StoryInfoNextActionCard({
38
+ progress,
39
+ onOpenStoryInfo,
40
+ }: {
41
+ progress: StoryProgress;
42
+ onOpenStoryInfo?: () => void;
43
+ }) {
44
+ return (
45
+ <div className="m-3 rounded-lg border border-accent/40 bg-accent/10 px-4 py-3 shadow-sm" data-testid="story-info-cta">
46
+ <div className="flex items-center gap-3">
47
+ <div className="min-w-0 flex-1">
48
+ <span className="inline-flex rounded-full bg-background px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.14em] text-accent">
49
+ Story info
50
+ </span>
51
+ <p className="mt-1 text-sm text-foreground" data-testid="story-info-next-action">
52
+ <span className="font-semibold">Next: </span>
53
+ <span>{storyInfoNextStep(progress)}</span>
54
+ </p>
55
+ </div>
56
+ <button
57
+ type="button"
58
+ onClick={onOpenStoryInfo}
59
+ disabled={!onOpenStoryInfo}
60
+ className="flex-shrink-0 rounded bg-accent px-4 py-2.5 text-sm font-bold text-white shadow-sm transition-colors hover:bg-accent-dim disabled:cursor-not-allowed disabled:opacity-50"
61
+ data-testid="story-info-next-action-btn"
62
+ >
63
+ Next Action
64
+ </button>
65
+ </div>
66
+ </div>
67
+ );
68
+ }
69
+
70
+ export function CartoonNextActionView({
71
+ progress,
72
+ onCoachAction,
73
+ onOpenStoryInfo,
74
+ }: {
75
+ progress: StoryProgress;
76
+ onCoachAction: (action: CoachUiAction, episodeFile: string | null) => void;
77
+ onOpenStoryInfo?: () => void;
78
+ }) {
79
+ const activeKey = cartoonWorkflowActiveKey(progress);
80
+ if (activeKey === "story-info") {
81
+ return <StoryInfoNextActionCard progress={progress} onOpenStoryInfo={onOpenStoryInfo} />;
82
+ }
83
+ return (
84
+ <WorkflowCoachView
85
+ coach={progress.coach ?? null}
86
+ showEmptyState
87
+ onAction={onCoachAction}
88
+ />
89
+ );
90
+ }
91
+
92
+ export function CartoonNextAction({
93
+ storyName,
94
+ authFetch,
95
+ refreshKey = 0,
96
+ onCoachAction,
97
+ onOpenStoryInfo,
98
+ }: {
99
+ storyName: string;
100
+ authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
101
+ refreshKey?: number;
102
+ onCoachAction: (action: CoachUiAction, episodeFile: string | null) => void;
103
+ onOpenStoryInfo?: () => void;
104
+ }) {
105
+ const [progress, setProgress] = useState<StoryProgress | null | undefined>(undefined);
106
+
107
+ const targetKey = JSON.stringify([storyName, refreshKey]);
108
+ const [loadedKey, setLoadedKey] = useState<string | null>(null);
109
+ if (loadedKey !== targetKey) {
110
+ setProgress(undefined);
111
+ setLoadedKey(targetKey);
112
+ }
113
+
114
+ useEffect(() => {
115
+ let cancelled = false;
116
+ authFetch(`/api/stories/${storyName}/progress`)
117
+ .then((res) => (res.ok ? res.json() : null))
118
+ .then((data: StoryProgress | null) => {
119
+ if (!cancelled) setProgress(isValidProgress(data) ? data : null);
120
+ })
121
+ .catch(() => {
122
+ if (!cancelled) setProgress(null);
123
+ });
124
+ return () => { cancelled = true; };
125
+ }, [storyName, authFetch, refreshKey]);
126
+
127
+ if (progress === undefined) return null;
128
+ if (!progress) {
129
+ return <WorkflowCoachView coach={null} showEmptyState onAction={onCoachAction} />;
130
+ }
131
+ return (
132
+ <CartoonNextActionView
133
+ progress={progress}
134
+ onCoachAction={onCoachAction}
135
+ onOpenStoryInfo={onOpenStoryInfo}
136
+ />
137
+ );
138
+ }
139
+
140
+ function isValidProgress(data: StoryProgress | null): data is StoryProgress {
141
+ return !!data
142
+ && !!data.metadata
143
+ && !!data.setup
144
+ && Array.isArray(data.episodes);
145
+ }
@@ -0,0 +1,267 @@
1
+ import { useState, useEffect, useCallback } from "react";
2
+ import { AssetImage } from "./asset-image";
3
+ import { cutNextAction } from "@app-lib/cuts";
4
+
5
+ type AuthFetch = (url: string, opts?: RequestInit) => Promise<Response>;
6
+
7
+ interface CutDialogue {
8
+ speaker: string;
9
+ text: string;
10
+ }
11
+
12
+ interface Cut {
13
+ id: number;
14
+ shotType: string;
15
+ description: string;
16
+ characters: string[];
17
+ dialogue: CutDialogue[];
18
+ narration: string;
19
+ sfx: string;
20
+ cleanImagePath: string | null;
21
+ finalImagePath: string | null;
22
+ exportedAt: string | null;
23
+ uploadedCid: string | null;
24
+ uploadedUrl: string | null;
25
+ kind?: "image" | "text";
26
+ background?: string;
27
+ }
28
+
29
+ interface CutsFile {
30
+ version: number;
31
+ plotFile: string;
32
+ cuts: Cut[];
33
+ }
34
+
35
+ interface CartoonPreviewProps {
36
+ storyName: string;
37
+ fileName: string;
38
+ authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
39
+ // #371: deep-link from a cut's next-action CTA into the Edit tab for that exact
40
+ // cut. `opensEditor` is whether the lettering editor can open directly (clean
41
+ // image / text panel / final) vs. just focusing the row to add clean art.
42
+ onEditCut?: (cutId: number, opensEditor: boolean) => void;
43
+ }
44
+
45
+ function TextOverlay({ cut }: { cut: Cut }) {
46
+ const hasText = cut.dialogue.length > 0 || cut.narration || cut.sfx;
47
+ if (!hasText) return null;
48
+
49
+ return (
50
+ <div className="space-y-1.5" data-testid={`cut-${cut.id}-overlay`}>
51
+ {cut.dialogue.map((d, i) => (
52
+ <div key={i} className="flex gap-2 text-xs">
53
+ <span className="font-medium text-foreground flex-shrink-0">{d.speaker}:</span>
54
+ <span className="text-foreground">{d.text}</span>
55
+ </div>
56
+ ))}
57
+ {cut.narration && (
58
+ <div className="border-l-2 border-border pl-3">
59
+ <p className="text-xs text-muted italic">{cut.narration}</p>
60
+ </div>
61
+ )}
62
+ {cut.sfx && (
63
+ <p className="text-xs font-mono text-muted">SFX: {cut.sfx}</p>
64
+ )}
65
+ </div>
66
+ );
67
+ }
68
+
69
+ function CutCard({ cut, storyName, authFetch, onEditCut }: { cut: Cut; storyName: string; authFetch: AuthFetch; onEditCut?: (cutId: number, opensEditor: boolean) => void }) {
70
+ const hasFinal = !!cut.finalImagePath;
71
+ const hasClean = !!cut.cleanImagePath;
72
+ const hasImage = hasFinal || hasClean;
73
+ // A cut with no clean/final image is a planned image cut whose art is still
74
+ // pending — even if narration/dialogue text already exists in cuts.json. It is
75
+ // NOT a finished narration-only card.
76
+ const hasPlannedText = cut.dialogue.length > 0 || !!cut.narration || !!cut.sfx;
77
+ const isTextPanel = cut.kind === "text";
78
+
79
+ return (
80
+ <div className="space-y-2">
81
+ {/* Cut header */}
82
+ <div className="flex items-center gap-2">
83
+ <span className="text-[10px] font-mono text-muted bg-surface border border-border rounded px-1.5 py-0.5">
84
+ #{cut.id}
85
+ </span>
86
+ <span className="text-[10px] font-mono text-muted">{cut.shotType}</span>
87
+ {cut.characters.length > 0 && (
88
+ <span className="text-[10px] text-muted truncate">
89
+ {cut.characters.join(", ")}
90
+ </span>
91
+ )}
92
+ </div>
93
+
94
+ {/* Final image — lettered, no overlay needed */}
95
+ {hasFinal && (
96
+ <AssetImage
97
+ storyName={storyName}
98
+ assetPath={cut.finalImagePath!}
99
+ authFetch={authFetch}
100
+ alt={cut.description || `Cut ${cut.id}`}
101
+ />
102
+ )}
103
+
104
+ {/* Clean image with text overlay */}
105
+ {!hasFinal && hasClean && (
106
+ <div className="border border-border rounded overflow-hidden">
107
+ <AssetImage
108
+ storyName={storyName}
109
+ assetPath={cut.cleanImagePath!}
110
+ authFetch={authFetch}
111
+ alt={cut.description || `Cut ${cut.id}`}
112
+ />
113
+ <div className="px-3 py-2 bg-surface/80 border-t border-border">
114
+ <TextOverlay cut={cut} />
115
+ </div>
116
+ </div>
117
+ )}
118
+
119
+ {/* Intentional text/interstitial panel (#351) — not pending art. Shows on
120
+ its styled background; the text is the panel content, not a caption. */}
121
+ {!hasImage && isTextPanel && (
122
+ <div
123
+ className="w-full border border-border rounded p-4 space-y-2"
124
+ style={{ background: cut.background || undefined }}
125
+ data-testid={`cut-${cut.id}-textpanel`}
126
+ >
127
+ <span className="text-[10px] font-mono text-muted">Text panel</span>
128
+ {hasPlannedText ? (
129
+ <TextOverlay cut={cut} />
130
+ ) : (
131
+ <p className="text-xs text-muted italic">Empty text panel — open the editor to add text.</p>
132
+ )}
133
+ </div>
134
+ )}
135
+
136
+ {/* Planned image cut — art not generated/uploaded yet */}
137
+ {!hasImage && !isTextPanel && (
138
+ <div
139
+ className="w-full bg-surface border border-dashed border-border rounded p-4 space-y-2"
140
+ data-testid={`cut-${cut.id}-pending`}
141
+ >
142
+ <div className="aspect-video flex flex-col items-center justify-center gap-1 text-center">
143
+ <span className="text-xs text-muted font-medium">Image pending</span>
144
+ <span className="text-[10px] text-muted">Planned image cut — generate &amp; upload the art</span>
145
+ </div>
146
+ {hasPlannedText && (
147
+ <div className="border-t border-dashed border-border pt-2 space-y-1">
148
+ <span className="text-[10px] font-mono text-muted">Planned text (will be lettered onto the image)</span>
149
+ <TextOverlay cut={cut} />
150
+ </div>
151
+ )}
152
+ </div>
153
+ )}
154
+
155
+ {/* Description */}
156
+ {cut.description && (
157
+ <p className="text-xs text-muted italic">{cut.description}</p>
158
+ )}
159
+
160
+ {/* Text shown below final images (already lettered, so just metadata) */}
161
+ {hasFinal && (
162
+ <TextOverlay cut={cut} />
163
+ )}
164
+
165
+ {/* #371: direct next-action CTA — jumps to the Edit tab for THIS cut so the
166
+ writer never has to hunt for it in the cut list. Works for image cuts
167
+ and text/interstitial panels alike. */}
168
+ {onEditCut && (() => {
169
+ const action = cutNextAction(cut);
170
+ return (
171
+ <button
172
+ type="button"
173
+ data-testid={`cut-${cut.id}-cta`}
174
+ data-cut-action={action.key}
175
+ onClick={() => onEditCut(cut.id, action.opensEditor)}
176
+ className="w-full px-3 py-1.5 text-xs font-medium rounded bg-accent text-white hover:bg-accent-dim"
177
+ >
178
+ {action.label}
179
+ </button>
180
+ );
181
+ })()}
182
+ </div>
183
+ );
184
+ }
185
+
186
+ export function CartoonPreview({ storyName, fileName, authFetch, onEditCut }: CartoonPreviewProps) {
187
+ const [cutsFile, setCutsFile] = useState<CutsFile | null>(null);
188
+ const [loading, setLoading] = useState(true);
189
+ const [error, setError] = useState<string | null>(null);
190
+
191
+ const plotFile = fileName.replace(/\.md$/, "");
192
+
193
+ const loadCuts = useCallback(async () => {
194
+ setLoading(true);
195
+ setError(null);
196
+ try {
197
+ const res = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}`);
198
+ if (res.status === 404) {
199
+ setCutsFile(null);
200
+ setLoading(false);
201
+ return;
202
+ }
203
+ if (!res.ok) {
204
+ const data = await res.json();
205
+ setError(data.error || "Failed to load cuts");
206
+ setLoading(false);
207
+ return;
208
+ }
209
+ const data = await res.json();
210
+ setCutsFile(data);
211
+ } catch {
212
+ setError("Failed to load cuts");
213
+ } finally {
214
+ setLoading(false);
215
+ }
216
+ }, [authFetch, storyName, plotFile]);
217
+
218
+ useEffect(() => {
219
+ loadCuts();
220
+ const interval = setInterval(loadCuts, 5000);
221
+ return () => clearInterval(interval);
222
+ }, [loadCuts]);
223
+
224
+ if (loading && !cutsFile) {
225
+ return (
226
+ <div className="h-full flex items-center justify-center text-muted text-sm">
227
+ Loading cuts...
228
+ </div>
229
+ );
230
+ }
231
+
232
+ if (error) {
233
+ return (
234
+ <div className="h-full flex flex-col items-center justify-center gap-2 px-4 text-center" data-testid="cuts-error">
235
+ <p className="text-sm text-error font-medium">Invalid cuts file</p>
236
+ <p className="text-xs text-error">{error}</p>
237
+ <p className="text-xs text-muted max-w-sm">
238
+ {plotFile}.cuts.json must follow the OWS v1 schema. Ask Claude to regenerate it using the v1 cuts schema shown in the cartoon writing instructions.
239
+ </p>
240
+ <button onClick={loadCuts} className="text-xs text-accent hover:text-accent-dim">
241
+ Retry
242
+ </button>
243
+ </div>
244
+ );
245
+ }
246
+
247
+ if (!cutsFile || cutsFile.cuts.length === 0) {
248
+ return (
249
+ <div className="h-full flex flex-col items-center justify-center gap-2 px-4 text-center">
250
+ <p className="text-sm text-muted">No cuts yet</p>
251
+ <p className="text-xs text-muted">
252
+ Ask Claude to create a cut plan for this episode.
253
+ </p>
254
+ </div>
255
+ );
256
+ }
257
+
258
+ return (
259
+ <div className="h-full overflow-y-auto">
260
+ <div className="max-w-lg mx-auto px-4 py-6 space-y-6">
261
+ {cutsFile.cuts.map((cut) => (
262
+ <CutCard key={cut.id} cut={cut} storyName={storyName} authFetch={authFetch} onEditCut={onEditCut} />
263
+ ))}
264
+ </div>
265
+ </div>
266
+ );
267
+ }