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