@mulmoclaude/mulmoscript-plugin 0.1.0 → 0.2.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/dist/server.js ADDED
@@ -0,0 +1,955 @@
1
+ import { i as executeUpdateScript, n as executeMulmoScriptSave, r as executeUpdateBeat } from "./plugin-W1ppnyhR.js";
2
+ import { t as GENERATION_EVENT } from "./contract-vY0niHkh.js";
3
+ import { existsSync, mkdirSync, realpathSync, statSync, unlinkSync } from "fs";
4
+ import path from "path";
5
+ import { addSessionProgressCallback, audio, generateBeatAudio, generateBeatImage, generateReferenceImage, getBeatAnimatedVideoPath, getBeatAudioPathOrUrl, getBeatMoviePaths, getBeatPngImagePath, getFileObject, getReferenceImagePath, images, initializeContextFromFiles, movie, movieFilePath, pdf, pdfFilePath, removeSessionProgressCallback, setGraphAILogger } from "mulmocast";
6
+ import { readFile } from "node:fs/promises";
7
+ import { AsyncLocalStorage } from "node:async_hooks";
8
+ import { GraphAILogger } from "graphai";
9
+ //#region src/server/support.ts
10
+ function errorText(err) {
11
+ if (err instanceof Error) return err.message;
12
+ if (err !== null && typeof err === "object") {
13
+ const obj = err;
14
+ if (typeof obj.details === "string" && obj.details) return obj.details;
15
+ if (typeof obj.message === "string" && obj.message) return obj.message;
16
+ }
17
+ return String(err);
18
+ }
19
+ function isRecord(value) {
20
+ return typeof value === "object" && value !== null && !Array.isArray(value);
21
+ }
22
+ function stripDataUri(dataUri) {
23
+ return dataUri.replace(/^data:image\/[^;]+;base64,/, "");
24
+ }
25
+ /** Realpath-based containment: resolve `relPath` against the ROOT's
26
+ * realpath and require the target's realpath to stay inside it. Returns
27
+ * null on ENOENT or traversal (symlink escapes included). */
28
+ function resolveWithinRoot(rootReal, relPath) {
29
+ const normalized = path.normalize(relPath || "");
30
+ const resolved = path.resolve(rootReal, normalized);
31
+ let resolvedReal;
32
+ try {
33
+ resolvedReal = realpathSync(resolved);
34
+ } catch {
35
+ return null;
36
+ }
37
+ if (resolvedReal !== rootReal && !resolvedReal.startsWith(rootReal + path.sep)) return null;
38
+ return resolvedReal;
39
+ }
40
+ async function fileToDataUri(filePath, mimeType) {
41
+ return `data:${mimeType};base64,${(await readFile(filePath)).toString("base64")}`;
42
+ }
43
+ //#endregion
44
+ //#region src/server/mulmoErrorCapture.ts
45
+ var capturedErrors = new AsyncLocalStorage();
46
+ var loggerInstalled = false;
47
+ var captureLog = null;
48
+ /** Route captured GraphAI errors into the host logger. Set once by
49
+ * `createMulmoScriptServerOps`; the GraphAILogger sink is global, so the
50
+ * last-configured host logger wins (one ops instance per process). */
51
+ function setMulmoErrorCaptureLogger(log) {
52
+ captureLog = log;
53
+ }
54
+ function formatLogArg(arg) {
55
+ if (typeof arg === "string") return arg;
56
+ if (arg instanceof Error) return arg.message;
57
+ try {
58
+ return JSON.stringify(arg);
59
+ } catch {
60
+ return String(arg);
61
+ }
62
+ }
63
+ /**
64
+ * Re-enable GraphAI's error level (everything else stays silenced) and
65
+ * route it into the host logger + the per-operation capture store.
66
+ * Call after every `setGraphAILogger(false)` — that helper disables all
67
+ * levels including error. Idempotent.
68
+ */
69
+ function enableGraphAIErrorCapture() {
70
+ GraphAILogger.setLevelEnabled("error", true);
71
+ if (loggerInstalled) return;
72
+ loggerInstalled = true;
73
+ GraphAILogger.setLogger((level, ...args) => {
74
+ if (level !== "error") return;
75
+ const message = args.map(formatLogArg).join(" ");
76
+ captureLog?.warn("mulmocast generation error", { message });
77
+ capturedErrors.getStore()?.push(message);
78
+ });
79
+ }
80
+ var CAUSE_FIELDS = [
81
+ "type",
82
+ "agentName",
83
+ "envVarName",
84
+ "errorCode",
85
+ "errorType"
86
+ ];
87
+ /** Render mulmocast's structured error `cause` as "field=value" pairs. */
88
+ function describeMulmoCause(err) {
89
+ if (!(err instanceof Error) || !isRecord(err.cause)) return null;
90
+ const { cause } = err;
91
+ const parts = CAUSE_FIELDS.flatMap((field) => {
92
+ const value = cause[field];
93
+ return typeof value === "string" && value !== "" ? [`${field}=${value}`] : [];
94
+ });
95
+ return parts.length > 0 ? parts.join(" ") : null;
96
+ }
97
+ /**
98
+ * Compose the enriched message for a failed mulmocast operation:
99
+ * mulmocast's own message, then its structured cause, then the
100
+ * captured underlying provider error(s). Deduped — GraphAI retries
101
+ * log the same error more than once.
102
+ */
103
+ function composeMulmoErrorMessage(err, captured) {
104
+ const base = errorText(err);
105
+ const details = [...new Set(captured)].filter((message) => message !== "" && message !== base);
106
+ return [
107
+ base,
108
+ describeMulmoCause(err),
109
+ ...details
110
+ ].filter(Boolean).join(" — ");
111
+ }
112
+ /**
113
+ * Run a mulmocast operation, capturing GraphAI error logs emitted while
114
+ * it executes. On failure, rethrows with the captured provider error(s)
115
+ * appended to the message (original error kept as `cause`). Uses
116
+ * AsyncLocalStorage so concurrent operations don't cross-attribute.
117
+ */
118
+ async function withMulmoErrorCapture(operation) {
119
+ return capturedErrors.run([], async () => {
120
+ try {
121
+ return await operation();
122
+ } catch (err) {
123
+ throw new Error(composeMulmoErrorMessage(err, capturedErrors.getStore() ?? []), { cause: err });
124
+ }
125
+ });
126
+ }
127
+ //#endregion
128
+ //#region src/server/ops.ts
129
+ var PDF_MODE = "slide";
130
+ var PDF_SIZE = "a4";
131
+ function opBadRequest(error) {
132
+ return {
133
+ ok: false,
134
+ code: "bad_request",
135
+ error
136
+ };
137
+ }
138
+ function opNotFound(error) {
139
+ return {
140
+ ok: false,
141
+ code: "not_found",
142
+ error
143
+ };
144
+ }
145
+ function opServerError(error) {
146
+ return {
147
+ ok: false,
148
+ code: "server_error",
149
+ error
150
+ };
151
+ }
152
+ var NOOP_LOG = {
153
+ info: () => {},
154
+ warn: () => {},
155
+ error: () => {}
156
+ };
157
+ async function buildContext(absoluteFilePath, force = false) {
158
+ setGraphAILogger(false);
159
+ enableGraphAIErrorCapture();
160
+ return initializeContextFromFiles(getFileObject({
161
+ file: absoluteFilePath,
162
+ basedir: path.dirname(absoluteFilePath),
163
+ grouped: true
164
+ }), true, force);
165
+ }
166
+ function buildBeatIdIndex(beats) {
167
+ const idToIndex = /* @__PURE__ */ new Map();
168
+ beats.forEach((beat, index) => {
169
+ const key = beat.id ?? `__index__${index}`;
170
+ idToIndex.set(key, index);
171
+ });
172
+ return idToIndex;
173
+ }
174
+ /** Map identity for the in-flight tracker. JSON array keeps the three
175
+ * fields unambiguous (a human-visible delimiter could collide). */
176
+ function generationMapKey(kind, filePath, key) {
177
+ return JSON.stringify([
178
+ kind,
179
+ filePath,
180
+ key
181
+ ]);
182
+ }
183
+ /**
184
+ * Build the per-host mulmoScript server ops instance. One instance per
185
+ * process — it owns the in-flight movie/PDF dedup sets and the
186
+ * generation-state tracker, and binds the injected host backend.
187
+ */
188
+ function createMulmoScriptServerOps(backend) {
189
+ const log = backend.log ?? NOOP_LOG;
190
+ setMulmoErrorCaptureLogger(log);
191
+ const storiesDir = path.resolve(backend.storiesDir);
192
+ function toStoryRef(absolutePath) {
193
+ const root = ensureStoriesReal() ?? storiesDir;
194
+ const rel = path.relative(root, absolutePath).split(path.sep).join("/");
195
+ return rel ? `stories/${rel}` : "stories";
196
+ }
197
+ let storiesRealCache = null;
198
+ function ensureStoriesReal() {
199
+ if (storiesRealCache) return storiesRealCache;
200
+ try {
201
+ mkdirSync(storiesDir, { recursive: true });
202
+ storiesRealCache = realpathSync(storiesDir);
203
+ return storiesRealCache;
204
+ } catch {
205
+ return null;
206
+ }
207
+ }
208
+ /**
209
+ * Resolve and validate a stories wire path to its absolute realpath.
210
+ *
211
+ * Uses the realpath-based resolveWithinRoot helper to defeat
212
+ * symlink-based escapes. Callers pass workspace-relative paths like
213
+ * "stories/foo.json" or "stories/__movies__/bar.mp4". We strip the
214
+ * leading "stories/" segment and resolve the remainder against the
215
+ * realpath of the stories directory itself — this works whether
216
+ * stories/ is a regular directory or a legitimate symlink to another
217
+ * location. ENOENT and traversal are distinguished (404 vs 400).
218
+ */
219
+ function resolveStory(filePath) {
220
+ const storiesReal = ensureStoriesReal();
221
+ if (!storiesReal) return opServerError("stories directory not available");
222
+ if (path.isAbsolute(filePath)) return opBadRequest("Invalid filePath");
223
+ const STORIES_PREFIX = `stories${path.sep}`;
224
+ const relFromStories = filePath === "stories" ? "" : filePath.startsWith(STORIES_PREFIX) || filePath.startsWith("stories/") ? filePath.slice(8) : filePath;
225
+ const resolved = resolveWithinRoot(storiesReal, relFromStories);
226
+ if (!resolved) {
227
+ const candidate = path.resolve(storiesReal, relFromStories);
228
+ if ((candidate === storiesReal || candidate.startsWith(storiesReal + path.sep)) && !existsSync(candidate)) return opNotFound(`File not found: ${filePath}`);
229
+ return opBadRequest("Invalid filePath");
230
+ }
231
+ return {
232
+ ok: true,
233
+ absolutePath: resolved
234
+ };
235
+ }
236
+ /**
237
+ * Realpath containment pre-guard for wire paths handed to the phase-1
238
+ * core's save/reopen/update executes. The core's own path guard is
239
+ * lexical (it runs against the generic FileOps, whose read/write follows
240
+ * symlinks), so hosts re-assert the realpath boundary here before
241
+ * invoking it — a symlink planted below the stories dir can't read or
242
+ * write outside the tree (Codex P1 on MulmoClaude#2133).
243
+ *
244
+ * Returns null when `filePath` isn't a non-empty string — shape
245
+ * validation (including the script-vs-filePath mode check) belongs to
246
+ * the core.
247
+ */
248
+ function guardStoryWirePath(filePath) {
249
+ if (typeof filePath !== "string" || filePath === "") return null;
250
+ const resolved = resolveStory(filePath);
251
+ return resolved.ok ? null : resolved;
252
+ }
253
+ function ffmpegGuard() {
254
+ if (backend.isFfmpegAvailable?.() === false) return {
255
+ ok: false,
256
+ code: "unavailable",
257
+ error: "ffmpeg is not installed — movie and beat rendering are unavailable. Install ffmpeg and restart the server."
258
+ };
259
+ return null;
260
+ }
261
+ const inFlightGenerations = /* @__PURE__ */ new Map();
262
+ function publishGeneration(chatSessionId, kind, filePath, key, finished, error) {
263
+ const mapKey = generationMapKey(kind, filePath, key);
264
+ const existing = inFlightGenerations.get(mapKey);
265
+ if (finished) {
266
+ if (existing && existing.count > 1) {
267
+ existing.count -= 1;
268
+ return;
269
+ }
270
+ inFlightGenerations.delete(mapKey);
271
+ } else {
272
+ if (existing) {
273
+ existing.count += 1;
274
+ return;
275
+ }
276
+ inFlightGenerations.set(mapKey, {
277
+ kind,
278
+ filePath,
279
+ key,
280
+ count: 1
281
+ });
282
+ }
283
+ const event = {
284
+ kind,
285
+ filePath,
286
+ key,
287
+ done: finished,
288
+ ...error ? { error } : {}
289
+ };
290
+ backend.onGenerationEvent?.(chatSessionId, event);
291
+ }
292
+ /** Snapshot of generations currently in flight for one script — the
293
+ * View's mount-time catch-up, filtered to its wire `filePath`. */
294
+ function pendingGenerations(filePath) {
295
+ return [...inFlightGenerations.values()].filter((entry) => entry.filePath === filePath).map(({ kind, key }) => ({
296
+ kind,
297
+ filePath,
298
+ key,
299
+ done: false
300
+ }));
301
+ }
302
+ /**
303
+ * Shared scaffolding for mulmoScript ops. Resolves the wire filePath,
304
+ * builds the mulmo context, and folds unexpected handler errors into a
305
+ * server_error failure (with a warn breadcrumb). Accepts a `deps` param
306
+ * so unit tests can inject fakes without the full mulmocast stack.
307
+ */
308
+ async function runStoryOp(filePath, options, handler, deps = {}) {
309
+ const resolver = deps.resolveStory ?? resolveStory;
310
+ const build = deps.buildContext ?? buildContext;
311
+ const resolved = resolver(filePath);
312
+ if (!resolved.ok) return resolved;
313
+ try {
314
+ const context = await build(resolved.absolutePath, options.force ?? false);
315
+ if (!context) {
316
+ if (options.onContextMissing) return options.onContextMissing();
317
+ return opServerError("Failed to initialize mulmo context");
318
+ }
319
+ return await withMulmoErrorCapture(() => handler({
320
+ absoluteFilePath: resolved.absolutePath,
321
+ context
322
+ }));
323
+ } catch (err) {
324
+ log.warn("op failed", {
325
+ ...options.operation ? { operation: options.operation } : {},
326
+ filePath,
327
+ error: errorText(err)
328
+ });
329
+ return opServerError(errorText(err));
330
+ }
331
+ }
332
+ async function beatImageOp(filePath, beatIndex) {
333
+ return runStoryOp(filePath, { operation: "beat-image" }, async ({ context }) => {
334
+ const { imagePath } = getBeatPngImagePath(context, beatIndex);
335
+ if (!existsSync(imagePath)) return {
336
+ ok: true,
337
+ image: null
338
+ };
339
+ return {
340
+ ok: true,
341
+ image: await fileToDataUri(imagePath, "image/png")
342
+ };
343
+ });
344
+ }
345
+ async function beatAudioOp(filePath, beatIndex) {
346
+ return runStoryOp(filePath, {
347
+ operation: "beat-audio",
348
+ onContextMissing: () => ({
349
+ ok: true,
350
+ audio: null
351
+ })
352
+ }, async ({ context }) => {
353
+ const beat = context.studio.script.beats[beatIndex];
354
+ const audioPath = getBeatAudioPathOrUrl(beat.text ?? "", context, beat, context.lang);
355
+ if (!audioPath || !existsSync(audioPath)) return {
356
+ ok: true,
357
+ audio: null
358
+ };
359
+ return {
360
+ ok: true,
361
+ audio: await fileToDataUri(audioPath, "audio/mpeg")
362
+ };
363
+ });
364
+ }
365
+ async function beatMovieOp(filePath, beatIndex) {
366
+ return runStoryOp(filePath, { operation: "beat-movie" }, async ({ context }) => {
367
+ const { movieFile, soundEffectFile, lipSyncFile } = getBeatMoviePaths(context, beatIndex);
368
+ const existing = [
369
+ lipSyncFile,
370
+ soundEffectFile,
371
+ movieFile,
372
+ getBeatAnimatedVideoPath(context, beatIndex)
373
+ ].find((candidate) => existsSync(candidate));
374
+ return {
375
+ ok: true,
376
+ moviePath: existing ? toStoryRef(existing) : null
377
+ };
378
+ });
379
+ }
380
+ async function characterImageOp(filePath, key) {
381
+ return runStoryOp(filePath, { operation: "character-image" }, async ({ context }) => {
382
+ const imagePath = getReferenceImagePath(context, key, "png");
383
+ if (!existsSync(imagePath)) return {
384
+ ok: true,
385
+ image: null
386
+ };
387
+ return {
388
+ ok: true,
389
+ image: await fileToDataUri(imagePath, "image/png")
390
+ };
391
+ });
392
+ }
393
+ /** Shared "output exists and is newer than the source script" gate for
394
+ * movie / PDF status. A stale artifact (script edited after it was
395
+ * generated) reports null so the UI re-offers the Generate button. */
396
+ function freshOutputRef(outputPath, absoluteFilePath) {
397
+ if (!existsSync(outputPath)) return null;
398
+ if (statSync(outputPath).mtimeMs < statSync(absoluteFilePath).mtimeMs) return null;
399
+ return toStoryRef(outputPath);
400
+ }
401
+ async function movieStatusOp(filePath) {
402
+ return runStoryOp(filePath, {
403
+ operation: "movie-status",
404
+ onContextMissing: () => ({
405
+ ok: true,
406
+ moviePath: null
407
+ })
408
+ }, async ({ absoluteFilePath, context }) => ({
409
+ ok: true,
410
+ moviePath: freshOutputRef(movieFilePath(context), absoluteFilePath)
411
+ }));
412
+ }
413
+ async function pdfStatusOp(filePath) {
414
+ return runStoryOp(filePath, {
415
+ operation: "pdf-status",
416
+ onContextMissing: () => ({
417
+ ok: true,
418
+ pdfPath: null
419
+ })
420
+ }, async ({ absoluteFilePath, context }) => ({
421
+ ok: true,
422
+ pdfPath: freshOutputRef(pdfFilePath(context, PDF_MODE), absoluteFilePath)
423
+ }));
424
+ }
425
+ async function renderBeatOp(args) {
426
+ const { filePath, beatIndex, force, chatSessionId } = args;
427
+ const ffmpeg = ffmpegGuard();
428
+ if (ffmpeg) return ffmpeg;
429
+ const mapKey = String(beatIndex);
430
+ publishGeneration(chatSessionId, "beatImage", filePath, mapKey, false);
431
+ let genError;
432
+ try {
433
+ const result = await runStoryOp(filePath, {
434
+ force,
435
+ operation: "render-beat"
436
+ }, async ({ context }) => {
437
+ await generateBeatImage({
438
+ index: beatIndex,
439
+ context,
440
+ args: force ? { forceImage: true } : void 0
441
+ });
442
+ const { imagePath } = getBeatPngImagePath(context, beatIndex);
443
+ if (!existsSync(imagePath)) return opServerError("Image was not generated");
444
+ return {
445
+ ok: true,
446
+ image: await fileToDataUri(imagePath, "image/png")
447
+ };
448
+ });
449
+ if (!result.ok) genError = result.error;
450
+ return result;
451
+ } finally {
452
+ publishGeneration(chatSessionId, "beatImage", filePath, mapKey, true, genError);
453
+ }
454
+ }
455
+ async function generateBeatAudioOp(args) {
456
+ const { filePath, beatIndex, force, chatSessionId } = args;
457
+ const mapKey = String(beatIndex);
458
+ publishGeneration(chatSessionId, "beatAudio", filePath, mapKey, false);
459
+ let genError;
460
+ try {
461
+ const result = await runStoryOp(filePath, {
462
+ force,
463
+ operation: "generate-beat-audio"
464
+ }, async ({ context }) => {
465
+ await generateBeatAudio(beatIndex, context, { settings: process.env });
466
+ const beat = context.studio.script.beats[beatIndex];
467
+ const audioPath = context.studio.beats[beatIndex]?.audioFile ?? getBeatAudioPathOrUrl(beat.text ?? "", context, beat, context.lang);
468
+ if (!audioPath || !existsSync(audioPath)) {
469
+ log.error("audio was not generated", {
470
+ beatIndex,
471
+ audioPath,
472
+ exists: audioPath ? existsSync(audioPath) : false,
473
+ beatTextLength: typeof beat?.text === "string" ? beat.text.length : 0,
474
+ audioFilePresent: Boolean(context.studio.beats[beatIndex]?.audioFile)
475
+ });
476
+ return opServerError("Audio was not generated");
477
+ }
478
+ return {
479
+ ok: true,
480
+ audio: await fileToDataUri(audioPath, "audio/mpeg")
481
+ };
482
+ });
483
+ if (!result.ok) genError = result.error;
484
+ return result;
485
+ } finally {
486
+ publishGeneration(chatSessionId, "beatAudio", filePath, mapKey, true, genError);
487
+ }
488
+ }
489
+ async function renderCharacterOp(args) {
490
+ const { filePath, key, force, chatSessionId } = args;
491
+ publishGeneration(chatSessionId, "characterImage", filePath, key, false);
492
+ let genError;
493
+ try {
494
+ const result = await runStoryOp(filePath, {
495
+ force,
496
+ operation: "render-character"
497
+ }, async ({ context }) => {
498
+ const imageEntries = context.studio.script.imageParams?.images ?? {};
499
+ const imageEntry = imageEntries[key];
500
+ if (!imageEntry || imageEntry.type !== "imagePrompt") return opBadRequest(`No imagePrompt entry for key: ${key}`);
501
+ const index = Object.keys(imageEntries).indexOf(key);
502
+ const imagePath = getReferenceImagePath(context, key, "png");
503
+ mkdirSync(path.dirname(imagePath), { recursive: true });
504
+ await generateReferenceImage({
505
+ context,
506
+ key,
507
+ index,
508
+ image: imageEntry,
509
+ force
510
+ });
511
+ if (!existsSync(imagePath)) return opServerError("Character image was not generated");
512
+ return {
513
+ ok: true,
514
+ image: await fileToDataUri(imagePath, "image/png")
515
+ };
516
+ });
517
+ if (!result.ok) genError = result.error;
518
+ return result;
519
+ } finally {
520
+ publishGeneration(chatSessionId, "characterImage", filePath, key, true, genError);
521
+ }
522
+ }
523
+ async function uploadBeatImageOp(filePath, beatIndex, imageData) {
524
+ return runStoryOp(filePath, { operation: "upload-beat-image" }, async ({ context }) => {
525
+ const { imagePath } = getBeatPngImagePath(context, beatIndex);
526
+ const base64 = stripDataUri(imageData);
527
+ await backend.writeFileAtomic(imagePath, Buffer.from(base64, "base64"));
528
+ return {
529
+ ok: true,
530
+ image: await fileToDataUri(imagePath, "image/png")
531
+ };
532
+ });
533
+ }
534
+ async function uploadCharacterImageOp(filePath, key, imageData) {
535
+ return runStoryOp(filePath, { operation: "upload-character-image" }, async ({ context }) => {
536
+ const imagePath = getReferenceImagePath(context, key, "png");
537
+ const base64 = stripDataUri(imageData);
538
+ await backend.writeFileAtomic(imagePath, Buffer.from(base64, "base64"));
539
+ return {
540
+ ok: true,
541
+ image: await fileToDataUri(imagePath, "image/png")
542
+ };
543
+ });
544
+ }
545
+ const inFlightMovies = /* @__PURE__ */ new Set();
546
+ const inFlightPdfs = /* @__PURE__ */ new Set();
547
+ async function runMovieGeneration(absoluteFilePath, onProgressEvent) {
548
+ return withMulmoErrorCapture(() => runMoviePipeline(absoluteFilePath, onProgressEvent));
549
+ }
550
+ async function runMoviePipeline(absoluteFilePath, onProgressEvent) {
551
+ const context = await buildContext(absoluteFilePath);
552
+ if (!context) return {
553
+ ok: false,
554
+ error: "Failed to initialize mulmo context"
555
+ };
556
+ const idToIndex = buildBeatIdIndex(context.studio.script.beats);
557
+ const onProgress = (event) => {
558
+ if (event.kind !== "beat" || event.inSession || event.id === void 0) return;
559
+ const beatIndex = idToIndex.get(event.id);
560
+ if (beatIndex === void 0) return;
561
+ if (event.sessionType !== "image" && event.sessionType !== "audio") return;
562
+ onProgressEvent({
563
+ kind: event.sessionType,
564
+ beatIndex
565
+ });
566
+ };
567
+ addSessionProgressCallback(onProgress);
568
+ try {
569
+ const imagesContext = await images(await audio(context));
570
+ await movie(imagesContext);
571
+ const outputPath = movieFilePath(imagesContext);
572
+ if (!existsSync(outputPath)) return {
573
+ ok: false,
574
+ error: "Movie was not generated"
575
+ };
576
+ return {
577
+ ok: true,
578
+ outputPath
579
+ };
580
+ } finally {
581
+ removeSessionProgressCallback(onProgress);
582
+ }
583
+ }
584
+ /**
585
+ * Long-held foreground movie generation (the package View's
586
+ * `generateMovie` dispatch). Resolves when the whole pipeline finishes.
587
+ * Per-beat completions are mirrored to the generation channels so the
588
+ * initiating View (and any other mounted View) reloads assets off disk
589
+ * as they land — the successor of the SSE per-beat events.
590
+ */
591
+ async function generateMovieOp(filePath, chatSessionId) {
592
+ const ffmpeg = ffmpegGuard();
593
+ if (ffmpeg) return ffmpeg;
594
+ const resolved = resolveStory(filePath);
595
+ if (!resolved.ok) return resolved;
596
+ const absoluteFilePath = resolved.absolutePath;
597
+ if (inFlightMovies.has(absoluteFilePath)) return opBadRequest("Movie generation is already in progress for this script");
598
+ inFlightMovies.add(absoluteFilePath);
599
+ publishGeneration(chatSessionId, "movie", filePath, "", false);
600
+ let genError;
601
+ try {
602
+ const result = await runMovieGeneration(absoluteFilePath, (event) => {
603
+ publishGeneration(chatSessionId, event.kind === "image" ? "beatImage" : "beatAudio", filePath, String(event.beatIndex), true);
604
+ });
605
+ if (!result.ok) {
606
+ genError = result.error;
607
+ return opServerError(result.error);
608
+ }
609
+ return {
610
+ ok: true,
611
+ moviePath: toStoryRef(result.outputPath)
612
+ };
613
+ } catch (err) {
614
+ genError = errorText(err);
615
+ return opServerError(genError);
616
+ } finally {
617
+ inFlightMovies.delete(absoluteFilePath);
618
+ publishGeneration(chatSessionId, "movie", filePath, "", true, genError);
619
+ }
620
+ }
621
+ function triggerAutoBackgroundMovie(absoluteFilePath, wireFilePath, chatSessionId) {
622
+ if (inFlightMovies.has(absoluteFilePath)) return;
623
+ inFlightMovies.add(absoluteFilePath);
624
+ runBackgroundMovieGeneration(absoluteFilePath, wireFilePath, chatSessionId);
625
+ }
626
+ async function runBackgroundMovieGeneration(absoluteFilePath, wireFilePath, chatSessionId) {
627
+ const errorSidecarPath = `${absoluteFilePath}.error.txt`;
628
+ try {
629
+ unlinkSync(errorSidecarPath);
630
+ } catch {}
631
+ publishGeneration(chatSessionId, "movie", wireFilePath, "", false);
632
+ let genError;
633
+ try {
634
+ const result = await runMovieGeneration(absoluteFilePath, (event) => {
635
+ const eventKind = event.kind === "image" ? "beatImage" : "beatAudio";
636
+ const key = String(event.beatIndex);
637
+ publishGeneration(chatSessionId, eventKind, wireFilePath, key, false);
638
+ setImmediate(() => publishGeneration(chatSessionId, eventKind, wireFilePath, key, true));
639
+ });
640
+ if (!result.ok) {
641
+ genError = result.error;
642
+ await writeErrorSidecar(errorSidecarPath, result.error);
643
+ log.warn("background movie generation failed", {
644
+ filePath: wireFilePath,
645
+ error: result.error
646
+ });
647
+ return;
648
+ }
649
+ log.info("background movie generation done", {
650
+ filePath: wireFilePath,
651
+ outputPath: result.outputPath
652
+ });
653
+ } catch (err) {
654
+ genError = errorText(err);
655
+ await writeErrorSidecar(errorSidecarPath, genError);
656
+ log.error("background movie generation crashed", {
657
+ filePath: wireFilePath,
658
+ error: genError
659
+ });
660
+ } finally {
661
+ inFlightMovies.delete(absoluteFilePath);
662
+ publishGeneration(chatSessionId, "movie", wireFilePath, "", true, genError);
663
+ }
664
+ }
665
+ async function writeErrorSidecar(errorSidecarPath, message) {
666
+ try {
667
+ await backend.writeFileAtomic(errorSidecarPath, message);
668
+ } catch (writeErr) {
669
+ log.error("failed to write error sidecar", {
670
+ errorSidecarPath,
671
+ error: errorText(writeErr)
672
+ });
673
+ }
674
+ }
675
+ async function runPdfGeneration(context, onImageBeatDone) {
676
+ return withMulmoErrorCapture(() => runPdfPipeline(context, onImageBeatDone));
677
+ }
678
+ async function runPdfPipeline(context, onImageBeatDone) {
679
+ const idToIndex = buildBeatIdIndex(context.studio.script.beats);
680
+ const onProgress = (event) => {
681
+ if (event.kind !== "beat" || event.inSession || event.id === void 0) return;
682
+ const beatIndex = idToIndex.get(event.id);
683
+ if (beatIndex === void 0) return;
684
+ if (event.sessionType !== "image") return;
685
+ onImageBeatDone(beatIndex);
686
+ };
687
+ addSessionProgressCallback(onProgress);
688
+ try {
689
+ const imagesContext = await images(context);
690
+ await pdf(imagesContext, PDF_MODE, "a4");
691
+ const outputPath = pdfFilePath(imagesContext, PDF_MODE);
692
+ if (!existsSync(outputPath)) return {
693
+ ok: false,
694
+ error: "PDF was not generated"
695
+ };
696
+ return {
697
+ ok: true,
698
+ outputPath
699
+ };
700
+ } finally {
701
+ removeSessionProgressCallback(onProgress);
702
+ }
703
+ }
704
+ /** Long-held foreground PDF generation (the package View's `generatePdf`
705
+ * dispatch) — the PDF sibling of `generateMovieOp`. */
706
+ async function generatePdfOp(filePath, chatSessionId) {
707
+ const ffmpeg = ffmpegGuard();
708
+ if (ffmpeg) return ffmpeg;
709
+ const resolved = resolveStory(filePath);
710
+ if (!resolved.ok) return resolved;
711
+ const absoluteFilePath = resolved.absolutePath;
712
+ if (inFlightPdfs.has(absoluteFilePath)) return opBadRequest("PDF generation is already in progress for this script");
713
+ inFlightPdfs.add(absoluteFilePath);
714
+ publishGeneration(chatSessionId, "pdf", filePath, "", false);
715
+ let genError;
716
+ try {
717
+ const context = await buildContext(absoluteFilePath);
718
+ if (!context) {
719
+ genError = "Failed to initialize mulmo context";
720
+ return opServerError(genError);
721
+ }
722
+ const result = await runPdfGeneration(context, (beatIndex) => {
723
+ publishGeneration(chatSessionId, "beatImage", filePath, String(beatIndex), true);
724
+ });
725
+ if (!result.ok) {
726
+ genError = result.error;
727
+ return opServerError(result.error);
728
+ }
729
+ return {
730
+ ok: true,
731
+ pdfPath: toStoryRef(result.outputPath)
732
+ };
733
+ } catch (err) {
734
+ genError = errorText(err);
735
+ return opServerError(genError);
736
+ } finally {
737
+ inFlightPdfs.delete(absoluteFilePath);
738
+ publishGeneration(chatSessionId, "pdf", filePath, "", true, genError);
739
+ }
740
+ }
741
+ return {
742
+ backend,
743
+ toStoryRef,
744
+ resolveStory,
745
+ guardStoryWirePath,
746
+ ffmpegGuard,
747
+ runStoryOp,
748
+ publishGeneration,
749
+ pendingGenerations,
750
+ beatImageOp,
751
+ beatAudioOp,
752
+ beatMovieOp,
753
+ characterImageOp,
754
+ movieStatusOp,
755
+ pdfStatusOp,
756
+ renderBeatOp,
757
+ generateBeatAudioOp,
758
+ renderCharacterOp,
759
+ uploadBeatImageOp,
760
+ uploadCharacterImageOp,
761
+ inFlightMovies,
762
+ inFlightPdfs,
763
+ runMovieGeneration,
764
+ runPdfGeneration,
765
+ generateMovieOp,
766
+ generatePdfOp,
767
+ triggerAutoBackgroundMovie
768
+ };
769
+ }
770
+ //#endregion
771
+ //#region src/server/dispatch.ts
772
+ function fromOpFailure(failure) {
773
+ return {
774
+ ok: false,
775
+ code: failure.code === "unavailable" ? "server_error" : failure.code,
776
+ error: failure.error
777
+ };
778
+ }
779
+ function fromPackageFailure(failure) {
780
+ return {
781
+ ok: false,
782
+ code: failure.code,
783
+ error: failure.error
784
+ };
785
+ }
786
+ function invalidArgs(kind) {
787
+ return {
788
+ ok: false,
789
+ code: "bad_request",
790
+ error: `invalid arguments for mulmoScript dispatch kind "${kind}"`
791
+ };
792
+ }
793
+ function str(value) {
794
+ return typeof value === "string" && value !== "" ? value : void 0;
795
+ }
796
+ function num(value) {
797
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : void 0;
798
+ }
799
+ /** Pass ok results through untouched; normalize failures for the wire. */
800
+ function envelope(result) {
801
+ return result.ok ? result : fromOpFailure(result);
802
+ }
803
+ function beatArgs(args) {
804
+ const filePath = str(args.filePath);
805
+ const beatIndex = num(args.beatIndex);
806
+ if (!filePath || beatIndex === void 0) return null;
807
+ return {
808
+ filePath,
809
+ beatIndex
810
+ };
811
+ }
812
+ function keyArgs(args) {
813
+ const filePath = str(args.filePath);
814
+ const key = str(args.key);
815
+ if (!filePath || !key) return null;
816
+ return {
817
+ filePath,
818
+ key
819
+ };
820
+ }
821
+ var PROBE_KINDS = /* @__PURE__ */ new Set([
822
+ "beatImage",
823
+ "beatAudio",
824
+ "beatMovie",
825
+ "characterImage",
826
+ "movieStatus",
827
+ "pdfStatus"
828
+ ]);
829
+ var GENERATE_KINDS = /* @__PURE__ */ new Set([
830
+ "renderBeat",
831
+ "generateBeatAudio",
832
+ "renderCharacter",
833
+ "generateMovie",
834
+ "generatePdf"
835
+ ]);
836
+ var UPLOAD_KINDS = /* @__PURE__ */ new Set(["uploadBeatImage", "uploadCharacterImage"]);
837
+ /**
838
+ * Build the kind router over an ops instance. The save / reopen / update
839
+ * kinds run the phase-1 core executes against the backend's artifacts
840
+ * FileOps, guarded by the instance's realpath containment
841
+ * (`guardStoryWirePath`) — the core's own guard is lexical.
842
+ */
843
+ function createMulmoScriptDispatchHandler(ops) {
844
+ const executeContext = { files: { artifacts: ops.backend.artifacts } };
845
+ async function saveKind(args) {
846
+ const guard = ops.guardStoryWirePath(args.filePath);
847
+ if (guard) return fromOpFailure(guard);
848
+ const outcome = await executeMulmoScriptSave(executeContext, {
849
+ script: args.script,
850
+ filename: str(args.filename),
851
+ filePath: str(args.filePath)
852
+ });
853
+ if (!outcome.ok) return fromPackageFailure(outcome);
854
+ return {
855
+ ok: true,
856
+ script: outcome.script,
857
+ filePath: outcome.filePath,
858
+ message: outcome.message
859
+ };
860
+ }
861
+ async function updateKind(kind, args) {
862
+ const guard = ops.guardStoryWirePath(args.filePath);
863
+ if (guard) return fromOpFailure(guard);
864
+ const outcome = kind === "updateBeat" ? await executeUpdateBeat(executeContext, args) : await executeUpdateScript(executeContext, args);
865
+ return outcome.ok ? { ok: true } : fromPackageFailure(outcome);
866
+ }
867
+ const STATUS_OPS = {
868
+ movieStatus: ops.movieStatusOp,
869
+ pdfStatus: ops.pdfStatusOp
870
+ };
871
+ const BEAT_PROBE_OPS = {
872
+ beatImage: ops.beatImageOp,
873
+ beatAudio: ops.beatAudioOp,
874
+ beatMovie: ops.beatMovieOp
875
+ };
876
+ async function probeKind(kind, args) {
877
+ const statusOp = STATUS_OPS[kind];
878
+ if (statusOp) {
879
+ const filePath = str(args.filePath);
880
+ return filePath ? envelope(await statusOp(filePath)) : invalidArgs(kind);
881
+ }
882
+ if (kind === "characterImage") {
883
+ const parsed = keyArgs(args);
884
+ return parsed ? envelope(await ops.characterImageOp(parsed.filePath, parsed.key)) : invalidArgs(kind);
885
+ }
886
+ const parsed = beatArgs(args);
887
+ if (!parsed) return invalidArgs(kind);
888
+ return envelope(await BEAT_PROBE_OPS[kind](parsed.filePath, parsed.beatIndex));
889
+ }
890
+ async function generateKind(kind, args) {
891
+ const chatSessionId = str(args.chatSessionId);
892
+ const force = args.force === true;
893
+ if (kind === "generateMovie" || kind === "generatePdf") {
894
+ const filePath = str(args.filePath);
895
+ if (!filePath) return invalidArgs(kind);
896
+ return envelope(kind === "generateMovie" ? await ops.generateMovieOp(filePath, chatSessionId) : await ops.generatePdfOp(filePath, chatSessionId));
897
+ }
898
+ if (kind === "renderCharacter") {
899
+ const parsed = keyArgs(args);
900
+ return parsed ? envelope(await ops.renderCharacterOp({
901
+ ...parsed,
902
+ force,
903
+ chatSessionId
904
+ })) : invalidArgs(kind);
905
+ }
906
+ const parsed = beatArgs(args);
907
+ if (!parsed) return invalidArgs(kind);
908
+ return envelope(kind === "renderBeat" ? await ops.renderBeatOp({
909
+ ...parsed,
910
+ force,
911
+ chatSessionId
912
+ }) : await ops.generateBeatAudioOp({
913
+ ...parsed,
914
+ force,
915
+ chatSessionId
916
+ }));
917
+ }
918
+ async function uploadKind(kind, args) {
919
+ const imageData = str(args.imageData);
920
+ if (!imageData) return invalidArgs(kind);
921
+ if (kind === "uploadCharacterImage") {
922
+ const parsed = keyArgs(args);
923
+ return parsed ? envelope(await ops.uploadCharacterImageOp(parsed.filePath, parsed.key, imageData)) : invalidArgs(kind);
924
+ }
925
+ const parsed = beatArgs(args);
926
+ if (!parsed) return invalidArgs(kind);
927
+ return envelope(await ops.uploadBeatImageOp(parsed.filePath, parsed.beatIndex, imageData));
928
+ }
929
+ return async (args) => {
930
+ const kind = str(args.kind);
931
+ if (!kind) return invalidArgs("<missing>");
932
+ if (kind === "save") return saveKind(args);
933
+ if (kind === "updateBeat" || kind === "updateScript") return updateKind(kind, args);
934
+ if (PROBE_KINDS.has(kind)) return probeKind(kind, args);
935
+ if (GENERATE_KINDS.has(kind)) return generateKind(kind, args);
936
+ if (UPLOAD_KINDS.has(kind)) return uploadKind(kind, args);
937
+ if (kind === "pendingGenerations") {
938
+ const filePath = str(args.filePath);
939
+ if (!filePath) return invalidArgs(kind);
940
+ return {
941
+ ok: true,
942
+ pending: ops.pendingGenerations(filePath)
943
+ };
944
+ }
945
+ return {
946
+ ok: false,
947
+ code: "bad_request",
948
+ error: `unknown mulmoScript dispatch kind "${kind}"`
949
+ };
950
+ };
951
+ }
952
+ //#endregion
953
+ export { GENERATION_EVENT, PDF_MODE, PDF_SIZE, buildBeatIdIndex, buildContext, composeMulmoErrorMessage, createMulmoScriptDispatchHandler, createMulmoScriptServerOps, describeMulmoCause, enableGraphAIErrorCapture, executeMulmoScriptSave, executeUpdateBeat, executeUpdateScript, withMulmoErrorCapture };
954
+
955
+ //# sourceMappingURL=server.js.map