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.
- package/client/assets/{PluginScopedRoot-CjiZ2FJI.js → PluginScopedRoot-B53YSjaC.js} +1 -1
- package/client/assets/index-D3czxq1I.css +2 -0
- package/client/assets/{index-yQkLdtMk.js → index-DUdqhWg6.js} +42 -43
- package/client/assets/{marp-Bs_qwoKZ.js → marp-CyWxL3bc.js} +12 -3
- package/client/index.html +3 -3
- package/package.json +6 -5
- package/server/agent/attachmentConverter.ts +0 -25
- package/server/api/auth/viewToken.ts +10 -8
- package/server/api/routes/agent.ts +35 -10
- package/server/api/routes/collections.ts +150 -96
- package/server/api/routes/files.ts +57 -54
- package/server/api/routes/mulmo-script.ts +257 -895
- package/server/api/routes/wiki/history.ts +17 -19
- package/server/events/collection-change.ts +0 -5
- package/server/events/file-change.ts +1 -6
- package/server/index.ts +25 -76
- package/server/plugins/diagnostics.ts +0 -6
- package/server/plugins/mulmoscript-server.ts +58 -0
- package/server/prompts/system/system.md +2 -2
- package/server/remoteHost/handlers/getCollection.ts +5 -4
- package/server/remoteHost/handlers/googleCalendar.ts +50 -3
- package/server/remoteHost/handlers/index.ts +3 -1
- package/server/utils/files/html-store.ts +2 -16
- package/server/utils/files/journal-io.ts +0 -12
- package/server/utils/files/plugins-io.ts +2 -11
- package/server/utils/files/safe.ts +39 -0
- package/server/utils/httpError.ts +6 -0
- package/server/utils/time.ts +0 -3
- package/server/workspace/collections/index.ts +1 -0
- package/server/workspace/collections/remoteView.ts +13 -4
- package/server/workspace/journal/state.ts +1 -9
- package/server/workspace/memory/topic-index-hook.ts +0 -9
- package/server/workspace/memory/topic-io.ts +0 -4
- package/server/workspace/skills/external/catalog.ts +24 -13
- package/server/workspace/skills/writer.ts +20 -16
- package/server/workspace/wiki-pages/snapshot.ts +13 -12
- package/src/composables/useFileDropZone.ts +3 -15
- package/src/config/apiRoutes.ts +6 -0
- package/src/config/roles.ts +0 -4
- package/src/lang/de.ts +0 -30
- package/src/lang/en.ts +0 -30
- package/src/lang/es.ts +0 -30
- package/src/lang/fr.ts +0 -30
- package/src/lang/ja.ts +0 -30
- package/src/lang/ko.ts +0 -30
- package/src/lang/pt-BR.ts +0 -30
- package/src/lang/zh.ts +0 -30
- package/src/plugins/accounting/index.ts +0 -2
- package/src/plugins/api.ts +0 -6
- package/src/plugins/canvas/index.ts +0 -2
- package/src/plugins/chart/index.ts +0 -2
- package/src/plugins/editImages/index.ts +0 -2
- package/src/plugins/generateImage/index.ts +0 -2
- package/src/plugins/manageSkills/index.ts +0 -2
- package/src/plugins/markdown/index.ts +0 -2
- package/src/plugins/photoLocations/index.ts +0 -2
- package/src/plugins/presentCollection/index.ts +0 -2
- package/src/plugins/presentForm/index.ts +0 -2
- package/src/plugins/presentHtml/index.ts +0 -2
- package/src/plugins/presentMulmoScript/definition.ts +11 -116
- package/src/plugins/presentMulmoScript/index.ts +62 -12
- package/src/plugins/presentSVG/index.ts +0 -2
- package/src/plugins/skill/index.ts +0 -3
- package/src/plugins/spreadsheet/engine/parser.ts +0 -103
- package/src/plugins/spreadsheet/index.ts +0 -2
- package/src/plugins/textResponse/index.ts +0 -3
- package/src/plugins/wiki/index.ts +0 -2
- package/src/types/notification.ts +0 -10
- package/src/utils/api.ts +0 -4
- package/client/assets/index-D5nJbMO1.css +0 -2
- package/server/api/routes/mulmoScriptValidate.ts +0 -101
- package/server/utils/mulmoErrorCapture.ts +0 -93
- package/src/plugins/presentMulmoScript/Preview.vue +0 -23
- package/src/plugins/presentMulmoScript/View.vue +0 -1949
- 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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
//
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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,
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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.
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
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
|
-
|
|
313
|
-
|
|
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
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
if (!
|
|
321
|
-
|
|
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
|
|
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
|
-
|
|
347
|
-
|
|
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
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
if (!
|
|
529
|
-
|
|
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
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
if (!
|
|
568
|
-
|
|
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>) =>
|
|
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
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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
|
-
|
|
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
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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
|
|
776
|
-
res.setHeader("Cache-Control", "no-cache");
|
|
777
|
-
res.setHeader("Connection", "keep-alive");
|
|
324
|
+
const send = beginSse(res);
|
|
778
325
|
|
|
779
|
-
|
|
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
|
|
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
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
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
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
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
|
-
|
|
948
|
-
|
|
949
|
-
|
|
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
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
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
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
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
|
|
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
|
|
1047
|
-
if (!
|
|
1048
|
-
|
|
1049
|
-
|
|
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
|
-
|
|
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
|
|
1110
|
-
if (!
|
|
1111
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1167
|
-
if (!
|
|
1168
|
-
|
|
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;
|