reframe-video 0.6.17 → 0.6.19

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/README.md CHANGED
@@ -22,6 +22,7 @@ npx reframe-video render hello.ts # → out/hello.mp4
22
22
  |---|---|
23
23
  | `reframe render <scene.ts> [--overlay edits.json] [-o out.mp4]` | deterministic mp4 |
24
24
  | `reframe batch <scene.ts> <data.json\|csv>` | one mp4 per data row (row keys are overlay addresses) |
25
+ | `reframe compile <scene.ts> [-o out.json] [--json]` | bundle + validate a scene to SceneIR JSON, no render (fast; no ffmpeg/chromium) |
25
26
  | `reframe preview` | scrub/play/edit UI for scenes in the current directory; edits export as overlay JSON |
26
27
  | `reframe new <name>` | scaffold a documented starter scene |
27
28
  | `reframe motion <mp4>` | calibrated motion profile of a rendered clip |
@@ -63,6 +64,50 @@ Audio is label-anchored (`audio: { cues: [{ at: "enter", sfx: "whoosh" }] }`)
63
64
  so sound design follows retiming and regeneration. Full syntax:
64
65
  `npx reframe-video guide`.
65
66
 
67
+ ## Rendering to a canvas (live preview)
68
+
69
+ The same renderer that produces the mp4 is exported as a subpath for drawing
70
+ frames to a 2D canvas in the browser — so an editor or preview can render a
71
+ scene live and match the export. Compile once, draw any time `t`:
72
+
73
+ ```ts
74
+ import { compileScene, evaluate } from "reframe-video";
75
+ import { renderFrame, drawDisplayList } from "reframe-video/renderer";
76
+
77
+ const compiled = compileScene(myScene); // myScene: SceneIR
78
+ const ctx = canvas.getContext("2d")!;
79
+
80
+ renderFrame(ctx, compiled, t); // clears + paints the frame at time t
81
+ // or drive the DisplayList yourself (you own the clear/background):
82
+ drawDisplayList(ctx, evaluate(compiled, t));
83
+ ```
84
+
85
+ Camera, clips, track mattes, group effects, gradients and text are handled
86
+ exactly like the mp4 path — `renderFrame` bakes the scene camera in, so don't
87
+ apply one yourself. Images and video need registries: pass `images`
88
+ (`{ get(src) }`) and `videos` (`{ frame(src, i) }`) returning decoded
89
+ `CanvasImageSource`s. The default entry (scene authoring + `compileScene` /
90
+ `evaluate`) is unchanged.
91
+
92
+ ## Compiling source to IR in-process (server)
93
+
94
+ For a backend that has an LLM author eDSL source and needs the **SceneIR** back
95
+ (to preview, diff, or self-correct) without rendering, the loader is exported as
96
+ a Node-only subpath:
97
+
98
+ ```ts
99
+ import { loadSceneFromCode, loadScene, SceneLoadError } from "reframe-video/compile";
100
+
101
+ const ir = await loadSceneFromCode(generatedSource); // bundle + validate, no ffmpeg/chromium
102
+ // errors are classified + sanitized (no base64 bundle dump):
103
+ try { await loadSceneFromCode(badSource); }
104
+ catch (e) { if (e instanceof SceneLoadError) console.log(e.kind, e.message); } // "eval" | "bundle" | "validation"
105
+ ```
106
+
107
+ This runs the scene module in-process — bound untrusted/model-authored source
108
+ (a timeout) and run it where it can't do harm. The same thing on the CLI is
109
+ `reframe compile … --json`.
110
+
66
111
  ## Why this instead of generating Remotion/HTML?
67
112
 
68
113
  One-shot generation quality is a wash (we measured it). The difference is the
package/dist/bin.js CHANGED
@@ -578,6 +578,15 @@ function validateScene(ir) {
578
578
  if (cue.gain !== void 0 && cue.gain < 0) {
579
579
  problems.push(`audio.cues[${i}]: gain must be >= 0`);
580
580
  }
581
+ if (cue.fadeIn !== void 0 && cue.fadeIn < 0) {
582
+ problems.push(`audio.cues[${i}]: fadeIn must be >= 0`);
583
+ }
584
+ if (cue.fadeOut !== void 0 && cue.fadeOut < 0) {
585
+ problems.push(`audio.cues[${i}]: fadeOut must be >= 0`);
586
+ }
587
+ if (cue.pan !== void 0 && (cue.pan < -1 || cue.pan > 1)) {
588
+ problems.push(`audio.cues[${i}]: pan must be in [-1, 1] (-1 left \u2026 +1 right)`);
589
+ }
581
590
  }
