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
@@ -0,0 +1,160 @@
1
+ // Shared text layout for cartoon lettering bubbles (#310).
2
+ //
3
+ // Both the export canvas (export-cut.ts) and the editor preview (LetteringEditor)
4
+ // run THIS function with a canvas `measureText`-based width measurer, so dialogue
5
+ // wraps by words and the font is sized to fit the bubble identically in the
6
+ // preview and the exported final image (WYSIWYG). Previously each drew a single
7
+ // maxWidth-compressed line, so long dialogue overflowed/clipped and the preview
8
+ // did not match the export.
9
+
10
+ export interface BubbleTextLayout {
11
+ /** Wrapped lines of body text (never empty; [""] for empty text). */
12
+ lines: string[];
13
+ /** Chosen body font size in the caller's pixel space. */
14
+ fontSize: number;
15
+ /** Line advance (fontSize * lineHeightFactor). */
16
+ lineHeight: number;
17
+ /** Speaker label font size, or 0 when there is no speaker. */
18
+ speakerFontSize: number;
19
+ /**
20
+ * True when the text did not fit even at the minimum font (the lines are a
21
+ * best-effort wrap that may clip/overflow the box). Drives the editor's
22
+ * text-overflow warning (#336). Export rendering ignores it (unchanged).
23
+ */
24
+ overflow: boolean;
25
+ }
26
+
27
+ export interface BubbleLayoutOptions {
28
+ /** Largest body font to try, in the caller's pixel space. */
29
+ maxFontSize: number;
30
+ /** Smallest body font (used even if text still overflows). */
31
+ minFontSize: number;
32
+ /** Fixed body font size; when present, skip auto-fit and use this size. */
33
+ fontSize?: number;
34
+ /** Line advance as a multiple of font size. Default 1.2. */
35
+ lineHeightFactor?: number;
36
+ /** Speaker-label size as a multiple of body font size. Default 0.8. */
37
+ speakerScale?: number;
38
+ /** Body text weight, for consistent bold/regular measurement and layout. */
39
+ fontWeight?: 400 | 700;
40
+ /** Horizontal padding inside the box (each side). Default 6% of width. */
41
+ paddingX?: number;
42
+ /** Vertical padding inside the box (each side). Default 8% of height. */
43
+ paddingY?: number;
44
+ /** Present a speaker label strip above the body. Default false. */
45
+ hasSpeaker?: boolean;
46
+ }
47
+
48
+ /** Measure rendered width of `text` at `fontSize` (canvas measureText-backed). */
49
+ export type MeasureWidth = (text: string, fontSize: number, fontWeight?: 400 | 700) => number;
50
+
51
+ /** Greedy word-wrap of `text` to lines no wider than `maxWidth` at `fontSize`. */
52
+ export function wrapText(
53
+ measure: MeasureWidth,
54
+ text: string,
55
+ maxWidth: number,
56
+ fontSize: number,
57
+ ): string[] {
58
+ const words = text.split(/\s+/).filter(Boolean);
59
+ if (words.length === 0) return [""];
60
+ const lines: string[] = [];
61
+ let current = "";
62
+ for (const word of words) {
63
+ const candidate = current ? `${current} ${word}` : word;
64
+ // Keep a word on the current line if it fits, or if the line is empty (a
65
+ // single over-long word still occupies its own line — the fit loop shrinks
66
+ // the font until it fits the box).
67
+ if (!current || measure(candidate, fontSize) <= maxWidth) {
68
+ current = candidate;
69
+ } else {
70
+ lines.push(current);
71
+ current = word;
72
+ }
73
+ }
74
+ if (current) lines.push(current);
75
+ return lines;
76
+ }
77
+
78
+ /**
79
+ * Lay out bubble text: pick the largest font (between min and max) at which the
80
+ * word-wrapped lines fit the box width AND total height, reserving a strip for a
81
+ * speaker label when present. Deterministic given the same `measure`, so the
82
+ * editor preview and the export canvas produce identical wrapping/sizing.
83
+ */
84
+ export function layoutBubbleText(
85
+ measure: MeasureWidth,
86
+ text: string,
87
+ boxWidth: number,
88
+ boxHeight: number,
89
+ opts: BubbleLayoutOptions,
90
+ ): BubbleTextLayout {
91
+ const lineHeightFactor = opts.lineHeightFactor ?? 1.2;
92
+ const speakerScale = opts.speakerScale ?? 0.8;
93
+ const padX = opts.paddingX ?? Math.max(2, boxWidth * 0.06);
94
+ const padY = opts.paddingY ?? Math.max(2, boxHeight * 0.08);
95
+ const availW = Math.max(1, boxWidth - 2 * padX);
96
+ const totalAvailH = Math.max(1, boxHeight - 2 * padY);
97
+
98
+ const maxFont = Math.max(opts.minFontSize, opts.maxFontSize);
99
+ const minFont = Math.max(1, Math.min(opts.minFontSize, maxFont));
100
+
101
+ const fit = (bodyFont: number): { lines: string[]; ok: boolean } => {
102
+ const speakerFont = opts.hasSpeaker ? bodyFont * speakerScale : 0;
103
+ const speakerStrip = opts.hasSpeaker ? speakerFont * lineHeightFactor : 0;
104
+ const bodyAvailH = Math.max(1, totalAvailH - speakerStrip);
105
+ const fontWeight = opts.fontWeight ?? 400;
106
+ const lines = wrapText((line, fontSize) => measure(line, fontSize, fontWeight), text, availW, bodyFont);
107
+ const bodyH = lines.length * bodyFont * lineHeightFactor;
108
+ const widthOk = lines.every((l) => measure(l, bodyFont, fontWeight) <= availW + 0.5);
109
+ return { lines, ok: bodyH <= bodyAvailH && widthOk };
110
+ };
111
+
112
+ if (typeof opts.fontSize === "number" && Number.isFinite(opts.fontSize) && opts.fontSize > 0) {
113
+ const bodyFont = Math.max(1, opts.fontSize);
114
+ const { lines, ok } = fit(bodyFont);
115
+ return {
116
+ lines,
117
+ fontSize: bodyFont,
118
+ lineHeight: bodyFont * lineHeightFactor,
119
+ speakerFontSize: opts.hasSpeaker ? bodyFont * speakerScale : 0,
120
+ overflow: !ok,
121
+ };
122
+ }
123
+
124
+ // Descend from max to min font (0.5px steps) and take the first that fits.
125
+ for (let f = maxFont; f >= minFont; f -= 0.5) {
126
+ const { lines, ok } = fit(f);
127
+ if (ok) {
128
+ return {
129
+ lines,
130
+ fontSize: f,
131
+ lineHeight: f * lineHeightFactor,
132
+ speakerFontSize: opts.hasSpeaker ? f * speakerScale : 0,
133
+ overflow: false,
134
+ };
135
+ }
136
+ }
137
+
138
+ // Nothing fits even at min — best effort: wrap at min font (may overflow).
139
+ const lines = wrapText(measure, text, availW, minFont);
140
+ return {
141
+ lines,
142
+ fontSize: minFont,
143
+ lineHeight: minFont * lineHeightFactor,
144
+ speakerFontSize: opts.hasSpeaker ? minFont * speakerScale : 0,
145
+ overflow: true,
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Default body min/max font sizes for a bubble, as fractions of the rendering
151
+ * HEIGHT so the export (natural image size) and the editor preview (displayed
152
+ * size) scale together — identical wrapping at both scales. `renderHeight` is
153
+ * the canvas/image height in the caller's pixel space.
154
+ */
155
+ export function defaultBubbleFontRange(renderHeight: number): { minFontSize: number; maxFontSize: number } {
156
+ return {
157
+ minFontSize: Math.max(1, renderHeight * 0.022),
158
+ maxFontSize: Math.max(1, renderHeight * 0.05),
159
+ };
160
+ }
@@ -0,0 +1,198 @@
1
+ // Persistent cartoon workflow coach (#429).
2
+ //
3
+ // The cartoon production flow is long and non-obvious — create story → bible →
4
+ // Genesis → plan cuts → clean images → letter → export → upload → prepare →
5
+ // publish → verify. Each individual screen was improved across #418–#427, but a
6
+ // normal writer still needs ONE persistent, front-end guide that converts the
7
+ // current story/episode state into a single clear next action, without reading
8
+ // terminal logs or technical warnings.
9
+ //
10
+ // This derives that coach PURELY from the already-built `StoryProgress` (which
11
+ // the route assembles from .story.json, structure.md, genesis.md, the cuts.json
12
+ // files, local assets, exports, uploaded URLs and publish status), plus a small
13
+ // per-episode disk hint (clean images present on disk but not yet recorded). It
14
+ // returns one stage label + one primary action, typed as either an agent
15
+ // copy-paste prompt or a direct in-app UI action. Fiction returns null so the
16
+ // fiction UX is completely untouched.
17
+
18
+ import type { StoryProgress, EpisodeProgress } from "./story-progress";
19
+
20
+ /** A direct, app-driven next step the UI can perform/route to. */
21
+ export type CoachUiAction =
22
+ | "open-cuts" // reveal the cut workspace
23
+ | "open-lettering" // open the cut workspace to letter / export
24
+ | "refresh-assets" // re-scan local clean images (#427)
25
+ | "upload" // upload the final images
26
+ | "generate-markdown" // "Prepare the episode for publish"
27
+ | "publish" // publish the episode to PlotLink
28
+ | "view-progress"; // open the story progress overview (#418)
29
+
30
+ export type CoachActionKind = "agent" | "ui";
31
+
32
+ export interface CartoonCoach {
33
+ /** Short current-stage label, e.g. "Clean images ready". */
34
+ stageLabel: string;
35
+ /** One primary next action in user-facing verbs, e.g. "Review cuts and start lettering". */
36
+ action: string;
37
+ actionKind: CoachActionKind;
38
+ /** Copy-paste agent prompt when `actionKind === "agent"`; null for UI actions. */
39
+ prompt: string | null;
40
+ /** The in-app action key when `actionKind === "ui"`; null for agent actions. */
41
+ uiAction: CoachUiAction | null;
42
+ /** Episode this action concerns (so the overview can deep-link), or null for setup-level steps. */
43
+ episodeFile: string | null;
44
+ }
45
+
46
+ export interface CoachOptions {
47
+ /**
48
+ * Currently-viewed file (e.g. "plot-02.md"). When it names an unfinished
49
+ * cartoon episode the coach speaks about THAT episode, so a future-episode
50
+ * placeholder reads as "Plan this episode first" instead of pointing at the
51
+ * story's active episode. Ignored for non-episode files (structure.md) and
52
+ * already-published episodes — those fall back to the story's active episode.
53
+ */
54
+ focusFile?: string | null;
55
+ /**
56
+ * Per-episode count of clean images present on disk but NOT yet recorded in
57
+ * cuts.json (acceptance #2). When > 0 at the clean-image stage the coach
58
+ * surfaces "Refresh assets" (re-detect) instead of "Generate clean images".
59
+ */
60
+ undetectedCleanByFile?: Record<string, number>;
61
+ }
62
+
63
+ function agent(stageLabel: string, action: string, prompt: string, episodeFile: string | null): CartoonCoach {
64
+ return { stageLabel, action, actionKind: "agent", prompt, uiAction: null, episodeFile };
65
+ }
66
+
67
+ function ui(stageLabel: string, action: string, uiAction: CoachUiAction, episodeFile: string | null): CartoonCoach {
68
+ return { stageLabel, action, actionKind: "ui", prompt: null, uiAction, episodeFile };
69
+ }
70
+
71
+ /** "genesis.cuts.json" | "plot-01.cuts.json" — the cut plan a writer points the agent at. */
72
+ function cutsFileName(episodeFile: string): string {
73
+ return episodeFile === "genesis.md" ? "genesis.cuts.json" : episodeFile.replace(/\.md$/, ".cuts.json");
74
+ }
75
+
76
+ /**
77
+ * Convert the story/episode state into the single next action a cartoon writer
78
+ * should take. Returns null for fiction (so fiction UX is unchanged) and for a
79
+ * cartoon story that is already fully published with nothing queued.
80
+ */
81
+ export function deriveCartoonCoach(progress: StoryProgress, opts: CoachOptions = {}): CartoonCoach | null {
82
+ if (progress.contentType !== "cartoon") return null;
83
+
84
+ // Setup gates block the whole story, so they take priority over any episode —
85
+ // regardless of which file is in focus. These mirror buildStoryProgress's
86
+ // setup ordering so the coach never disagrees with the progress overview.
87
+ if (!progress.setup.hasStructure) {
88
+ return agent(
89
+ "New cartoon story",
90
+ "Write the story bible",
91
+ "Let's build this cartoon. Write the story bible (structure.md) — visual style, character bible, and episode format. Don't generate images, letter, upload, or publish yet.",
92
+ "structure.md",
93
+ );
94
+ }
95
+ if (!progress.setup.hasGenesis) {
96
+ return agent(
97
+ "Story bible ready",
98
+ "Write the Genesis (Episode 1) opening",
99
+ "Write the Genesis (Episode 1) opening for this cartoon, then plan its cuts in genesis.cuts.json. Don't generate images yet.",
100
+ "genesis.md",
101
+ );
102
+ }
103
+
104
+ // The episode the coach speaks about: the focused file when it's an unfinished
105
+ // episode, otherwise the story's active (first unpublished) episode.
106
+ const episodes = progress.episodes;
107
+ const focused = opts.focusFile ? episodes.find((e) => e.file === opts.focusFile) : undefined;
108
+ const active = episodes.find((e) => !e.published);
109
+ const ep = focused && !focused.published ? focused : active;
110
+
111
+ if (!ep) {
112
+ // Every episode is published — nudge toward the next one rather than a wall
113
+ // of "all done".
114
+ return agent(
115
+ "All episodes published",
116
+ "Start the next episode",
117
+ "Plan the cuts for the next episode in a new cuts.json. Don't generate images yet.",
118
+ null,
119
+ );
120
+ }
121
+
122
+ return coachForEpisode(ep, opts.undetectedCleanByFile?.[ep.file] ?? 0);
123
+ }
124
+
125
+ /**
126
+ * The per-episode production pipeline, in the order a writer performs it
127
+ * (#429): plan cuts → clean images → letter → export → upload → prepare →
128
+ * publish. Each stage emits one stage label + one primary action; the
129
+ * pre-image steps are agent prompts, the rest are in-app UI actions.
130
+ */
131
+ function coachForEpisode(ep: EpisodeProgress, undetectedClean: number): CartoonCoach {
132
+ const c = ep.cuts;
133
+ const label = ep.label;
134
+ const file = ep.file;
135
+ const isGenesis = ep.kind === "genesis";
136
+
137
+ // No cut plan yet — a not-started episode or a future-episode placeholder.
138
+ // Acceptance #3: this reads as "plan this first", never a publish warning.
139
+ if (!c || c.total === 0) {
140
+ return agent(
141
+ `${label} not started`,
142
+ isGenesis ? "Plan the Genesis cuts" : "Plan this episode first",
143
+ isGenesis
144
+ ? "Plan the cuts for the Genesis (Episode 1) in genesis.cuts.json. Don't generate images, letter, upload, or publish yet."
145
+ : `Plan the cuts for ${label} in ${cutsFileName(file)}. Don't generate images, letter, upload, or publish yet.`,
146
+ file,
147
+ );
148
+ }
149
+
150
+ // 1) Clean images — agent-generated. If images are already on disk but not yet
151
+ // recorded, the next action is a read-only re-scan instead (#427), not a
152
+ // redundant "generate again".
153
+ if (c.withClean < c.needClean) {
154
+ if (undetectedClean > 0) {
155
+ return ui(
156
+ "Clean images found on disk",
157
+ "Refresh assets to detect them",
158
+ "refresh-assets",
159
+ file,
160
+ );
161
+ }
162
+ return agent(
163
+ `${label} cuts planned`,
164
+ "Generate clean images",
165
+ `Generate clean images for every cut in ${cutsFileName(file)}. Don't letter, upload, or publish yet.`,
166
+ file,
167
+ );
168
+ }
169
+
170
+ // 2) Lettering — place speech bubbles & captions in the cut workspace.
171
+ if (c.withText < c.total) {
172
+ return ui("Clean images ready", "Review cuts and start lettering", "open-lettering", file);
173
+ }
174
+
175
+ // 3) Export the lettered final images.
176
+ if (c.exported < c.total) {
177
+ return ui("Lettering in progress", "Finish and export the final images", "open-lettering", file);
178
+ }
179
+
180
+ // 4) Upload the exported final images.
181
+ if (c.uploaded < c.total) {
182
+ return ui("Final images ready", "Upload the final images", "upload", file);
183
+ }
184
+
185
+ // 5) Every cut is uploaded — assemble the publish layout, then publish. Driven
186
+ // by the same readiness state the per-file publish UI uses, so the coach and
187
+ // the publish controls never disagree.
188
+ switch (ep.state) {
189
+ case "ready":
190
+ return ui("Ready to publish", `Publish ${label} to PlotLink`, "publish", file);
191
+ case "blocked":
192
+ return ui("Needs fixes before publishing", "Review and fix the publish issues", "open-cuts", file);
193
+ case "planning":
194
+ default:
195
+ // Images uploaded but the publish layout (cut blocks) isn't built yet.
196
+ return ui("Images uploaded", "Prepare the episode for publish", "generate-markdown", file);
197
+ }
198
+ }
@@ -0,0 +1,83 @@
1
+ import type { Cut } from "./cuts";
2
+
3
+ const MARKER_START = (id: string) => `<!-- ows:cartoon-cut ${id} start -->`;
4
+ const MARKER_END = (id: string) => `<!-- ows:cartoon-cut ${id} end -->`;
5
+ // Matches each existing cut-block START marker (capturing its id), used only to
6
+ // report stale blocks — blocks that existed but no longer map to a cut.
7
+ const START_MARKER_REGEX = /<!-- ows:cartoon-cut (cut-\d+) start -->/g;
8
+
9
+ function cutId(index: number): string {
10
+ return `cut-${String(index).padStart(3, "0")}`;
11
+ }
12
+
13
+ export function generateCutBlock(cut: Cut, index: number): string {
14
+ const id = cutId(index);
15
+ const desc = cut.description || `Cut ${index}`;
16
+
17
+ // Every cut is a planned image cut. The publish-facing markdown only carries
18
+ // the uploaded image once it exists; before that we emit a safe awaiting-upload
19
+ // marker comment. We never copy dialogue/narration prose from cuts.json into
20
+ // the skeleton — those texts are lettered onto the image, not published as text.
21
+ const content = cut.uploadedUrl
22
+ ? `![${desc}](${cut.uploadedUrl})`
23
+ : `<!-- Cut ${index}: awaiting upload -->`;
24
+
25
+ return `${MARKER_START(id)}\n${content}\n${MARKER_END(id)}`;
26
+ }
27
+
28
+ export function generateCartoonMarkdown(cuts: Cut[]): string {
29
+ return cuts.map((cut, i) => generateCutBlock(cut, i + 1)).join("\n\n");
30
+ }
31
+
32
+ /**
33
+ * Generate the publish-facing cartoon markdown for a plot from its cut plan.
34
+ *
35
+ * Publish-facing cartoon markdown is a PURE `ows:cartoon-cut` image sequence, so
36
+ * the output is rebuilt entirely from `cuts` rather than edited in place: no
37
+ * surrounding prose from `existingMd` — scaffold instructions, stale placeholders,
38
+ * headings, manual commentary — can survive into it (#319). Rebuilding (rather
39
+ * than the earlier strip-and-replace) also closes the case @re1 flagged where
40
+ * prose sat in the same blank-line paragraph as a marker block (e.g.
41
+ * `Intro\n<!-- ...start -->\n…\n<!-- ...end -->\nOutro`) and leaked through a
42
+ * block-only regex replace.
43
+ *
44
+ * `existingMd` is consulted only to (a) leave a non-cartoon document — markerless
45
+ * and with no cuts, i.e. fiction — untouched, and (b) report cut blocks that
46
+ * existed before but no longer map to a cut ("stale" blocks).
47
+ */
48
+ export function mergeCartoonMarkdown(
49
+ existingMd: string,
50
+ cuts: Cut[],
51
+ ): { markdown: string; warnings: string[] } {
52
+ const warnings: string[] = [];
53
+
54
+ // A markerless doc with no cuts is fiction — leave it untouched. (The route
55
+ // already guards on cuts.json, but keep the function safe in isolation.)
56
+ const isCartoonDoc = cuts.length > 0 || /ows:cartoon-cut/.test(existingMd);
57
+ if (!isCartoonDoc) return { markdown: existingMd, warnings };
58
+
59
+ for (let i = 0; i < cuts.length; i++) {
60
+ if (!cuts[i].uploadedUrl) {
61
+ warnings.push(`Cut ${i + 1}: missing upload URL`);
62
+ }
63
+ }
64
+
65
+ // Warn about cut marker blocks present in the old markdown that no longer map
66
+ // to a cut, so a removed/renumbered cut is not silently dropped.
67
+ const newIds = new Set<string>(cuts.map((_, i) => cutId(i + 1)));
68
+ for (const m of existingMd.matchAll(START_MARKER_REGEX)) {
69
+ if (!newIds.has(m[1])) warnings.push(`Removed stale block: ${m[1]}`);
70
+ }
71
+
72
+ return { markdown: generateCartoonMarkdown(cuts), warnings };
73
+ }
74
+
75
+ export function getReadinessWarnings(cuts: Cut[]): string[] {
76
+ const warnings: string[] = [];
77
+ for (let i = 0; i < cuts.length; i++) {
78
+ if (!cuts[i].uploadedUrl) {
79
+ warnings.push(`Cut ${i + 1}: not uploaded`);
80
+ }
81
+ }
82
+ return warnings;
83
+ }
@@ -0,0 +1,122 @@
1
+ import type { Cut } from "./cuts";
2
+ import { cutScriptLines } from "./lettering-status";
3
+
4
+ const SHOT_TYPE_LABELS: Record<string, string> = {
5
+ wide: "Wide",
6
+ medium: "Medium",
7
+ "close-up": "Close-up",
8
+ "extreme-close-up": "Extreme close-up",
9
+ };
10
+
11
+ const NO_TEXT_CONSTRAINT =
12
+ "No speech bubbles, captions, sound effects, narration, or any text or lettering in the image.";
13
+
14
+ /**
15
+ * Baseline visual style lock for every clean cut image (#404).
16
+ *
17
+ * Image generation drifts toward polished photoreal / painterly concept art unless
18
+ * the prompt fights it explicitly — "semi-realistic webtoon" alone is too weak (it
19
+ * was the #211 Sci-fi pilot's failure mode). This block pins the requested
20
+ * illustrated-panel look with strong positive descriptors AND hard negative
21
+ * constraints (no photorealism, no painterly concept art, no 3D render), so it is
22
+ * reusable across every cut and the agent-facing "Copy Codex task" prompt without
23
+ * each cut re-stating it. Story-specific style (palette, line weight, the exact
24
+ * webtoon reference) is layered on top via structure.md's Visual Style Guide.
25
+ */
26
+ export const CLEAN_IMAGE_STYLE_LOCK =
27
+ "Style lock — illustrated comic/webtoon panel art: clean black contour/ink lines, " +
28
+ "flat or cel shading, simplified but realistic (semi-realistic) anatomy and faces, " +
29
+ "backgrounds drawn as illustrated comic panels. Hold this same style on every cut for " +
30
+ "character and panel consistency. " +
31
+ "Hard negatives — NOT photorealistic, NOT a photograph, NOT a glossy or painterly digital " +
32
+ "painting, NOT concept art, NOT a 3D/CGI render, NOT airbrushed, no photoreal textures.";
33
+
34
+ /**
35
+ * Build a deterministic clean-image generation prompt from a cut's fields only.
36
+ *
37
+ * Pure function — no side effects. Dialogue, narration, and SFX text are
38
+ * intentionally excluded: those are lettered onto the image later, not drawn.
39
+ */
40
+ export function buildCleanImagePrompt(cut: Cut): string {
41
+ const shotLabel = SHOT_TYPE_LABELS[cut.shotType] ?? cut.shotType;
42
+ const description = cut.description?.trim() || `Cut ${cut.id}`;
43
+
44
+ const lines: string[] = [`${shotLabel} shot. ${description}`];
45
+
46
+ if (cut.characters.length > 0) {
47
+ lines.push(`Characters: ${cut.characters.join(", ")}.`);
48
+ }
49
+
50
+ lines.push(CLEAN_IMAGE_STYLE_LOCK);
51
+ lines.push(NO_TEXT_CONSTRAINT);
52
+
53
+ return lines.join("\n").trim();
54
+ }
55
+
56
+ /** Canonical clean-image output path for a cut (webp, matching the sync/import contract). */
57
+ export function cleanImageOutputPath(plotFile: string, cutId: number): string {
58
+ return `assets/${plotFile}/cut-${String(cutId).padStart(2, "0")}-clean.webp`;
59
+ }
60
+
61
+ /**
62
+ * Build an actionable Codex *task* prompt for generating a cut's clean image.
63
+ *
64
+ * Unlike `buildCleanImagePrompt` (a pure visual description), this instructs the
65
+ * agent to PRODUCE THE ACTUAL IMAGE and hand it off to the cut. A generated PNG in
66
+ * the image cache is an accepted outcome: the agent must NOT convert it (no
67
+ * agent-side image tools) — the writer imports it via the OWS "Import from Codex"
68
+ * button, which converts it. A tool that already emits WebP/JPEG <1MB can write the
69
+ * asset path directly. The visual prompt is embedded so no scene detail is lost.
70
+ * Pure function — no side effects.
71
+ */
72
+ export function buildCodexTaskPrompt(cut: Cut, plotFile: string): string {
73
+ const outputPath = cleanImageOutputPath(plotFile, cut.id);
74
+ return [
75
+ `Generate the clean image for cut ${cut.id}.`,
76
+ "",
77
+ "Image description:",
78
+ buildCleanImagePrompt(cut),
79
+ "",
80
+ "How to hand it off:",
81
+ "- Produce the actual image — do not just describe it or return a prompt.",
82
+ `- If your image tool can write a WebP or JPEG under 1MB, save it at ${outputPath} and run "Sync clean images".`,
83
+ "- If it only produces a PNG (e.g. built-in image generation saves to ~/.codex/generated_images), that is fine — do NOT convert or rename it yourself. Leave it there and import it into this cut with the OWS \"Import from Codex\" button, which converts the PNG automatically.",
84
+ "- Clean image only: no text, speech bubbles, captions, sound effects, signage, watermark, or signature.",
85
+ "- Hold the style lock above — an illustrated comic/webtoon panel, NOT a photoreal photo, painterly concept art, or 3D render. If a result reads photorealistic, regenerate it as illustrated panel art.",
86
+ "- Do not letter or upload anything — final lettering and upload happen later in OWS.",
87
+ ].join("\n");
88
+ }
89
+
90
+ /**
91
+ * Build the "Ask AI to draft lettering" prompt for a cut (#442). The agent writes
92
+ * DRAFT speech bubbles/captions into the cut's `overlays` array in cuts.json from
93
+ * the recorded script; the writer then reviews/adjusts them in the OWS lettering
94
+ * editor and exports there. Intentionally a copy-paste prompt — no auto-apply, no
95
+ * export/upload — so the human stays in control of the final lettering. Pure.
96
+ */
97
+ export function buildLetteringPrompt(cut: Cut, plotFile: string): string {
98
+ const cutsFile = `${plotFile}.cuts.json`;
99
+ const lines = cutScriptLines(cut);
100
+ const script = lines.length > 0
101
+ ? lines
102
+ .map((l) =>
103
+ l.type === "speech"
104
+ ? `- speech — ${l.speaker || "Speaker"}: "${l.text}"`
105
+ : l.type === "narration"
106
+ ? `- narration: ${l.text}`
107
+ : `- sfx: ${l.text}`,
108
+ )
109
+ .join("\n")
110
+ : "- (no dialogue/narration/SFX recorded for this cut — add a caption only if the scene needs one)";
111
+ return [
112
+ `Draft the speech bubbles and captions for cut ${cut.id} of ${plotFile}.`,
113
+ "",
114
+ "Script to letter:",
115
+ script,
116
+ "",
117
+ "How to draft it:",
118
+ `- Edit cut ${cut.id}'s "overlays" array in ${cutsFile}: add one overlay per line above — "type":"speech" for dialogue (also set "speaker"), "narration" for captions, "sfx" for sound effects, with the line's text.`,
119
+ "- Position each overlay with x, y, width, height as 0–1 fractions of the panel, roughly where it belongs over the art, and keep bubbles clear of faces.",
120
+ "- These are DRAFT positions only: do NOT export or upload. The writer reviews and adjusts them in the OWS lettering editor, then exports the final image there.",
121
+ ].join("\n");
122
+ }