mulmoclaude 1.2.0 → 1.3.0

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 (75) hide show
  1. package/client/assets/{PluginScopedRoot-CjiZ2FJI.js → PluginScopedRoot-B53YSjaC.js} +1 -1
  2. package/client/assets/index-D3czxq1I.css +2 -0
  3. package/client/assets/{index-yQkLdtMk.js → index-DUdqhWg6.js} +42 -43
  4. package/client/assets/{marp-Bs_qwoKZ.js → marp-CyWxL3bc.js} +12 -3
  5. package/client/index.html +3 -3
  6. package/package.json +6 -5
  7. package/server/agent/attachmentConverter.ts +0 -25
  8. package/server/api/auth/viewToken.ts +10 -8
  9. package/server/api/routes/agent.ts +35 -10
  10. package/server/api/routes/collections.ts +150 -96
  11. package/server/api/routes/files.ts +57 -54
  12. package/server/api/routes/mulmo-script.ts +257 -895
  13. package/server/api/routes/wiki/history.ts +17 -19
  14. package/server/events/collection-change.ts +0 -5
  15. package/server/events/file-change.ts +1 -6
  16. package/server/index.ts +25 -76
  17. package/server/plugins/diagnostics.ts +0 -6
  18. package/server/plugins/mulmoscript-server.ts +58 -0
  19. package/server/prompts/system/system.md +2 -2
  20. package/server/remoteHost/handlers/getCollection.ts +5 -4
  21. package/server/remoteHost/handlers/googleCalendar.ts +50 -3
  22. package/server/remoteHost/handlers/index.ts +3 -1
  23. package/server/utils/files/html-store.ts +2 -16
  24. package/server/utils/files/journal-io.ts +0 -12
  25. package/server/utils/files/plugins-io.ts +2 -11
  26. package/server/utils/files/safe.ts +39 -0
  27. package/server/utils/httpError.ts +6 -0
  28. package/server/utils/time.ts +0 -3
  29. package/server/workspace/collections/index.ts +1 -0
  30. package/server/workspace/collections/remoteView.ts +13 -4
  31. package/server/workspace/journal/state.ts +1 -9
  32. package/server/workspace/memory/topic-index-hook.ts +0 -9
  33. package/server/workspace/memory/topic-io.ts +0 -4
  34. package/server/workspace/skills/external/catalog.ts +24 -13
  35. package/server/workspace/skills/writer.ts +20 -16
  36. package/server/workspace/wiki-pages/snapshot.ts +13 -12
  37. package/src/composables/useFileDropZone.ts +3 -15
  38. package/src/config/apiRoutes.ts +6 -0
  39. package/src/config/roles.ts +0 -4
  40. package/src/lang/de.ts +0 -30
  41. package/src/lang/en.ts +0 -30
  42. package/src/lang/es.ts +0 -30
  43. package/src/lang/fr.ts +0 -30
  44. package/src/lang/ja.ts +0 -30
  45. package/src/lang/ko.ts +0 -30
  46. package/src/lang/pt-BR.ts +0 -30
  47. package/src/lang/zh.ts +0 -30
  48. package/src/plugins/accounting/index.ts +0 -2
  49. package/src/plugins/api.ts +0 -6
  50. package/src/plugins/canvas/index.ts +0 -2
  51. package/src/plugins/chart/index.ts +0 -2
  52. package/src/plugins/editImages/index.ts +0 -2
  53. package/src/plugins/generateImage/index.ts +0 -2
  54. package/src/plugins/manageSkills/index.ts +0 -2
  55. package/src/plugins/markdown/index.ts +0 -2
  56. package/src/plugins/photoLocations/index.ts +0 -2
  57. package/src/plugins/presentCollection/index.ts +0 -2
  58. package/src/plugins/presentForm/index.ts +0 -2
  59. package/src/plugins/presentHtml/index.ts +0 -2
  60. package/src/plugins/presentMulmoScript/definition.ts +11 -116
  61. package/src/plugins/presentMulmoScript/index.ts +62 -12
  62. package/src/plugins/presentSVG/index.ts +0 -2
  63. package/src/plugins/skill/index.ts +0 -3
  64. package/src/plugins/spreadsheet/engine/parser.ts +0 -103
  65. package/src/plugins/spreadsheet/index.ts +0 -2
  66. package/src/plugins/textResponse/index.ts +0 -3
  67. package/src/plugins/wiki/index.ts +0 -2
  68. package/src/types/notification.ts +0 -10
  69. package/src/utils/api.ts +0 -4
  70. package/client/assets/index-D5nJbMO1.css +0 -2
  71. package/server/api/routes/mulmoScriptValidate.ts +0 -101
  72. package/server/utils/mulmoErrorCapture.ts +0 -93
  73. package/src/plugins/presentMulmoScript/Preview.vue +0 -23
  74. package/src/plugins/presentMulmoScript/View.vue +0 -1949
  75. package/src/plugins/presentMulmoScript/helpers.ts +0 -206
@@ -1,96 +1,50 @@
1
1
  import { Router, Request, Response } from "express";
2
- import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, unlinkSync, writeFileSync } from "fs";
3
- import path from "path";
4
- import { WORKSPACE_PATHS } from "../../workspace/paths.js";
5
- import { writeFileAtomic } from "../../utils/files/atomic.js";
6
- import { stripDataUri } from "../../utils/files/image-store.js";
7
- import { writeJsonAtomic } from "../../utils/files/json.js";
8
2
  import {
9
- getFileObject,
10
- initializeContextFromFiles,
11
- generateBeatImage,
12
- getBeatPngImagePath,
13
- generateBeatAudio,
14
- getBeatAudioPathOrUrl,
15
- getBeatAnimatedVideoPath,
16
- getBeatMoviePaths,
17
- generateReferenceImage,
18
- getReferenceImagePath,
19
- images,
20
- audio,
21
- movie,
22
- movieFilePath,
23
- pdf,
24
- pdfFilePath,
25
- setGraphAILogger,
26
- addSessionProgressCallback,
27
- removeSessionProgressCallback,
28
- type MulmoScript,
29
- } from "mulmocast";
30
- import { mulmoScriptSchema, type MulmoBeat, type MulmoImagePromptMedia } from "@mulmocast/types";
31
- import { slugify } from "../../utils/slug.js";
32
- import { resolveWithinRoot } from "../../utils/files/safe.js";
3
+ executeMulmoScriptSave,
4
+ executeUpdateBeat,
5
+ executeUpdateScript,
6
+ type MulmoScriptFailure,
7
+ type SaveMulmoScriptArgs,
8
+ } from "@mulmoclaude/mulmoscript-plugin";
9
+ import { makeArtifactsFileOps } from "../../plugins/runtime.js";
10
+ import { buildContext, type OpFailure } from "@mulmoclaude/mulmoscript-plugin/server";
11
+ import { mulmoScriptOps } from "../../plugins/mulmoscript-server.js";
33
12
  import { errorMessage } from "../../utils/errors.js";
34
- import { badRequest, notFound, sendError, serverError } from "../../utils/httpError.js";
35
- import { depStatus } from "../../system/optionalDeps.js";
13
+ import { badRequest, notFound, sendError } from "../../utils/httpError.js";
36
14
  import { getOptionalStringQuery, getSessionQuery } from "../../utils/request.js";
37
- import { log } from "../../system/logger/index.js";
38
- import { enableGraphAIErrorCapture, withMulmoErrorCapture } from "../../utils/mulmoErrorCapture.js";
39
- import { validateUpdateBeatBody, validateUpdateScriptBody } from "./mulmoScriptValidate.js";
40
15
  import { API_ROUTES } from "../../../src/config/apiRoutes.js";
41
16
  import { bindRoute } from "../../utils/router.js";
42
- import { publishGeneration } from "../../events/session-store/index.js";
43
17
  import { GENERATION_KINDS } from "../../../src/types/events.js";
44
18
 
19
+ // Express adapters over the shared ops instance from
20
+ // `server/plugins/mulmoscript-server.ts`. Every op body lives in
21
+ // @mulmoclaude/mulmoscript-plugin/server (phase 3 — single source of
22
+ // truth, shared with the plugin dispatch handler and, in phase 3b, with
23
+ // MulmoTerminal); these routes only validate request shapes and map
24
+ // `OpFailure.code` back onto the pre-extraction HTTP statuses. The
25
+ // save / reopen / update slice additionally delegates to the package's
26
+ // phase-1 core executes.
27
+
45
28
  const router = Router();
46
29
 
47
- // mulmocast shells out to ffmpeg for movie / beat rendering. When
48
- // ffmpeg is absent the optional-deps probe (#1385) marks it
49
- // unavailable; intercept here with a clear 503 instead of letting
50
- // the library throw an opaque spawn ENOENT mid-stream. `undefined`
51
- // means the boot probe hasn't completed — assume available so a
52
- // brief startup window never blocks a render.
53
- function ffmpegUnavailable(res: Response): boolean {
54
- if (depStatus("ffmpeg")?.available === false) {
55
- sendError(res, 503, "ffmpeg is not installed — movie and beat rendering are unavailable. Install ffmpeg and restart the server.");
56
- return true;
57
- }
58
- return false;
59
- }
60
- const storiesDir = path.resolve(WORKSPACE_PATHS.stories);
30
+ const OP_FAILURE_STATUS: Record<OpFailure["code"], number> = {
31
+ bad_request: 400,
32
+ not_found: 404,
33
+ unavailable: 503,
34
+ server_error: 500,
35
+ };
61
36
 