582
591
  const duck = ir.audio?.bgm?.duck;
583
592
  if (typeof duck === "object" && duck !== null && duck.depth !== void 0 && (duck.depth < 0 || duck.depth > 1)) {
@@ -644,7 +653,7 @@ var init_validate = __esm({
644
653
  line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress", ...FX_PROPS],
645
654
  text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "prefix", "suffix", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
646
655
  image: [...COMMON_PROPS, "src", "width", "height", "fit"],
647
- video: [...COMMON_PROPS, "src", "width", "height", "fit", "start", "rate", "clipStart", "volume"],
656
+ video: [...COMMON_PROPS, "src", "width", "height", "fit", "start", "rate", "clipStart", "volume", "fadeIn", "pan"],
648
657
  path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
649
658
  group: COMMON_PROPS
650
659
  };
@@ -1251,7 +1260,7 @@ function collectClipAudio(ir, duration, warnings) {
1251
1260
  warnings.push(`video "${node.id}": start ${start.toFixed(2)}s past the scene end \u2014 audio dropped`);
1252
1261
  continue;
1253
1262
  }
1254
- out.push({ nodeId: node.id, src: node.props.src, start, rate: node.props.rate ?? 1, clipStart: node.props.clipStart ?? 0, gain });
1263
+ out.push({ nodeId: node.id, src: node.props.src, start, rate: node.props.rate ?? 1, clipStart: node.props.clipStart ?? 0, gain, fadeIn: node.props.fadeIn ?? 0, pan: node.props.pan ?? 0 });
1255
1264
  }
1256
1265
  if (node.type === "group") walk(node.children);
1257
1266
  }
@@ -1293,6 +1302,9 @@ function resolveAudioPlan(compiled) {
1293
1302
  t,
1294
1303
  gain: cue.gain ?? 1,
1295
1304
  duration: cueDuration,
1305
+ fadeIn: cue.fadeIn ?? 0,
1306
+ fadeOut: cue.fadeOut ?? 0,
1307
+ pan: cue.pan ?? 0,
1296
1308
  source: cue.sfx ? { kind: "sfx", name: cue.sfx, params: cue.params ?? {} } : { kind: "file", path: cue.file }
1297
1309
  });
1298
1310
  }
@@ -1985,6 +1997,10 @@ function atempoChain(rate) {
1985
1997
  out.push(`atempo=${r.toFixed(4)}`);
1986
1998
  return out;
1987
1999
  }