62
- // The downloadMovie handler expects "stories/<rel>" (historical
63
- // convention, independent of the on-disk location). After #284 the
64
- // physical directory moved under artifacts/, so we can't return
65
- // path.relative(workspacePath, ...) any more — that now begins with
66
- // "artifacts/stories/". Re-rooting the path at storiesDir keeps the
67
- // wire format stable.
68
- function toStoryRef(absolutePath: string): string {
69
- const rel = path.relative(storiesDir, absolutePath).split(path.sep).join("/");
70
- return rel ? `stories/${rel}` : "stories";
37
+ function sendOpFailure(res: Response, failure: OpFailure): void {
38
+ sendError(res, OP_FAILURE_STATUS[failure.code], failure.error);
71
39
  }
72
40
 
73
- // Lazily realpath the stories dir on first use. We can't realpath at
74
- // module load because the directory may not exist yet (it's created
75
- // on demand by /mulmo-script POST). The cache is invalidated never —
76
- // once the dir exists, its realpath is stable.
77
- let storiesRealCache: string | null = null;
78
- function ensureStoriesReal(): string | null {
79
- if (storiesRealCache) return storiesRealCache;
80
- try {
81
- mkdirSync(storiesDir, { recursive: true });
82
- storiesRealCache = realpathSync(storiesDir);
83
- return storiesRealCache;
84
- } catch {
85
- return null;
86
- }
87
- }
88
-
89
- interface SaveMulmoScriptBody {
90
- script?: MulmoScript;
91
- filename?: string;
92
- filePath?: string;
93
- autoGenerateMovie?: boolean;
41
+ // Shared SSE preamble for the two streaming routes; returns the
42
+ // line-writer bound to this response.
43
+ function beginSse(res: Response): (data: unknown) => void {
44
+ res.setHeader("Content-Type", "text/event-stream");
45
+ res.setHeader("Cache-Control", "no-cache");
46
+ res.setHeader("Connection", "keep-alive");
47
+ return (data: unknown) => res.write(`data: ${JSON.stringify(data)}\n\n`);
94
48
  }
95
49
 
96
50
  interface RenderBeatBody {
@@ -112,6 +66,7 @@ interface ErrorResponse {
112
66
 
113
67
  type BeatImageResponse = { image: string | null } | ErrorResponse;
114
68
  type BeatAudioResponse = { audio: string | null } | ErrorResponse;
69
+ type BeatMovieResponse = { moviePath: string | null } | ErrorResponse;
115
70
  type MovieStatusResponse = { moviePath: string | null } | ErrorResponse;
116
71
  type PdfStatusResponse = { pdfPath: string | null } | ErrorResponse;
117
72
  type GenerateBeatAudioResponse = { audio: string } | ErrorResponse;
@@ -125,13 +80,52 @@ interface FilePathQuery {
125
80
  filePath?: string;
126
81
  }
127
82
 
128
- interface ScriptOutcome {
129
- script: MulmoScript;
130
- /** Workspace-relative wire form, e.g. "stories/foo.json". */
131
- wireFilePath: string;
132
- /** Realpath, used by movie generation and the dedup set. */
133
- absoluteFilePath: string;
134
- message: string;
83
+ // Request values arrive untyped at runtime — query params can be arrays
84
+ // (repeated `?filePath=` keys) and JSON bodies can carry any shape. The
85
+ // guards below reject non-string / non-index values before they can reach
86
+ // any path or beat-indexed logic (CodeQL
87
+ // js/type-confusion-through-parameter-tampering + Codex review on #2133).
88
+ function stringQuery(value: unknown): string | null {
89
+ return typeof value === "string" && value !== "" ? value : null;
90
+ }
91
+
92
+ // Beat indexes must be non-negative integers — `-1` / `1.5` must fail as a
93
+ // deterministic 400 instead of indexing undefined beats downstream.
94
+ function validBeatIndex(value: unknown): value is number {
95
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
96
+ }
97
+
98
+ function parseBeatQuery<TRes>(req: Request<object, TRes, object, BeatQuery>, res: Response): { filePath: string; beatIndex: number } | null {
99
+ const filePath = stringQuery(req.query.filePath);
100
+ const beatIndexStr = stringQuery(req.query.beatIndex);
101
+ // Number() (not parseInt) so "1.5" stays fractional and fails the
102
+ // integer check instead of silently truncating to 1.
103
+ const beatIndex = beatIndexStr !== null ? Number(beatIndexStr) : undefined;
104
+ if (!filePath || !validBeatIndex(beatIndex)) {
105
+ badRequest(res, "filePath and beatIndex are required");
106
+ return null;
107
+ }
108
+ return { filePath, beatIndex };
109
+ }
110
+
111
+ // The save / reopen / update slice lives in the shared
112
+ // @mulmoclaude/mulmoscript-plugin package (single source of truth, also
113
+ // consumable by MulmoTerminal — plans/feat-mulmoscript-plugin.md). These
114
+ // routes are THIN host adapters: they inject the GENERIC `files.artifacts`
115
+ // runtime capability, map the package's discriminated failures back onto
116
+ // the pre-extraction 400/404 wire contract, and keep the host-only
117
+ // `autoGenerateMovie` trigger (movie generation needs mulmocast/ffmpeg,
118
+ // which stay host-side until phase 3).
119
+ function makeExecuteContext() {
120
+ return { files: { artifacts: makeArtifactsFileOps() } };
121
+ }
122
+
123
+ function sendPackageFailure(res: Response, failure: MulmoScriptFailure): void {
124
+ if (failure.code === "not_found") {
125
+ notFound(res, failure.error);
126
+ } else {
127
+ badRequest(res, failure.error);
128
+ }
135
129
  }
136
130
 
137
131
  // Unified entry point — save a fresh `script` OR re-display an existing
@@ -140,441 +134,108 @@ interface ScriptOutcome {
140
134
  // either side needing to know which mode the user picked. The MCP layer
141
135
  // in `server/agent/plugin-names.ts` routes the tool name straight here,
142
136
  // so any per-mode logic on the client would be invisible to it.
143
- bindRoute(router, API_ROUTES.mulmoScript.save, async (req: Request<object, object, SaveMulmoScriptBody>, res: Response) => {
144
- const { script, filename, filePath, autoGenerateMovie } = req.body ?? {};
145
-
146
- const hasScript = script !== undefined && script !== null;
147
- const hasFilePath = typeof filePath === "string" && filePath !== "";
148
- if (hasScript === hasFilePath) {
149
- badRequest(
150
- res,
151
- hasScript ? "Provide either `script` or `filePath`, not both." : "Provide either `script` (new presentation) or `filePath` (existing presentation).",
152
- );
137
+ bindRoute(router, API_ROUTES.mulmoScript.save, async (req: Request<object, object, SaveMulmoScriptArgs>, res: Response) => {
138
+ // Realpath symlink containment before the package's lexical guard
139
+ // see mulmoScriptOps.guardStoryWirePath.
140
+ const guard = mulmoScriptOps.guardStoryWirePath(req.body?.filePath);
141
+ if (guard) {
142
+ sendOpFailure(res, guard);
143
+ return;
144
+ }
145
+ const outcome = await executeMulmoScriptSave(makeExecuteContext(), req.body ?? {});
146
+ if (!outcome.ok) {
147
+ sendPackageFailure(res, outcome);
153
148
  return;
154
149
  }
155
150
 
156
- const outcome = hasFilePath ? await loadScriptFromDisk(filePath, res) : await saveScriptToDisk(script as MulmoScript, filename, res);
157
- if (!outcome) return; // helper already wrote a 4xx/5xx response
158
-
159
- if (autoGenerateMovie === true) {
160
- triggerAutoBackgroundMovie(outcome.absoluteFilePath, outcome.wireFilePath, getSessionQuery(req) || undefined);
151
+ if (req.body?.autoGenerateMovie === true) {
152
+ // The in-flight dedup + background pipeline key on the realpath, so
153
+ // re-resolve the package's wire path host-side.
154
+ const resolved = mulmoScriptOps.resolveStory(outcome.filePath);
155
+ if (resolved.ok) {
156
+ mulmoScriptOps.triggerAutoBackgroundMovie(resolved.absolutePath, outcome.filePath, getSessionQuery(req) || undefined);
157
+ }
161
158
  }
162
159
 
163
160
  res.json({
164
- data: { script: outcome.script, filePath: outcome.wireFilePath },
161
+ data: { script: outcome.script, filePath: outcome.filePath },
165
162
  message: outcome.message,
166
163
  instructions: "Display the storyboard to the user.",
167
164
  });
168
165
  });
169
166
 
170
- async function saveScriptToDisk(script: MulmoScript, filename: string | undefined, res: Response): Promise<ScriptOutcome | null> {
171
- // Validate against the same schema the reopen path uses so /save and
172
- // /load agree on what counts as a valid MulmoScript — otherwise
173
- // /save could persist a script that /load would later refuse, and
174
- // autoGenerateMovie could kick off against malformed input.
175
- const validation = mulmoScriptSchema.safeParse(script);
176
- if (!validation.success) {
177
- badRequest(res, "script is not a valid MulmoScript");
178
- return null;
179
- }
180
- const validatedScript = validation.data;
181
-
182
- mkdirSync(storiesDir, { recursive: true });
183
-
184
- const title = validatedScript.title || "untitled";
185
- // slugify drops `/`, `\`, and `..`, so a hostile `filename` like
186
- // "../../etc/passwd" can never escape storiesDir via the path.join
187
- // below — defense in depth on top of the wire-side validation.
188
- const slug = filename ? slugify(filename.replace(/\.json$/i, "")) : slugify(title);
189
- const fname = `${slug}-${Date.now()}.json`;
190
- const writePath = path.join(storiesDir, fname);
191
-
192
- await writeJsonAtomic(writePath, validatedScript);
193
-
194
- // Realpath-resolve the freshly written file so `inFlightMovies`
195
- // entries created from this code path collide with ones produced by
196
- // `resolveStoryPath` (used by reopen / SSE generateMovie / movie-
197
- // status). Without this, a symlinked storiesDir would let two movie
198
- // generations for the same physical file run concurrently because
199
- // their dedup keys differ (path.join vs realpath).
200
- let absoluteFilePath: string;
201
- try {
202
- absoluteFilePath = realpathSync(writePath);
203
- } catch {
204
- absoluteFilePath = writePath;
205
- }
206
-
207
- return {
208
- script: validatedScript,
209
- wireFilePath: `stories/${fname}`,
210
- absoluteFilePath,
211
- message: `Saved MulmoScript to stories/${fname}`,
212
- };
213
- }
214
-
215
- async function loadScriptFromDisk(filePath: string, res: Response): Promise<ScriptOutcome | null> {
216
- if (!filePath.toLowerCase().endsWith(".json")) {
217
- badRequest(res, "filePath must point to a .json file");
218
- return null;
219
- }
220
- const absoluteFilePath = resolveStoryPath(filePath, res);
221
- if (!absoluteFilePath) return null; // resolveStoryPath already responded
222
-
223
- let raw: string;
224
- try {
225
- raw = readFileSync(absoluteFilePath, "utf-8");
226
- } catch (err) {
227
- serverError(res, errorMessage(err));
228
- return null;
229
- }
230
-
231
- let parsed: unknown;
232
- try {
233
- parsed = JSON.parse(raw);
234
- } catch (err) {
235
- badRequest(res, `Invalid JSON: ${errorMessage(err)}`);
236
- return null;
237
- }
238
-
239
- const validation = mulmoScriptSchema.safeParse(parsed);
240
- if (!validation.success) {
241
- badRequest(res, "File is not a valid MulmoScript");
242
- return null;
243
- }
244
-
245
- // Canonicalize via the realpath-resolved absoluteFilePath so wire
246
- // forms like "bar.json" or "stories/foo/../bar.json" all collapse
247
- // to the same "stories/<rel>" key — pendingGenerations and movie-
248
- // status lookups depend on that stability.
249
- const wireFilePath = toStoryRef(absoluteFilePath);
250
-
251
- return {
252
- script: validation.data,
253
- wireFilePath,
254
- absoluteFilePath,
255
- message: `Loaded MulmoScript from ${wireFilePath}`,
256
- };
257
- }
258
-
259
- // Module-level dedup so a foreground SSE call and a fire-and-forget
260
- // background call can't race on the same script. Keyed by the realpath
261
- // (absoluteFilePath) so two different wire spellings of the same file
262
- // still collide. The set is intentionally process-local — a multi-
263
- // process deployment would need an external lock; that's out of scope.
264
- const inFlightMovies = new Set<string>();
265
-
266
- // Same dedup model as inFlightMovies, scoped to PDF generation
267
- // (#1614). PDFs and movies don't share the lock — they write to
268
- // different output files and can safely run in parallel.
269
- const inFlightPdfs = new Set<string>();
270
-
271
- function triggerAutoBackgroundMovie(absoluteFilePath: string, wireFilePath: string, chatSessionId: string | undefined): void {
272
- if (inFlightMovies.has(absoluteFilePath)) return;
273
- inFlightMovies.add(absoluteFilePath);
274
- void runBackgroundMovieGeneration(absoluteFilePath, wireFilePath, chatSessionId);
275
- }
276
-
277
- bindRoute(router, API_ROUTES.mulmoScript.updateBeat, async (req: Request<object, object, unknown>, res: Response) => {
278
- const validation = validateUpdateBeatBody(req.body);
279
- if (!validation.ok) {
280
- badRequest(res, validation.error);
167
+ // The updateBeat / updateScript routes are identical except for which
168
+ // package execute they call: guard the wire path, run the execute, map a
169
+ // failure onto the pre-extraction status, else 200. Share the shell.
170
+ async function runGuardedUpdate(req: Request<object, object, unknown>, res: Response, execute: typeof executeUpdateBeat): Promise<void> {
171
+ const guard = mulmoScriptOps.guardStoryWirePath((req.body as { filePath?: unknown } | undefined)?.filePath);
172
+ if (guard) {
173
+ sendOpFailure(res, guard);
281
174
  return;
282
175
  }
283
- const { filePath, beatIndex, beat } = validation.value;
284
-
285
- const absoluteFilePath = resolveStoryPath(filePath, res);
286
- if (!absoluteFilePath) return;
287
-
288
- const script: MulmoScript = JSON.parse(readFileSync(absoluteFilePath, "utf-8"));
289
-
290
- if (!Array.isArray(script.beats) || beatIndex >= script.beats.length) {
291
- badRequest(res, "Invalid beatIndex");
176
+ const outcome = await execute(makeExecuteContext(), req.body);
177
+ if (!outcome.ok) {
178
+ sendPackageFailure(res, outcome);
292
179
  return;
293
180
  }
294
-
295
- script.beats[beatIndex] = beat as MulmoBeat;
296
- await writeJsonAtomic(absoluteFilePath, script);
297
-
298
181
  res.json({ ok: true });
299
- });
300
-
301
- bindRoute(router, API_ROUTES.mulmoScript.updateScript, async (req: Request<object, object, unknown>, res: Response) => {
302
- const validation = validateUpdateScriptBody(req.body);
303
- if (!validation.ok) {
304
- badRequest(res, validation.error);
305
- return;
306
- }
307
- const { filePath, script: updatedScript } = validation.value;
182
+ }
308
183
 
309
- const absoluteFilePath = resolveStoryPath(filePath, res);
310
- if (!absoluteFilePath) return;
184
+ bindRoute(router, API_ROUTES.mulmoScript.updateBeat, (req: Request<object, object, unknown>, res: Response) => runGuardedUpdate(req, res, executeUpdateBeat));
311
185
 
312
- await writeJsonAtomic(absoluteFilePath, updatedScript);
313
- res.json({ ok: true });
314
- });
186
+ bindRoute(router, API_ROUTES.mulmoScript.updateScript, (req: Request<object, object, unknown>, res: Response) =>
187
+ runGuardedUpdate(req, res, executeUpdateScript),
188
+ );
315
189
 
316
190
  bindRoute(router, API_ROUTES.mulmoScript.beatImage, async (req: Request<object, BeatImageResponse, object, BeatQuery>, res: Response<BeatImageResponse>) => {
317
- const { filePath, beatIndex: beatIndexStr } = req.query;
318
- const beatIndex = beatIndexStr !== undefined ? parseInt(beatIndexStr, 10) : undefined;
319
-
320
- if (!filePath || beatIndex === undefined || isNaN(beatIndex)) {
321
- badRequest(res, "filePath and beatIndex are required");
191
+ const query = parseBeatQuery(req, res);
192
+ if (!query) return;
193
+ const result = await mulmoScriptOps.beatImageOp(query.filePath, query.beatIndex);
194
+ if (!result.ok) {
195
+ sendOpFailure(res, result);
322
196
  return;
323
197
  }
324
-
325
- await withStoryContext(res, filePath, {}, async ({ context }) => {
326
- const { imagePath } = getBeatPngImagePath(context, beatIndex);
327
- if (!existsSync(imagePath)) {
328
- res.json({ image: null });
329
- return;
330
- }
331
- res.json({ image: fileToDataUri(imagePath, "image/png") });
332
- });
198
+ res.json({ image: result.image });
333
199
  });
334
200
 
335
201
  bindRoute(
336
202
  router,
337
203
  API_ROUTES.mulmoScript.movieStatus,
338
204
  async (req: Request<object, MovieStatusResponse, object, FilePathQuery>, res: Response<MovieStatusResponse>) => {
339
- const { filePath } = req.query;
340
-
205
+ const filePath = stringQuery(req.query.filePath);
341
206
  if (!filePath) {
342
207
  badRequest(res, "filePath is required");
343
208
  return;
344
209
  }
345
-
346
- const absoluteFilePath = resolveStoryPath(filePath, res);
347
- if (!absoluteFilePath) return;
348
-
349
- try {
350
- const context = await buildContext(absoluteFilePath);
351
- if (!context) {
352
- res.json({ moviePath: null });
353
- return;
354
- }
355
-
356
- const outputPath = movieFilePath(context);
357
- if (!existsSync(outputPath)) {
358
- res.json({ moviePath: null });
359
- return;
360
- }
361
-
362
- const movieMtime = statSync(outputPath).mtimeMs;
363
- const sourceMtime = statSync(absoluteFilePath).mtimeMs;
364
- if (movieMtime < sourceMtime) {
365
- res.json({ moviePath: null });
366
- return;
367
- }
368
-
369
- res.json({ moviePath: toStoryRef(outputPath) });
370
- } catch (err) {
371
- serverError(res, errorMessage(err));
210
+ const result = await mulmoScriptOps.movieStatusOp(filePath);
211
+ if (!result.ok) {
212
+ sendOpFailure(res, result);
213
+ return;
372
214
  }
215
+ res.json({ moviePath: result.moviePath });
373
216
  },
374
217
  );
375
218
 
376
- function fileToDataUri(filePath: string, mimeType: string): string {
377
- const data = readFileSync(filePath);
378
- return `data:${mimeType};base64,${data.toString("base64")}`;
379
- }
380
-
381
- // Helper: resolve and validate a stories filePath, returns absoluteFilePath or null
382
- //
383
- // Uses the realpath-based resolveWithinRoot helper to defeat
384
- // symlink-based escapes. The previous implementation used a plain
385
- // `path.resolve` + `startsWith` check, which a malicious symlink
386
- // under stories/ could bypass.
387
- //
388
- // Callers pass workspace-relative paths like "stories/foo.json" or
389
- // "stories/__movies__/bar.mp4". We strip the leading "stories/"
390
- // segment and resolve the remainder against the realpath of the
391
- // stories directory itself — this works whether stories/ is a
392
- // regular directory or a legitimate symlink to another location
393
- // (e.g. workspace/stories → /ext/stories on a different disk).
394
- function resolveStoryPath(filePath: string, res: Response): string | null {
395
- const storiesReal = ensureStoriesReal();
396
- if (!storiesReal) {
397
- serverError(res, "stories directory not available");
398
- return null;
399
- }
400
- // Reject absolute paths and parent traversal at the syntactic
401
- // level — defense in depth on top of the realpath check below.
402
- if (path.isAbsolute(filePath)) {
403
- badRequest(res, "Invalid filePath");
404
- return null;
405
- }
406
- // Strip the optional "stories/" prefix so the remainder is a path
407
- // relative to storiesReal. Accepts both "stories/foo.json" (the
408
- // canonical caller convention) and bare "foo.json".
409
- const STORIES_PREFIX = `stories${path.sep}`;
410
- const relFromStories =
411
- filePath === "stories" ? "" : filePath.startsWith(STORIES_PREFIX) || filePath.startsWith("stories/") ? filePath.slice("stories/".length) : filePath;
412
- // resolveWithinRoot enforces both the realpath boundary AND
413
- // existence; ENOENT and traversal both produce null. Distinguish
414
- // them via a follow-up existsSync so 404 vs 400 stays accurate.
415
- const resolved = resolveWithinRoot(storiesReal, relFromStories);
416
- if (!resolved) {
417
- const candidate = path.resolve(storiesReal, relFromStories);
418
- if (!existsSync(candidate)) {
419
- notFound(res, `File not found: ${filePath}`);
420
- } else {
421
- badRequest(res, "Invalid filePath");
422
- }
423
- return null;
424
- }
425
- return resolved;
426
- }
427
-
428
- // Helper: build mulmo context for a story file
429
- async function buildContext(absoluteFilePath: string, force = false) {
430
- // setGraphAILogger(false) silences GraphAI's chatty info/debug output
431
- // but also its error level — re-enable error capture so a failed
432
- // generation surfaces the real provider error, not just mulmocast's
433
- // generic "generate error" wrapper.
434
- setGraphAILogger(false);
435
- enableGraphAIErrorCapture();
436
- const files = getFileObject({
437
- file: absoluteFilePath,
438
- basedir: path.dirname(absoluteFilePath),
439
- grouped: true,
440
- });
441
- return initializeContextFromFiles(files, true, force);
442
- }
443
-
444
- // Awaited context type used by every helper that calls buildContext.
445
- type StoryContext = NonNullable<Awaited<ReturnType<typeof buildContext>>>;
446
-
447
- interface WithStoryContextDeps {
448
- resolveStoryPath?: (filePath: string, res: Response) => string | null;
449
- buildContext?: (absoluteFilePath: string, force?: boolean) => Promise<StoryContext | undefined>;
450
- }
451
-
452
- // Shared scaffolding for mulmo-script handlers. Each handler resolves
453
- // the workspace-relative filePath, builds the mulmo context, and
454
- // catches unexpected errors with a 500 + errorMessage. Extracted so
455
- // every handler can focus on its own business logic.
456
- //
457
- // Accepts a `deps` param so unit tests can inject fakes without the
458
- // full mulmocast stack.
459
- export interface WithStoryContextOptions {
460
- force?: boolean;
461
- /**
462
- * Handler-specific tag included in the helper's failure log so
463
- * dashboards can distinguish which route is failing (e.g.
464
- * `"generate-beat-audio"`). Falls back to a generic
465
- * `"handler failed"` entry when omitted.
466
- */
467
- operation?: string;
468
- /**
469
- * Soft-fail override for `buildContext` returning undefined. Some
470
- * endpoints (e.g. `GET /beat-audio`) historically returned a
471
- * 200 `{ audio: null }` in that case so the frontend can silently
472
- * retry. If provided, this callback writes the fallback response
473
- * instead of the default 500 `{ error: "Failed to initialize
474
- * mulmo context" }`.
475
- */
476
- onContextMissing?: (res: Response) => void;
477
- }
478
-
479
- export async function withStoryContext(
480
- res: Response,
481
- filePath: string,
482
- options: WithStoryContextOptions,
483
- handler: (ctx: { absoluteFilePath: string; context: StoryContext }) => Promise<void>,
484
- deps: WithStoryContextDeps = {},
485
- ): Promise<void> {
486
- const resolver = deps.resolveStoryPath ?? resolveStoryPath;
487
- const build = deps.buildContext ?? buildContext;
488
- const absoluteFilePath = resolver(filePath, res);
489
- if (!absoluteFilePath) return;
490
- try {
491
- const context = await build(absoluteFilePath, options.force ?? false);
492
- if (!context) {
493
- if (options.onContextMissing) {
494
- options.onContextMissing(res);
495
- } else {
496
- serverError(res, "Failed to initialize mulmo context");
497
- }
498
- return;
499
- }
500
- // withMulmoErrorCapture appends the underlying provider error
501
- // (missing API key, quota, …) to any mulmocast failure, which
502
- // otherwise reaches the client as a generic "generate error".
503
- await withMulmoErrorCapture(() => handler({ absoluteFilePath, context }));
504
- } catch (err) {
505
- // Log every handler failure at warn so operators get a breadcrumb
506
- // even when the migrated handler doesn't wrap its own try/catch.
507
- // Consistent with the chat-index / wiki-backlinks / journal
508
- // fire-and-forget error pattern.
509
- log.warn("mulmo-script", "handler failed", {
510
- ...(options.operation ? { operation: options.operation } : {}),
511
- filePath,
512
- error: errorMessage(err),
513
- });
514
- // Double-write guard: if the handler has already started streaming
515
- // or sent a partial response, appending a 500 body here would
516
- // trigger Express's "Cannot set headers after they are sent"
517
- // warning and corrupt the on-wire response.
518
- if (!res.headersSent) {
519
- serverError(res, errorMessage(err));
520
- }
521
- }
522
- }
523
-
524
219
  bindRoute(router, API_ROUTES.mulmoScript.beatAudio, async (req: Request<object, BeatAudioResponse, object, BeatQuery>, res: Response<BeatAudioResponse>) => {
525
- const { filePath, beatIndex: beatIndexStr } = req.query;
526
- const beatIndex = beatIndexStr !== undefined ? parseInt(beatIndexStr, 10) : undefined;
527
-
528
- if (!filePath || beatIndex === undefined || isNaN(beatIndex)) {
529
- badRequest(res, "filePath and beatIndex are required");
220
+ const query = parseBeatQuery(req, res);
221
+ if (!query) return;
222
+ const result = await mulmoScriptOps.beatAudioOp(query.filePath, query.beatIndex);
223
+ if (!result.ok) {
224
+ sendOpFailure(res, result);
530
225
  return;
531
226
  }
532
-
533
- // GET /beat-audio is a probe — the frontend polls it expecting a
534
- // 200 with `{ audio: null }` when nothing has been generated yet.
535
- // Override the helper's default 500-on-context-missing so the
536
- // soft-fail contract is preserved.
537
- await withStoryContext(
538
- res,
539
- filePath,
540
- {
541
- operation: "beat-audio",
542
- onContextMissing: (response) => response.json({ audio: null }),
543
- },
544
- async ({ context }) => {
545
- const beat = context.studio.script.beats[beatIndex];
546
- const audioPath = getBeatAudioPathOrUrl(beat.text ?? "", context, beat, context.lang);
547
- if (!audioPath || !existsSync(audioPath)) {
548
- res.json({ audio: null });
549
- return;
550
- }
551
- res.json({ audio: fileToDataUri(audioPath, "audio/mpeg") });
552
- },
553
- );
227
+ res.json({ audio: result.audio });
554
228
  });
555
229
 
556
- type BeatMovieResponse = { moviePath: string | null } | ErrorResponse;
557
-
558
- // Probe for a beat's generated video clip. Preference order mirrors the
559
- // movie-assembly pipeline's "most processed wins": lip-synced > with
560
- // sound effect > raw movie clip > animated html_tailwind render. The
561
- // response is the "stories/…" wire path so the client can stream it
562
- // through the existing bearer-authenticated downloadMovie route.
563
230
  bindRoute(router, API_ROUTES.mulmoScript.beatMovie, async (req: Request<object, BeatMovieResponse, object, BeatQuery>, res: Response<BeatMovieResponse>) => {
564
- const { filePath, beatIndex: beatIndexStr } = req.query;
565
- const beatIndex = beatIndexStr !== undefined ? parseInt(beatIndexStr, 10) : undefined;
566
-
567
- if (!filePath || beatIndex === undefined || isNaN(beatIndex)) {
568
- badRequest(res, "filePath and beatIndex are required");
231
+ const query = parseBeatQuery(req, res);
232
+ if (!query) return;
233
+ const result = await mulmoScriptOps.beatMovieOp(query.filePath, query.beatIndex);
234
+ if (!result.ok) {
235
+ sendOpFailure(res, result);
569
236
  return;
570
237
  }
571
-
572
- await withStoryContext(res, filePath, { operation: "beat-movie" }, async ({ context }) => {
573
- const { movieFile, soundEffectFile, lipSyncFile } = getBeatMoviePaths(context, beatIndex);
574
- const candidates = [lipSyncFile, soundEffectFile, movieFile, getBeatAnimatedVideoPath(context, beatIndex)];
575
- const existing = candidates.find((candidate) => existsSync(candidate));
576
- res.json({ moviePath: existing ? toStoryRef(existing) : null });
577
- });
238
+ res.json({ moviePath: result.moviePath });
578
239
  });
579
240
 
580
241
  interface GenerateBeatAudioBody {
@@ -584,205 +245,89 @@ interface GenerateBeatAudioBody {
584
245
  chatSessionId?: string;
585
246
  }
586
247
 
587
- async function handleGenerateBeatAudio(req: Request<object, object, GenerateBeatAudioBody>, res: Response<GenerateBeatAudioResponse>): Promise<void> {
588
- const { filePath, beatIndex, force, chatSessionId } = req.body;
589
-
590
- if (!filePath || beatIndex === undefined) {
591
- badRequest(res, "filePath and beatIndex are required");
592
- return;
593
- }
594
-
595
- const key = String(beatIndex);
596
- publishGeneration(chatSessionId, GENERATION_KINDS.beatAudio, filePath, key, false);
597
- let genError: string | undefined;
598
- try {
599
- await withStoryContext(res, filePath, { force, operation: "generate-beat-audio" }, async ({ context }) => {
600
- try {
601
- await generateBeatAudio(beatIndex, context, {
602
- settings: process.env as Record<string, string>,
603
- } as Parameters<typeof generateBeatAudio>[2]);
604
-
605
- const beat = context.studio.script.beats[beatIndex];
606
- const audioPath = context.studio.beats[beatIndex]?.audioFile ?? getBeatAudioPathOrUrl(beat.text ?? "", context, beat, context.lang);
607
-
608
- if (!audioPath || !existsSync(audioPath)) {
609
- // Logic-flow failure (not an exception) — emit a targeted
610
- // log. Don't write raw `beat.text` into persistent logs —
611
- // it's free-form user content and can contain sensitive
612
- // data.
613
- log.error("generate-beat-audio", "audio was not generated", {
614
- beatIndex,
615
- audioPath,
616
- exists: audioPath ? existsSync(audioPath) : false,
617
- beatTextLength: typeof beat?.text === "string" ? beat.text.length : 0,
618
- audioFilePresent: Boolean(context.studio.beats[beatIndex]?.audioFile),
619
- });
620
- genError = "Audio was not generated";
621
- serverError(res, genError);
622
- return;
623
- }
624
-
625
- res.json({ audio: fileToDataUri(audioPath, "audio/mpeg") });
626
- } catch (err) {
627
- genError = errorMessage(err);
628
- throw err;
629
- }
630
- });
631
- } finally {
632
- publishGeneration(chatSessionId, GENERATION_KINDS.beatAudio, filePath, key, true, genError);
633
- }
634
- }
635
-
636
248
  bindRoute(
637
249
  router,
638
250
  API_ROUTES.mulmoScript.generateBeatAudio,
639
- async (req: Request<object, object, GenerateBeatAudioBody>, res: Response<GenerateBeatAudioResponse>) => handleGenerateBeatAudio(req, res),
251
+ async (req: Request<object, object, GenerateBeatAudioBody>, res: Response<GenerateBeatAudioResponse>) => {
252
+ const { filePath, beatIndex, force, chatSessionId } = req.body;
253
+ if (typeof filePath !== "string" || !filePath || !validBeatIndex(beatIndex)) {
254
+ badRequest(res, "filePath and beatIndex are required");
255
+ return;
256
+ }
257
+ const result = await mulmoScriptOps.generateBeatAudioOp({ filePath, beatIndex, force, chatSessionId });
258
+ if (!result.ok) {
259
+ sendOpFailure(res, result);
260
+ return;
261
+ }
262
+ res.json({ audio: result.audio });
263
+ },
640
264
  );
641
265
 
642
266
  bindRoute(router, API_ROUTES.mulmoScript.renderBeat, async (req: Request<object, object, RenderBeatBody>, res: Response) => {
643
267
  const { filePath, beatIndex, force, chatSessionId } = req.body;
644
-
645
- if (!filePath || beatIndex === undefined) {
268
+ if (typeof filePath !== "string" || !filePath || !validBeatIndex(beatIndex)) {
646
269
  badRequest(res, "filePath and beatIndex are required");
647
270
  return;
648
271
  }
649
- if (ffmpegUnavailable(res)) return;
650
-
651
- const key = String(beatIndex);
652
- publishGeneration(chatSessionId, GENERATION_KINDS.beatImage, filePath, key, false);
653
- // withStoryContext swallows errors and responds with 500, so we
654
- // track failure via a local flag / message rather than try/catch
655
- // around the outer call.
656
- let genError: string | undefined;
657
- try {
658
- await withStoryContext(res, filePath, { force }, async ({ context }) => {
659
- try {
660
- await generateBeatImage({
661
- index: beatIndex,
662
- context,
663
- args: force ? { forceImage: true } : undefined,
664
- });
665
-
666
- const { imagePath } = getBeatPngImagePath(context, beatIndex);
667
- if (!existsSync(imagePath)) {
668
- genError = "Image was not generated";
669
- serverError(res, genError);
670
- return;
671
- }
672
- res.json({ image: fileToDataUri(imagePath, "image/png") });
673
- } catch (err) {
674
- genError = errorMessage(err);
675
- throw err;
676
- }
677
- });
678
- } finally {
679
- publishGeneration(chatSessionId, GENERATION_KINDS.beatImage, filePath, key, true, genError);
272
+ const result = await mulmoScriptOps.renderBeatOp({ filePath, beatIndex, force, chatSessionId });
273
+ if (!result.ok) {
274
+ sendOpFailure(res, result);
275
+ return;
680
276
  }
277
+ res.json({ image: result.image });
681
278
  });
682
279
 
683
- type MovieGenerationResult = { ok: true; outputPath: string } | { ok: false; error: string };
684
-
685
- interface MovieProgressEvent {
686
- kind: "image" | "audio";
687
- beatIndex: number;
688
- }
689
-
690
- // Map each beat to its array index, keyed by beat.id (falling back to
691
- // a synthetic `__index__<n>` for id-less beats). Shared by the movie
692
- // and PDF pipelines to translate mulmocast's per-beat progress events
693
- // (which carry the beat id) back into an index the UI can address.
694
- export function buildBeatIdIndex(beats: MulmoBeat[]): Map<string, number> {
695
- const idToIndex = new Map<string, number>();
696
- beats.forEach((beat, index) => {
697
- const key = beat.id ?? `__index__${index}`;
698
- idToIndex.set(key, index);
699
- });
700
- return idToIndex;
701
- }
702
-
703
- // Shared core for both the SSE-streaming `generateMovie` route and the
704
- // fire-and-forget background path triggered by `autoGenerateMovie`.
705
- // Builds the mulmo context, runs images→audio→movie, and reports
706
- // per-beat progress through the supplied callback. Throws on
707
- // unexpected pipeline errors; returns a structured failure when the
708
- // pipeline runs to completion but the output file is missing.
709
- async function runMovieGeneration(absoluteFilePath: string, onProgressEvent: (event: MovieProgressEvent) => void): Promise<MovieGenerationResult> {
710
- return withMulmoErrorCapture(() => runMoviePipeline(absoluteFilePath, onProgressEvent));
711
- }
712
-
713
- async function runMoviePipeline(absoluteFilePath: string, onProgressEvent: (event: MovieProgressEvent) => void): Promise<MovieGenerationResult> {
714
- const context = await buildContext(absoluteFilePath);
715
- if (!context) return { ok: false, error: "Failed to initialize mulmo context" };
716
-
717
- const idToIndex = buildBeatIdIndex(context.studio.script.beats as MulmoBeat[]);
718
-
719
- // Known limitation: addSessionProgressCallback is global, so when two
720
- // movie generations for *different* scripts run concurrently, both
721
- // closures are invoked for every beat event and rely on idToIndex to
722
- // filter out the other run's events. That filter is reliable only
723
- // when each beat carries an explicit `id`. Beats without one fall
724
- // back to "__index__${index}", and identical fallback ids across
725
- // scripts collide → progress meant for script A surfaces on script B.
726
- // Fixing this properly needs mulmocast to attach a per-run identifier
727
- // to its progress events (or a global serialization gate); tracked
728
- // separately, out of scope for this PR.
729
- const onProgress = (event: { kind: string; sessionType: string; id?: string; inSession: boolean }) => {
730
- if (event.kind !== "beat" || event.inSession || event.id === undefined) return;
731
- const beatIndex = idToIndex.get(event.id);
732
- if (beatIndex === undefined) return;
733
- if (event.sessionType !== "image" && event.sessionType !== "audio") return;
734
- onProgressEvent({ kind: event.sessionType, beatIndex });
735
- };
736
-
737
- addSessionProgressCallback(onProgress);
738
- try {
739
- // Order matters: audio() must run before images(). For html_tailwind
740
- // beats with `animation: true`, mulmocast only emits the per-beat
741
- // `_animated.mp4` when the beat's duration is already known (see
742
- // processHtmlTailwindAnimated in mulmocast). Durations are populated
743
- // by audio(), so running images() first leaves the .mp4 files
744
- // missing and movie() then fails in validateBeatSource.
745
- const audioContext = await audio(context);
746
- const imagesContext = await images(audioContext);
747
- await movie(imagesContext);
748
-
749
- const outputPath = movieFilePath(imagesContext);
750
- if (!existsSync(outputPath)) return { ok: false, error: "Movie was not generated" };
751
- return { ok: true, outputPath };
752
- } finally {
753
- removeSessionProgressCallback(onProgress);
754
- }
280
+ interface GenerationRequestBody {
281
+ filePath: string;
282
+ chatSessionId?: string;
755
283
  }
756
284
 
757
- bindRoute(router, API_ROUTES.mulmoScript.generateMovie, async (req: Request<object, object, { filePath: string; chatSessionId?: string }>, res: Response) => {
285
+ // Validate the `{ filePath }` body, run the ffmpeg guard, and resolve the
286
+ // script to an absolute path — responding (400 / op-failure) and returning
287
+ // null on any failure. Shared by the movie and PDF SSE routes.
288
+ function resolveStoryRequest(
289
+ req: Request<object, object, GenerationRequestBody>,
290
+ res: Response,
291
+ ): { filePath: string; absoluteFilePath: string; chatSessionId?: string } | null {
758
292
  const { filePath, chatSessionId } = req.body;
759
-
760
- if (!filePath) {
293
+ if (typeof filePath !== "string" || !filePath) {
761
294
  badRequest(res, "filePath is required");
762
- return;
295
+ return null;
763
296
  }
297
+ const ffmpeg = mulmoScriptOps.ffmpegGuard();
298
+ if (ffmpeg) {
299
+ sendOpFailure(res, ffmpeg);
300
+ return null;
301
+ }
302
+ const resolved = mulmoScriptOps.resolveStory(filePath);
303
+ if (!resolved.ok) {
304
+ sendOpFailure(res, resolved);
305
+ return null;
306
+ }
307
+ return { filePath, absoluteFilePath: resolved.absolutePath, chatSessionId };
308
+ }
764
309
 
765
- if (ffmpegUnavailable(res)) return;
766
-
767
- const absoluteFilePath = resolveStoryPath(filePath, res);
768
- if (!absoluteFilePath) return;
310
+ // SSE movie generation. Retained for wire compatibility (the extracted
311
+ // View now uses the long-held `generateMovie` dispatch + generation
312
+ // pubsub events instead); the pipeline itself is shared via
313
+ // `mulmoScriptOps.runMovieGeneration`.
314
+ bindRoute(router, API_ROUTES.mulmoScript.generateMovie, async (req: Request<object, object, { filePath: string; chatSessionId?: string }>, res: Response) => {
315
+ const parsed = resolveStoryRequest(req, res);
316
+ if (!parsed) return;
317
+ const { filePath, absoluteFilePath, chatSessionId } = parsed;
769
318
 
770
- if (inFlightMovies.has(absoluteFilePath)) {
319
+ if (mulmoScriptOps.inFlightMovies.has(absoluteFilePath)) {
771
320
  badRequest(res, "Movie generation is already in progress for this script");
772
321
  return;
773
322
  }
774
323
 
775
- res.setHeader("Content-Type", "text/event-stream");
776
- res.setHeader("Cache-Control", "no-cache");
777
- res.setHeader("Connection", "keep-alive");
324
+ const send = beginSse(res);
778
325
 
779
- const send = (data: unknown) => res.write(`data: ${JSON.stringify(data)}\n\n`);
780
-
781
- inFlightMovies.add(absoluteFilePath);
782
- publishGeneration(chatSessionId, GENERATION_KINDS.movie, filePath, "", false);
326
+ mulmoScriptOps.inFlightMovies.add(absoluteFilePath);
327
+ mulmoScriptOps.publishGeneration(chatSessionId, GENERATION_KINDS.movie, filePath, "", false);
783
328
  let genError: string | undefined;
784
329
  try {
785
- const result = await runMovieGeneration(absoluteFilePath, (event) => {
330
+ const result = await mulmoScriptOps.runMovieGeneration(absoluteFilePath, (event) => {
786
331
  send({ type: `beat_${event.kind}_done`, beatIndex: event.beatIndex });
787
332
  });
788
333
  if (!result.ok) {
@@ -790,84 +335,17 @@ bindRoute(router, API_ROUTES.mulmoScript.generateMovie, async (req: Request<obje
790
335
  send({ type: "error", message: result.error });
791
336
  return;
792
337
  }
793
- send({ type: "done", moviePath: toStoryRef(result.outputPath) });
338
+ send({ type: "done", moviePath: mulmoScriptOps.toStoryRef(result.outputPath) });
794
339
  } catch (err) {
795
340
  genError = errorMessage(err);
796
341
  send({ type: "error", message: genError });
797
342
  } finally {
798
- inFlightMovies.delete(absoluteFilePath);
799
- publishGeneration(chatSessionId, GENERATION_KINDS.movie, filePath, "", true, genError);
343
+ mulmoScriptOps.inFlightMovies.delete(absoluteFilePath);
344
+ mulmoScriptOps.publishGeneration(chatSessionId, GENERATION_KINDS.movie, filePath, "", true, genError);
800
345
  res.end();
801
346
  }
802
347
  });
803
348
 
804
- // Detached movie generation. Reports progress through the same session
805
- // pendingGenerations channel that the View already watches — so a user
806
- // opening the canvas mid-generation sees spinners, and a user opening
807
- // it after completion sees the finished movie loaded from disk by the
808
- // View's normal mount-time path. Errors are persisted to a
809
- // `<filename>.error.txt` sidecar next to the script (no synchronous
810
- // client to alert); any stale sidecar from a previous run is cleared on
811
- // each new attempt. Triggered server-side from the unified save route
812
- // when the caller passes `autoGenerateMovie: true`.
813
- async function runBackgroundMovieGeneration(absoluteFilePath: string, wireFilePath: string, chatSessionId: string | undefined): Promise<void> {
814
- const errorSidecarPath = `${absoluteFilePath}.error.txt`;
815
- // Clear stale error from a previous failed run before starting; if it
816
- // doesn't exist that's fine. Catch any unexpected fs errors silently —
817
- // the worst case is the user sees an out-of-date error file later.
818
- try {
819
- unlinkSync(errorSidecarPath);
820
- } catch {
821
- // intentional: ENOENT is the common case, others non-fatal
822
- }
823
-
824
- publishGeneration(chatSessionId, GENERATION_KINDS.movie, wireFilePath, "", false);
825
- let genError: string | undefined;
826
- try {
827
- const result = await runMovieGeneration(absoluteFilePath, (event) => {
828
- // Mirror per-beat completions through the session channel so the
829
- // View's pendingGenerations watcher reloads the asset off disk.
830
- // We fire start+finish in two ticks — `setImmediate` lets the SSE
831
- // writer flush the start event before the finish removes the
832
- // entry, otherwise Vue's batched reactivity could see a net "no
833
- // change" and skip the reload.
834
- const eventKind = event.kind === "image" ? GENERATION_KINDS.beatImage : GENERATION_KINDS.beatAudio;
835
- const key = String(event.beatIndex);
836
- publishGeneration(chatSessionId, eventKind, wireFilePath, key, false);
837
- setImmediate(() => publishGeneration(chatSessionId, eventKind, wireFilePath, key, true));
838
- });
839
-
840
- if (!result.ok) {
841
- genError = result.error;
842
- writeErrorSidecar(errorSidecarPath, result.error);
843
- log.warn("mulmo-script", "background movie generation failed", { filePath: wireFilePath, error: result.error });
844
- return;
845
- }
846
- log.info("mulmo-script", "background movie generation done", {
847
- filePath: wireFilePath,
848
- outputPath: result.outputPath,
849
- });
850
- } catch (err) {
851
- genError = errorMessage(err);
852
- writeErrorSidecar(errorSidecarPath, genError);
853
- log.error("mulmo-script", "background movie generation crashed", { filePath: wireFilePath, error: genError });
854
- } finally {
855
- inFlightMovies.delete(absoluteFilePath);
856
- publishGeneration(chatSessionId, GENERATION_KINDS.movie, wireFilePath, "", true, genError);
857
- }
858
- }
859
-
860
- function writeErrorSidecar(errorSidecarPath: string, message: string): void {
861
- try {
862
- writeFileSync(errorSidecarPath, message);
863
- } catch (writeErr) {
864
- log.error("mulmo-script", "failed to write error sidecar", {
865
- errorSidecarPath,
866
- error: errorMessage(writeErr),
867
- });
868
- }
869
- }
870
-
871
349
  interface CharacterImageQuery {
872
350
  filePath?: string;
873
351
  key?: string;
@@ -892,21 +370,18 @@ bindRoute(
892
370
  router,
893
371
  API_ROUTES.mulmoScript.characterImage,
894
372
  async (req: Request<object, CharacterImageResponse, object, CharacterImageQuery>, res: Response<CharacterImageResponse>) => {
895
- const { filePath, key } = req.query;
896
-
373
+ const filePath = stringQuery(req.query.filePath);
374
+ const key = stringQuery(req.query.key);
897
375
  if (!filePath || !key) {
898
376
  badRequest(res, "filePath and key are required");
899
377
  return;
900
378
  }
901
-
902
- await withStoryContext(res, filePath, {}, async ({ context }) => {
903
- const imagePath = getReferenceImagePath(context, key, "png");
904
- if (!existsSync(imagePath)) {
905
- res.json({ image: null });
906
- return;
907
- }
908
- res.json({ image: fileToDataUri(imagePath, "image/png") });
909
- });
379
+ const result = await mulmoScriptOps.characterImageOp(filePath, key);
380
+ if (!result.ok) {
381
+ sendOpFailure(res, result);
382
+ return;
383
+ }
384
+ res.json({ image: result.image });
910
385
  },
911
386
  );
912
387
 
@@ -915,21 +390,16 @@ bindRoute(
915
390
  API_ROUTES.mulmoScript.uploadBeatImage,
916
391
  async (req: Request<object, BeatImageResponse, UploadBeatImageBody>, res: Response<BeatImageResponse>) => {
917
392
  const { filePath, beatIndex, imageData } = req.body;
918
-
919
- if (!filePath || beatIndex === undefined || !imageData) {
393
+ if (typeof filePath !== "string" || !filePath || !validBeatIndex(beatIndex) || typeof imageData !== "string" || !imageData) {
920
394
  badRequest(res, "filePath, beatIndex, and imageData are required");
921
395
  return;
922
396
  }
923
-
924
- await withStoryContext(res, filePath, {}, async ({ context }) => {
925
- const { imagePath } = getBeatPngImagePath(context, beatIndex);
926
- // writeFileAtomic creates parent dirs and prevents a half-
927
- // written PNG from surviving a crash mid-write (#881 v2).
928
- const base64 = stripDataUri(imageData);
929
- await writeFileAtomic(imagePath, Buffer.from(base64, "base64"));
930
-
931
- res.json({ image: fileToDataUri(imagePath, "image/png") });
932
- });
397
+ const result = await mulmoScriptOps.uploadBeatImageOp(filePath, beatIndex, imageData);
398
+ if (!result.ok) {
399
+ sendOpFailure(res, result);
400
+ return;
401
+ }
402
+ res.json({ image: result.image });
933
403
  },
934
404
  );
935
405
 
@@ -938,50 +408,16 @@ bindRoute(
938
408
  API_ROUTES.mulmoScript.renderCharacter,
939
409
  async (req: Request<object, CharacterImageResponse, RenderCharacterBody>, res: Response<CharacterImageResponse>) => {
940
410
  const { filePath, key, force, chatSessionId } = req.body;
941
-
942
- if (!filePath || !key) {
411
+ if (typeof filePath !== "string" || !filePath || typeof key !== "string" || !key) {
943
412
  badRequest(res, "filePath and key are required");
944
413
  return;
945
414
  }
946
-
947
- publishGeneration(chatSessionId, GENERATION_KINDS.characterImage, filePath, key, false);
948
- let genError: string | undefined;
949
- try {
950
- await withStoryContext(res, filePath, { force }, async ({ context }) => {
951
- try {
952
- const images = context.studio.script.imageParams?.images ?? {};
953
- const imageEntry = images[key];
954
- if (!imageEntry || imageEntry.type !== "imagePrompt") {
955
- genError = `No imagePrompt entry for key: ${key}`;
956
- badRequest(res, genError);
957
- return;
958
- }
959
-
960
- const index = Object.keys(images).indexOf(key);
961
- const imagePath = getReferenceImagePath(context, key, "png");
962
- mkdirSync(path.dirname(imagePath), { recursive: true });
963
-
964
- await generateReferenceImage({
965
- context,
966
- key,
967
- index,
968
- image: imageEntry as MulmoImagePromptMedia,
969
- force,
970
- });
971
- if (!existsSync(imagePath)) {
972
- genError = "Character image was not generated";
973
- serverError(res, genError);
974
- return;
975
- }
976
- res.json({ image: fileToDataUri(imagePath, "image/png") });
977
- } catch (err) {
978
- genError = errorMessage(err);
979
- throw err;
980
- }
981
- });
982
- } finally {
983
- publishGeneration(chatSessionId, GENERATION_KINDS.characterImage, filePath, key, true, genError);
415
+ const result = await mulmoScriptOps.renderCharacterOp({ filePath, key, force, chatSessionId });
416
+ if (!result.ok) {
417
+ sendOpFailure(res, result);
418
+ return;
984
419
  }
420
+ res.json({ image: result.image });
985
421
  },
986
422
  );
987
423
 
@@ -990,144 +426,67 @@ bindRoute(
990
426
  API_ROUTES.mulmoScript.uploadCharacterImage,
991
427
  async (req: Request<object, CharacterImageResponse, UploadCharacterImageBody>, res: Response<CharacterImageResponse>) => {
992
428
  const { filePath, key, imageData } = req.body;
993
-
994
- if (!filePath || !key || !imageData) {
429
+ if (typeof filePath !== "string" || !filePath || typeof key !== "string" || !key || typeof imageData !== "string" || !imageData) {
995
430
  badRequest(res, "filePath, key, and imageData are required");
996
431
  return;
997
432
  }
998
-
999
- await withStoryContext(res, filePath, {}, async ({ context }) => {
1000
- const imagePath = getReferenceImagePath(context, key, "png");
1001
- // writeFileAtomic creates parent dirs and prevents a half-
1002
- // written PNG from surviving a crash mid-write (#881 v2).
1003
- const base64 = stripDataUri(imageData);
1004
- await writeFileAtomic(imagePath, Buffer.from(base64, "base64"));
1005
-
1006
- res.json({ image: fileToDataUri(imagePath, "image/png") });
1007
- });
433
+ const result = await mulmoScriptOps.uploadCharacterImageOp(filePath, key, imageData);
434
+ if (!result.ok) {
435
+ sendOpFailure(res, result);
436
+ return;
437
+ }
438
+ res.json({ image: result.image });
1008
439
  },
1009
440
  );
1010
441
 
1011
442
  bindRoute(router, API_ROUTES.mulmoScript.downloadMovie, (req: Request, res: Response) => {
1012
443
  const moviePath = getOptionalStringQuery(req, "moviePath");
1013
-
1014
444
  if (!moviePath) {
1015
445
  badRequest(res, "moviePath is required");
1016
446
  return;
1017
447
  }
1018
-
1019
- const absolutePath = resolveStoryPath(moviePath, res);
1020
- if (!absolutePath) return;
1021
-
1022
- res.download(absolutePath);
448
+ const resolved = mulmoScriptOps.resolveStory(moviePath);
449
+ if (!resolved.ok) {
450
+ sendOpFailure(res, resolved);
451
+ return;
452
+ }
453
+ res.download(resolved.absolutePath);
1023
454
  });
1024
455
 
1025
- // ── PDF (#1614) ────────────────────────────────────────────────
1026
- //
1027
- // PDF is the third output channel for a MulmoScript, alongside the
1028
- // existing movie pipeline. Same three-endpoint shape: poll status,
1029
- // kick off generation (SSE), download. We pin pdfMode="slide" +
1030
- // pdfSize="a4" — that's the configured default for MulmoClaude's
1031
- // editor; mulmocast's other modes (talk / handout / letter) stay
1032
- // reachable via the CLI for power users.
1033
-
1034
- const PDF_MODE = "slide" as const;
1035
- const PDF_SIZE = "a4" as const;
1036
-
1037
456
  bindRoute(
1038
457
  router,
1039
458
  API_ROUTES.mulmoScript.pdfStatus,
1040
459
  async (req: Request<object, PdfStatusResponse, object, FilePathQuery>, res: Response<PdfStatusResponse>) => {
1041
- const { filePath } = req.query;
460
+ const filePath = stringQuery(req.query.filePath);
1042
461
  if (!filePath) {
1043
462
  badRequest(res, "filePath is required");
1044
463
  return;
1045
464
  }
1046
- const absoluteFilePath = resolveStoryPath(filePath, res);
1047
- if (!absoluteFilePath) return;
1048
- try {
1049
- const context = await buildContext(absoluteFilePath);
1050
- if (!context) {
1051
- res.json({ pdfPath: null });
1052
- return;
1053
- }
1054
- const outputPath = pdfFilePath(context, PDF_MODE);
1055
- if (!existsSync(outputPath)) {
1056
- res.json({ pdfPath: null });
1057
- return;
1058
- }
1059
- // Same "newer than source" gate the movie status uses: a stale
1060
- // PDF (script edited after PDF was generated) reports null so
1061
- // the UI re-offers the Generate button.
1062
- const pdfMtime = statSync(outputPath).mtimeMs;
1063
- const sourceMtime = statSync(absoluteFilePath).mtimeMs;
1064
- if (pdfMtime < sourceMtime) {
1065
- res.json({ pdfPath: null });
1066
- return;
1067
- }
1068
- res.json({ pdfPath: toStoryRef(outputPath) });
1069
- } catch (err) {
1070
- serverError(res, errorMessage(err));
465
+ const result = await mulmoScriptOps.pdfStatusOp(filePath);
466
+ if (!result.ok) {
467
+ sendOpFailure(res, result);
468
+ return;
1071
469
  }
470
+ res.json({ pdfPath: result.pdfPath });
1072
471
  },
1073
472
  );
1074
473
 
1075
- type PdfGenerationResult = { ok: true; outputPath: string } | { ok: false; error: string };
1076
-
1077
- // Shared core for the SSE-streaming `generatePdf` route. Mirrors the
1078
- // movie pipeline's per-beat progress reporting so the UI's
1079
- // pendingGenerations watcher can light spinners during the image
1080
- // pass; the PDF action itself doesn't emit progress events, so only
1081
- // image events are forwarded. Returns a structured failure when the
1082
- // pipeline completes but the output file is missing.
1083
- async function runPdfGeneration(context: StoryContext, onImageBeatDone: (beatIndex: number) => void): Promise<PdfGenerationResult> {
1084
- return withMulmoErrorCapture(() => runPdfPipeline(context, onImageBeatDone));
1085
- }
1086
-
1087
- async function runPdfPipeline(context: StoryContext, onImageBeatDone: (beatIndex: number) => void): Promise<PdfGenerationResult> {
1088
- const idToIndex = buildBeatIdIndex(context.studio.script.beats as MulmoBeat[]);
1089
- const onProgress = (event: { kind: string; sessionType: string; id?: string; inSession: boolean }) => {
1090
- if (event.kind !== "beat" || event.inSession || event.id === undefined) return;
1091
- const beatIndex = idToIndex.get(event.id);
1092
- if (beatIndex === undefined) return;
1093
- if (event.sessionType !== "image") return;
1094
- onImageBeatDone(beatIndex);
1095
- };
1096
- addSessionProgressCallback(onProgress);
1097
- try {
1098
- const imagesContext = await images(context);
1099
- await pdf(imagesContext, PDF_MODE, PDF_SIZE);
1100
- const outputPath = pdfFilePath(imagesContext, PDF_MODE);
1101
- if (!existsSync(outputPath)) return { ok: false, error: "PDF was not generated" };
1102
- return { ok: true, outputPath };
1103
- } finally {
1104
- removeSessionProgressCallback(onProgress);
1105
- }
1106
- }
1107
-
474
+ // SSE PDF generation retained for wire compatibility, same as the
475
+ // movie SSE route above.
1108
476
  async function handleGeneratePdf(req: Request<object, object, { filePath: string; chatSessionId?: string }>, res: Response): Promise<void> {
1109
- const { filePath, chatSessionId } = req.body;
1110
- if (!filePath) {
1111
- badRequest(res, "filePath is required");
1112
- return;
1113
- }
1114
- if (ffmpegUnavailable(res)) return;
477
+ const parsed = resolveStoryRequest(req, res);
478
+ if (!parsed) return;
479
+ const { filePath, absoluteFilePath, chatSessionId } = parsed;
1115
480
 
1116
- const absoluteFilePath = resolveStoryPath(filePath, res);
1117
- if (!absoluteFilePath) return;
1118
-
1119
- if (inFlightPdfs.has(absoluteFilePath)) {
481
+ if (mulmoScriptOps.inFlightPdfs.has(absoluteFilePath)) {
1120
482
  badRequest(res, "PDF generation is already in progress for this script");
1121
483
  return;
1122
484
  }
1123
485
 
1124
- res.setHeader("Content-Type", "text/event-stream");
1125
- res.setHeader("Cache-Control", "no-cache");
1126
- res.setHeader("Connection", "keep-alive");
1127
- const send = (data: unknown) => res.write(`data: ${JSON.stringify(data)}\n\n`);
486
+ const send = beginSse(res);
1128
487
 
1129
- inFlightPdfs.add(absoluteFilePath);
1130
- publishGeneration(chatSessionId, GENERATION_KINDS.pdf, filePath, "", false);
488
+ mulmoScriptOps.inFlightPdfs.add(absoluteFilePath);
489
+ mulmoScriptOps.publishGeneration(chatSessionId, GENERATION_KINDS.pdf, filePath, "", false);
1131
490
  let genError: string | undefined;
1132
491
  try {
1133
492
  const context = await buildContext(absoluteFilePath);
@@ -1136,19 +495,19 @@ async function handleGeneratePdf(req: Request<object, object, { filePath: string
1136
495
  send({ type: "error", message: genError });
1137
496
  return;
1138
497
  }
1139
- const result = await runPdfGeneration(context, (beatIndex) => send({ type: "beat_image_done", beatIndex }));
498
+ const result = await mulmoScriptOps.runPdfGeneration(context, (beatIndex) => send({ type: "beat_image_done", beatIndex }));
1140
499
  if (!result.ok) {
1141
500
  genError = result.error;
1142
501
  send({ type: "error", message: genError });
1143
502
  return;
1144
503
  }
1145
- send({ type: "done", pdfPath: toStoryRef(result.outputPath) });
504
+ send({ type: "done", pdfPath: mulmoScriptOps.toStoryRef(result.outputPath) });
1146
505
  } catch (err) {
1147
506
  genError = errorMessage(err);
1148
507
  send({ type: "error", message: genError });
1149
508
  } finally {
1150
- inFlightPdfs.delete(absoluteFilePath);
1151
- publishGeneration(chatSessionId, GENERATION_KINDS.pdf, filePath, "", true, genError);
509
+ mulmoScriptOps.inFlightPdfs.delete(absoluteFilePath);
510
+ mulmoScriptOps.publishGeneration(chatSessionId, GENERATION_KINDS.pdf, filePath, "", true, genError);
1152
511
  res.end();
1153
512
  }
1154
513
  }
@@ -1163,9 +522,12 @@ bindRoute(router, API_ROUTES.mulmoScript.downloadPdf, (req: Request, res: Respon
1163
522
  badRequest(res, "pdfPath is required");
1164
523
  return;
1165
524
  }
1166
- const absolutePath = resolveStoryPath(pdfPath, res);
1167
- if (!absolutePath) return;
1168
- res.download(absolutePath);
525
+ const resolved = mulmoScriptOps.resolveStory(pdfPath);
526
+ if (!resolved.ok) {
527
+ sendOpFailure(res, resolved);
528
+ return;
529
+ }
530
+ res.download(resolved.absolutePath);
1169
531
  });
1170
532
 
1171
533
  export default router;