2000
+ function panFilter(pan) {
2001
+ const clamp = (v) => Math.max(0, Math.min(1, v)).toFixed(4);
2002
+ return `pan=stereo|c0=${clamp(1 - pan)}*c0|c1=${clamp(1 + pan)}*c1`;
2003
+ }
1988
2004
  function buildFilterGraph(plan, inputs) {
1989
2005
  const lines = [];
1990
2006
  const mixIn = ["[anchor]"];
@@ -2013,7 +2029,14 @@ function buildFilterGraph(plan, inputs) {
2013
2029
  }
2014
2030
  plan.cues.forEach((cue, i) => {
2015
2031
  const delayMs = Math.round(cue.t * 1e3);
2016
- lines.push(`[${inputIndex}:a]${FORMAT},volume=${cue.gain},adelay=${delayMs}:all=1[c${i}]`);
2032
+ const chain = [FORMAT, `volume=${cue.gain}`];
2033
+ if (cue.fadeIn > 0) chain.push(`afade=t=in:st=0:d=${cue.fadeIn}`);
2034
+ if (cue.fadeOut > 0) {
2035
+ chain.push(`afade=t=out:st=${Math.max(0, cue.duration - cue.fadeOut).toFixed(3)}:d=${cue.fadeOut}`);
2036
+ }
2037
+ if (cue.pan !== 0) chain.push(panFilter(cue.pan));
2038
+ chain.push(`adelay=${delayMs}:all=1`);
2039
+ lines.push(`[${inputIndex}:a]${chain.join(",")}[c${i}]`);
2017
2040
  mixIn.push(`[c${i}]`);
2018
2041
  inputIndex++;
2019
2042
  });
@@ -2021,6 +2044,8 @@ function buildFilterGraph(plan, inputs) {
2021
2044
  const chain = [];
2022
2045
  if (audio.clipStart > 0) chain.push(`atrim=start=${audio.clipStart.toFixed(3)}`, "asetpts=PTS-STARTPTS");
2023
2046
  chain.push(...atempoChain(audio.rate), FORMAT, `volume=${audio.gain}`);
2047
+ if (audio.fadeIn > 0) chain.push(`afade=t=in:st=0:d=${audio.fadeIn}`);
2048
+ if (audio.pan !== 0) chain.push(panFilter(audio.pan));
2024
2049
  const delayMs = Math.round(audio.start * 1e3);
2025
2050
  if (delayMs > 0) chain.push(`adelay=${delayMs}:all=1`);
2026
2051
  lines.push(`[${inputIndex}:a]${chain.join(",")}[k${i}]`);
@@ -2455,13 +2480,13 @@ async function captureIr(ir, opts) {
2455
2480
  const assets = await buildImageAssets(ir, sceneDir);
2456
2481
  const { fps, duration } = resolveTiming(ir, opts);
2457
2482
  const videoAssets = await buildVideoFrameAssets(ir, sceneDir, fps, duration);
2458
- const bundle = await browserBundle();
2483
+ const bundle2 = await browserBundle();
2459
2484
  return withPage(ir.size, async (page) => {
2460
2485
  await page.setContent(
2461
2486
  `<!DOCTYPE html><html><body style="margin:0;background:#000"></body></html>`
2462
2487
  );
2463
2488
  await injectFonts(page);
2464
- await page.addScriptTag({ content: bundle });
2489
+ await page.addScriptTag({ content: bundle2 });
2465
2490
  await page.evaluate(
2466
2491
  ([sceneIr, imageAssets, vAssets]) => window.__reframe.init(sceneIr, imageAssets, vAssets),
2467
2492
  [ir, assets, videoAssets]
@@ -2654,66 +2679,108 @@ var init_batch = __esm({
2654
2679
  // ../render-cli/src/loadScene.ts
2655
2680
  var loadScene_exports = {};
2656
2681
  __export(loadScene_exports, {
2682
+ SceneLoadError: () => SceneLoadError,
2657
2683
  isComposition: () => isComposition,
2658
2684
  loadModule: () => loadModule,
2659
- loadScene: () => loadScene
2685
+ loadScene: () => loadScene,
2686
+ loadSceneFromCode: () => loadSceneFromCode
2660
2687
  });
2661
2688
  import { build as build2 } from "esbuild";
2662
2689
  import { readFile as readFile6 } from "node:fs/promises";
2663
2690
  import { dirname as dirname6, resolve as resolve5 } from "node:path";
2664
2691
  import { fileURLToPath as fileURLToPath4 } from "node:url";
2665
- async function loadDefault(path2) {
2666
- if (path2.endsWith(".json")) return JSON.parse(await readFile6(path2, "utf8"));
2667
- let code;
2692
+ async function bundle(input) {
2693
+ const common = {
2694
+ bundle: true,
2695
+ format: "esm",
2696
+ platform: "neutral",
2697
+ write: false,
2698
+ logLevel: "silent",
2699
+ sourcemap: "inline",
2700
+ alias: ALIAS
2701
+ };
2668
2702
  try {
2669
- const out = await build2({
2670
- entryPoints: [path2],
2671
- bundle: true,
2672
- format: "esm",
2673
- platform: "neutral",
2674
- write: false,
2675
- logLevel: "silent",
2676
- sourcemap: "inline",
2677
- // both specifiers accepted: the guide's canonical "@reframe/core" and
2678
- // the published package name
2679
- alias: { "@reframe/core": CORE_ENTRY, "reframe-video": CORE_ENTRY }
2680
- });
2681
- code = out.outputFiles[0].text;
2703
+ const out = await build2(
2704
+ "path" in input ? { ...common, entryPoints: [input.path] } : { ...common, stdin: { contents: input.code, resolveDir: input.resolveDir, loader: "ts", sourcefile: "scene.ts" } }
2705
+ );
2706
+ return out.outputFiles[0].text;
2682
2707
  } catch (err) {
2683
- throw new Error(`failed to bundle ${path2}:
2684
- ${err instanceof Error ? err.message : String(err)}`);
2708
+ throw new SceneLoadError("bundle", clean(err), { cause: err });
2685
2709
  }
2686
- const mod = await import(`data:text/javascript;base64,${Buffer.from(code).toString("base64")}`);
2687
- if (mod.default === void 0) throw new Error(`${path2} must default-export a scene or composition`);
2710
+ }
2711
+ async function importDefault(code, label) {
2712
+ let mod;
2713
+ try {
2714
+ mod = await import(`data:text/javascript;base64,${Buffer.from(code).toString("base64")}`);
2715
+ } catch (err) {
2716
+ const kind = err instanceof Error && err.name === "SceneValidationError" ? "validation" : "eval";
2717
+ throw new SceneLoadError(kind, clean(err), { cause: err });
2718
+ }
2719
+ if (mod.default === void 0) throw new SceneLoadError("eval", `${label} must default-export a scene or composition`);
2688
2720
  return mod.default;
2689
2721
  }
2722
+ async function loadDefault(path2) {
2723
+ if (path2.endsWith(".json")) {
2724
+ try {
2725
+ return JSON.parse(await readFile6(path2, "utf8"));
2726
+ } catch (err) {
2727
+ throw new SceneLoadError("eval", `failed to read ${path2}: ${clean(err)}`, { cause: err });
2728
+ }
2729
+ }
2730
+ return importDefault(await bundle({ path: path2 }), path2);
2731
+ }
2690
2732
  function isComposition(def) {
2691
2733
  return typeof def === "object" && def !== null && Array.isArray(def.scenes);
2692
2734
  }
2693
- async function loadScene(path2) {
2694
- const def = await loadDefault(path2);
2735
+ function asScene(def, label) {
2695
2736
  if (isComposition(def)) {
2696
- throw new Error(`${path2} is a composition \u2014 render it directly, not as a single scene`);
2737
+ throw new SceneLoadError("validation", `${label} is a composition \u2014 render it directly, not as a single scene`);
2738
+ }
2739
+ try {
2740
+ validateScene(def);
2741
+ } catch (err) {
2742
+ throw new SceneLoadError("validation", clean(err), { cause: err });
2697
2743
  }
2698
- validateScene(def);
2699
2744
  return def;
2700
2745
  }
2746
+ async function loadScene(path2) {
2747
+ return asScene(await loadDefault(path2), path2);
2748
+ }
2749
+ async function loadSceneFromCode(code, resolveDir = process.cwd()) {
2750
+ return asScene(await importDefault(await bundle({ code, resolveDir }), "<source>"), "<source>");
2751
+ }
2701
2752
  async function loadModule(path2) {
2702
2753
  const def = await loadDefault(path2);
2703
2754
  if (isComposition(def)) {
2704
- validateComposition(def);
2755
+ try {
2756
+ validateComposition(def);
2757
+ } catch (err) {
2758
+ throw new SceneLoadError("validation", clean(err), { cause: err });
2759
+ }
2705
2760
  return { kind: "composition", ir: def };
2706
2761
  }
2707
- validateScene(def);
2708
- return { kind: "scene", ir: def };
2762
+ return { kind: "scene", ir: asScene(def, path2) };
2709
2763
  }
2710
- var HERE, CORE_ENTRY;
2764
+ var HERE, CORE_ENTRY, SceneLoadError, clean, ALIAS;
2711
2765
  var init_loadScene = __esm({
2712
2766
  "../render-cli/src/loadScene.ts"() {
2713
2767
  "use strict";
2714
2768
  init_src();
2715
2769
  HERE = dirname6(fileURLToPath4(import.meta.url));
2716
2770
  CORE_ENTRY = true ? resolve5(HERE, "index.js") : resolve5(HERE, "..", "..", "core", "src", "index.ts");
2771
+ SceneLoadError = class extends Error {
2772
+ kind;
2773
+ constructor(kind, message, options) {
2774
+ super(message, options);
2775
+ this.name = "SceneLoadError";
2776
+ this.kind = kind;
2777
+ }
2778
+ };
2779
+ clean = (err) => (err instanceof Error ? err.message : String(err)).replace(
2780
+ /data:text\/javascript;base64,[A-Za-z0-9+/=]+/g,
2781
+ "<scene bundle>"
2782
+ );
2783
+ ALIAS = { "@reframe/core": CORE_ENTRY, "reframe-video": CORE_ENTRY };
2717
2784
  }
2718
2785
  });
2719
2786
 
@@ -2730,6 +2797,7 @@ var ROOT2 = PACKAGED ? resolve6(HERE2, "..") : resolve6(HERE2, "..", "..", "..")
2730
2797
  var USER_CWD = process.env.INIT_CWD ?? process.cwd();
2731
2798
  var RENDER_CLI = PACKAGED ? join9(ROOT2, "dist", "cli.js") : join9(ROOT2, "packages", "render-cli", "src", "cli.ts");
2732
2799
  var LABELS = PACKAGED ? join9(ROOT2, "dist", "labels.js") : join9(ROOT2, "packages", "render-cli", "src", "labels.ts");
2800
+ var COMPILE = PACKAGED ? join9(ROOT2, "dist", "compile.js") : join9(ROOT2, "packages", "render-cli", "src", "compile.ts");
2733
2801
  var DIFF = PACKAGED ? join9(ROOT2, "dist", "diff.js") : join9(ROOT2, "packages", "render-cli", "src", "diff.ts");
2734
2802
  var PLAYER = PACKAGED ? join9(ROOT2, "dist", "player.js") : join9(ROOT2, "packages", "render-cli", "src", "player.ts");
2735
2803
  var ANALYZE = PACKAGED ? join9(ROOT2, "dist", "analyze.js") : join9(ROOT2, "benchmark", "harness", "motion", "analyze.ts");
@@ -2748,6 +2816,8 @@ usage:
2748
2816
  ${CMD} preview open the scrub/edit UI (lists scenes in your directory)
2749
2817
  ${CMD} new <scene-name> scaffold <scene-name>.ts in your directory
2750
2818
  ${CMD} labels <scene.ts|.json> print the event clock (label \u2192 exact seconds; for sound design / timing)
2819
+ ${CMD} compile <scene.ts|.json> [-o out.json] [--stdin] [--code "<src>"] [--json]
2820
+ bundle + validate a scene to SceneIR JSON, no render (fast; no ffmpeg/chromium)
2751
2821
  ${CMD} motion <mp4|framesDir> motion-profile a rendered clip
2752
2822
  ${CMD} trace <ref.mp4> [--apply scene.ts] extract a video's motion structure \u2192 MotionSketch / timeline
2753
2823
  ${CMD} diff <ref-image> [<scene.ts>] [--t S] [--mode side|blend|diff|grid] compare/measure a render against a reference image
@@ -2896,6 +2966,22 @@ ${USAGE}`);
2896
2966
  await (PACKAGED ? run2(process.execPath, [LABELS, inputPath]) : run2("npx", ["tsx", LABELS, inputPath]))
2897
2967
  );
2898
2968
  }
2969
+ case "compile": {
2970
+ const hasInlineSource = rest.includes("--stdin") || rest.includes("--code");
2971
+ const fileArg = rest.find((a, i) => !a.startsWith("-") && !["-o", "--code", "--timeout"].includes(rest[i - 1] ?? ""));
2972
+ if (!fileArg && !hasInlineSource) fail(`compile needs a scene file, --stdin, or --code "<src>"
2973
+
2974
+ ${USAGE}`);
2975
+ const passed = rest.map((a, i) => {
2976
+ if (a === fileArg) return userPath(a);
2977
+ if (rest[i - 1] === "-o") return userPath(a);
2978
+ return a;
2979
+ });
2980
+ if (fileArg && !existsSync6(userPath(fileArg))) fail(`no such file: ${userPath(fileArg)}`);
2981
+ process.exit(
2982
+ await (PACKAGED ? run2(process.execPath, [COMPILE, ...passed]) : run2("npx", ["tsx", COMPILE, ...passed]))
2983
+ );
2984
+ }
2899
2985
  case "player": {
2900
2986
  const input = rest[0];
2901
2987
  if (!input || input.startsWith("-")) fail(`player needs a scene file
@@ -350,7 +350,7 @@
350
350
  line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress", ...FX_PROPS],
351
351
  text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "prefix", "suffix", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
352
352
  image: [...COMMON_PROPS, "src", "width", "height", "fit"],
353
- video: [...COMMON_PROPS, "src", "width", "height", "fit", "start", "rate", "clipStart", "volume"],
353
+ video: [...COMMON_PROPS, "src", "width", "height", "fit", "start", "rate", "clipStart", "volume", "fadeIn", "pan"],
354
354
  path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
355
355
  group: COMMON_PROPS
356
356
  };
package/dist/cli.js CHANGED
@@ -355,7 +355,7 @@ var PROPS_BY_TYPE = {
355
355
  line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress", ...FX_PROPS],
356
356
  text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "prefix", "suffix", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
357
357
  image: [...COMMON_PROPS, "src", "width", "height", "fit"],
358
- video: [...COMMON_PROPS, "src", "width", "height", "fit", "start", "rate", "clipStart", "volume"],
358
+ video: [...COMMON_PROPS, "src", "width", "height", "fit", "start", "rate", "clipStart", "volume", "fadeIn", "pan"],
359
359
  path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
360
360
  group: COMMON_PROPS
361
361
  };
@@ -592,6 +592,15 @@ function validateScene(ir) {
592
592
  if (cue.gain !== void 0 && cue.gain < 0) {
593
593
  problems.push(`audio.cues[${i}]: gain must be >= 0`);
594
594
  }
595
+ if (cue.fadeIn !== void 0 && cue.fadeIn < 0) {
596
+ problems.push(`audio.cues[${i}]: fadeIn must be >= 0`);
597
+ }
598
+ if (cue.fadeOut !== void 0 && cue.fadeOut < 0) {
599
+ problems.push(`audio.cues[${i}]: fadeOut must be >= 0`);
600
+ }
601
+ if (cue.pan !== void 0 && (cue.pan < -1 || cue.pan > 1)) {
602
+ problems.push(`audio.cues[${i}]: pan must be in [-1, 1] (-1 left \u2026 +1 right)`);
603
+ }
595
604
  }
596
605
  const duck = ir.audio?.bgm?.duck;
597
606
  if (typeof duck === "object" && duck !== null && duck.depth !== void 0 && (duck.depth < 0 || duck.depth > 1)) {
@@ -919,7 +928,7 @@ function collectClipAudio(ir, duration, warnings) {
919
928
  warnings.push(`video "${node.id}": start ${start.toFixed(2)}s past the scene end \u2014 audio dropped`);
920
929
  continue;
921
930
  }
922
- out.push({ nodeId: node.id, src: node.props.src, start, rate: node.props.rate ?? 1, clipStart: node.props.clipStart ?? 0, gain });
931
+ out.push({ nodeId: node.id, src: node.props.src, start, rate: node.props.rate ?? 1, clipStart: node.props.clipStart ?? 0, gain, fadeIn: node.props.fadeIn ?? 0, pan: node.props.pan ?? 0 });
923
932
  }
924
933
  if (node.type === "group") walk(node.children);
925
934
  }
@@ -961,6 +970,9 @@ function resolveAudioPlan(compiled) {
961
970
  t,
962
971
  gain: cue.gain ?? 1,
963
972
  duration: cueDuration,
973
+ fadeIn: cue.fadeIn ?? 0,
974
+ fadeOut: cue.fadeOut ?? 0,
975
+ pan: cue.pan ?? 0,
964
976
  source: cue.sfx ? { kind: "sfx", name: cue.sfx, params: cue.params ?? {} } : { kind: "file", path: cue.file }
965
977
  });
966
978
  }
@@ -1038,6 +1050,9 @@ function resolveCompositionAudioPlan(comp) {
1038
1050
  t,
1039
1051
  gain: cue.gain ?? 1,
1040
1052
  duration: cueDuration,
1053
+ fadeIn: cue.fadeIn ?? 0,
1054
+ fadeOut: cue.fadeOut ?? 0,
1055
+ pan: cue.pan ?? 0,
1041
1056
  source: cue.sfx ? { kind: "sfx", name: cue.sfx, params: cue.params ?? {} } : { kind: "file", path: cue.file }
1042
1057
  });
1043
1058
  }
@@ -1457,6 +1472,10 @@ function atempoChain(rate) {
1457
1472
  out.push(`atempo=${r.toFixed(4)}`);
1458
1473
  return out;
1459
1474
  }
1475
+ function panFilter(pan) {
1476
+ const clamp = (v) => Math.max(0, Math.min(1, v)).toFixed(4);
1477
+ return `pan=stereo|c0=${clamp(1 - pan)}*c0|c1=${clamp(1 + pan)}*c1`;
1478
+ }
1460
1479
  function buildFilterGraph(plan, inputs) {
1461
1480
  const lines = [];
1462
1481
  const mixIn = ["[anchor]"];
@@ -1485,7 +1504,14 @@ function buildFilterGraph(plan, inputs) {
1485
1504
  }
1486
1505
  plan.cues.forEach((cue, i) => {
1487
1506
  const delayMs = Math.round(cue.t * 1e3);
1488
- lines.push(`[${inputIndex}:a]${FORMAT},volume=${cue.gain},adelay=${delayMs}:all=1[c${i}]`);
1507
+ const chain = [FORMAT, `volume=${cue.gain}`];
1508
+ if (cue.fadeIn > 0) chain.push(`afade=t=in:st=0:d=${cue.fadeIn}`);
1509
+ if (cue.fadeOut > 0) {
1510
+ chain.push(`afade=t=out:st=${Math.max(0, cue.duration - cue.fadeOut).toFixed(3)}:d=${cue.fadeOut}`);
1511
+ }
1512
+ if (cue.pan !== 0) chain.push(panFilter(cue.pan));
1513
+ chain.push(`adelay=${delayMs}:all=1`);
1514
+ lines.push(`[${inputIndex}:a]${chain.join(",")}[c${i}]`);
1489
1515
  mixIn.push(`[c${i}]`);
1490
1516
  inputIndex++;
1491
1517
  });
@@ -1493,6 +1519,8 @@ function buildFilterGraph(plan, inputs) {
1493
1519
  const chain = [];
1494
1520
  if (audio.clipStart > 0) chain.push(`atrim=start=${audio.clipStart.toFixed(3)}`, "asetpts=PTS-STARTPTS");
1495
1521
  chain.push(...atempoChain(audio.rate), FORMAT, `volume=${audio.gain}`);
1522
+ if (audio.fadeIn > 0) chain.push(`afade=t=in:st=0:d=${audio.fadeIn}`);
1523
+ if (audio.pan !== 0) chain.push(panFilter(audio.pan));
1496
1524
  const delayMs = Math.round(audio.start * 1e3);
1497
1525
  if (delayMs > 0) chain.push(`adelay=${delayMs}:all=1`);
1498
1526
  lines.push(`[${inputIndex}:a]${chain.join(",")}[k${i}]`);
@@ -1878,13 +1906,13 @@ async function captureIr(ir, opts) {
1878
1906
  const assets = await buildImageAssets(ir, sceneDir);
1879
1907
  const { fps, duration } = resolveTiming(ir, opts);
1880
1908
  const videoAssets = await buildVideoFrameAssets(ir, sceneDir, fps, duration);
1881
- const bundle = await browserBundle();
1909
+ const bundle2 = await browserBundle();
1882
1910
  return withPage(ir.size, async (page) => {
1883
1911
  await page.setContent(
1884
1912
  `<!DOCTYPE html><html><body style="margin:0;background:#000"></body></html>`
1885
1913
  );
1886
1914
  await injectFonts(page);
1887
- await page.addScriptTag({ content: bundle });
1915
+ await page.addScriptTag({ content: bundle2 });
1888
1916
  await page.evaluate(
1889
1917
  ([sceneIr, imageAssets, vAssets]) => window.__reframe.init(sceneIr, imageAssets, vAssets),
1890
1918
  [ir, assets, videoAssets]
@@ -2049,42 +2077,84 @@ import { dirname as dirname6, resolve as resolve5 } from "node:path";
2049
2077
  import { fileURLToPath as fileURLToPath4 } from "node:url";
2050
2078
  var HERE = dirname6(fileURLToPath4(import.meta.url));
2051
2079
  var CORE_ENTRY = true ? resolve5(HERE, "index.js") : resolve5(HERE, "..", "..", "core", "src", "index.ts");
2052
- async function loadDefault(path2) {
2053
- if (path2.endsWith(".json")) return JSON.parse(await readFile4(path2, "utf8"));
2054
- let code;
2080
+ var SceneLoadError = class extends Error {
2081
+ kind;
2082
+ constructor(kind, message, options) {
2083
+ super(message, options);
2084
+ this.name = "SceneLoadError";
2085
+ this.kind = kind;
2086
+ }
2087
+ };
2088
+ var clean = (err) => (err instanceof Error ? err.message : String(err)).replace(
2089
+ /data:text\/javascript;base64,[A-Za-z0-9+/=]+/g,
2090
+ "<scene bundle>"
2091
+ );
2092
+ var ALIAS = { "@reframe/core": CORE_ENTRY, "reframe-video": CORE_ENTRY };
2093
+ async function bundle(input) {
2094
+ const common = {
2095
+ bundle: true,
2096
+ format: "esm",
2097
+ platform: "neutral",
2098
+ write: false,
2099
+ logLevel: "silent",
2100
+ sourcemap: "inline",
2101
+ alias: ALIAS
2102
+ };
2055
2103
  try {
2056
- const out = await build2({
2057
- entryPoints: [path2],
2058
- bundle: true,
2059
- format: "esm",
2060
- platform: "neutral",
2061
- write: false,
2062
- logLevel: "silent",
2063
- sourcemap: "inline",
2064
- // both specifiers accepted: the guide's canonical "@reframe/core" and
2065
- // the published package name
2066
- alias: { "@reframe/core": CORE_ENTRY, "reframe-video": CORE_ENTRY }
2067
- });
2068
- code = out.outputFiles[0].text;
2104
+ const out = await build2(
2105
+ "path" in input ? { ...common, entryPoints: [input.path] } : { ...common, stdin: { contents: input.code, resolveDir: input.resolveDir, loader: "ts", sourcefile: "scene.ts" } }
2106
+ );
2107
+ return out.outputFiles[0].text;
2108
+ } catch (err) {
2109
+ throw new SceneLoadError("bundle", clean(err), { cause: err });
2110
+ }
2111
+ }
2112
+ async function importDefault(code, label) {
2113
+ let mod;
2114
+ try {
2115
+ mod = await import(`data:text/javascript;base64,${Buffer.from(code).toString("base64")}`);
2069
2116
  } catch (err) {
2070
- throw new Error(`failed to bundle ${path2}:
2071
- ${err instanceof Error ? err.message : String(err)}`);
2117
+ const kind = err instanceof Error && err.name === "SceneValidationError" ? "validation" : "eval";
2118
+ throw new SceneLoadError(kind, clean(err), { cause: err });
2072
2119
  }
2073
- const mod = await import(`data:text/javascript;base64,${Buffer.from(code).toString("base64")}`);
2074
- if (mod.default === void 0) throw new Error(`${path2} must default-export a scene or composition`);
2120
+ if (mod.default === void 0) throw new SceneLoadError("eval", `${label} must default-export a scene or composition`);
2075
2121
  return mod.default;
2076
2122
  }
2123
+ async function loadDefault(path2) {
2124
+ if (path2.endsWith(".json")) {
2125
+ try {
2126
+ return JSON.parse(await readFile4(path2, "utf8"));
2127
+ } catch (err) {
2128
+ throw new SceneLoadError("eval", `failed to read ${path2}: ${clean(err)}`, { cause: err });
2129
+ }
2130
+ }
2131
+ return importDefault(await bundle({ path: path2 }), path2);
2132
+ }
2077
2133
  function isComposition(def) {
2078
2134
  return typeof def === "object" && def !== null && Array.isArray(def.scenes);
2079
2135
  }
2136
+ function asScene(def, label) {
2137
+ if (isComposition(def)) {
2138
+ throw new SceneLoadError("validation", `${label} is a composition \u2014 render it directly, not as a single scene`);
2139
+ }
2140
+ try {
2141
+ validateScene(def);
2142
+ } catch (err) {
2143
+ throw new SceneLoadError("validation", clean(err), { cause: err });
2144
+ }
2145
+ return def;
2146
+ }
2080
2147
  async function loadModule(path2) {
2081
2148
  const def = await loadDefault(path2);
2082
2149
  if (isComposition(def)) {
2083
- validateComposition(def);
2150
+ try {
2151
+ validateComposition(def);
2152
+ } catch (err) {
2153
+ throw new SceneLoadError("validation", clean(err), { cause: err });
2154
+ }
2084
2155
  return { kind: "composition", ir: def };
2085
2156
  }
2086
- validateScene(def);
2087
- return { kind: "scene", ir: def };
2157
+ return { kind: "scene", ir: asScene(def, path2) };
2088
2158
  }
2089
2159
 
2090
2160
  // ../render-cli/src/cli.ts
@@ -0,0 +1,6 @@
1
+ import type { CompositionIR, SceneIR } from "./index.js";
2
+ export declare class SceneLoadError extends Error { readonly kind: "bundle" | "eval" | "validation"; }
3
+ export declare function loadScene(path: string): Promise<SceneIR>;
4
+ export declare function loadSceneFromCode(code: string, resolveDir?: string): Promise<SceneIR>;
5
+ export declare function isComposition(def: unknown): def is CompositionIR;
6
+ export declare function loadModule(path: string): Promise<{ kind: "scene"; ir: SceneIR } | { kind: "composition"; ir: CompositionIR }>;