@glissade/cli 0.32.0 → 0.33.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/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  `gs` — headless rendering and the dev/capture loop. `gs render` evaluates every frame, rasterizes on Skia (no browser anywhere), writes a PNG sequence or muxes mp4/webm via FFmpeg with sample-accurate audio. v2: `gs dev --record` serves a scene with its state machines mounted and writes input-trace sidecars; `gs render --trace/--state` are the deterministic export routes for interactive scenes.
4
4
 
5
+ The full command set: `render`, `dev`, `import` (Lottie `.json` / `.svg`), `build`, `mcp` (an MCP server over the engine), `describe`, `migrate`, `narrate`, `sfx`, `prepare`, `measure-loudness`, `fonts`, `cache`, `diff`, and `verify-determinism`.
6
+
5
7
  ```sh
6
8
  npm i -D @glissade/cli
7
9
  ```
package/dist/build.js CHANGED
@@ -72,18 +72,55 @@ var build_exports = /* @__PURE__ */ __exportAll({
72
72
  defineProject: () => defineProject,
73
73
  hashInputs: () => hashInputs,
74
74
  loadConfig: () => loadConfig,
75
+ mixDefaults: () => mixDefaults,
75
76
  outputVideo: () => outputVideo,
77
+ renderDefaults: () => renderDefaults,
76
78
  resolveScenes: () => resolveScenes,
77
79
  stepInputs: () => stepInputs,
78
- stepOutput: () => stepOutput
80
+ stepOutput: () => stepOutput,
81
+ stepSalt: () => stepSalt
79
82
  });
83
+ /** The defaults that change a RENDER's output — folded into its staleness hash
84
+ * (a captions/mix-mode flip must re-run render, never serve a stale cached
85
+ * master) and spread into the render call. `cache` is deliberately absent
86
+ * (byte-identical speed knob). */
87
+ function renderDefaults(cfg) {
88
+ const d = cfg.defaults ?? {};
89
+ return {
90
+ ...d.fps !== void 0 ? { fps: d.fps } : {},
91
+ ...d.captions !== void 0 ? { captions: d.captions } : {},
92
+ ...d.narration !== void 0 ? { narration: d.narration } : {},
93
+ ...d.music !== void 0 ? { music: d.music } : {},
94
+ ...d.sfx !== void 0 ? { sfx: d.sfx } : {},
95
+ ...d.loudness !== void 0 ? { loudness: d.loudness } : {}
96
+ };
97
+ }
98
+ /** The mix-mode defaults measure-loudness shares with render (measured mix ==
99
+ * rendered mix, or render's stale-mixHash guard trips). */
100
+ function mixDefaults(cfg) {
101
+ const d = cfg.defaults ?? {};
102
+ return {
103
+ ...d.narration !== void 0 ? { narration: d.narration } : {},
104
+ ...d.music !== void 0 ? { music: d.music } : {},
105
+ ...d.sfx !== void 0 ? { sfx: d.sfx } : {}
106
+ };
107
+ }
108
+ /** Per-step staleness salt: the engine version PLUS any config options that
109
+ * change the step's OUTPUT. Options ride the salt (not stepInputs) because
110
+ * they aren't files; a flipped option changes the hash exactly like an edited
111
+ * input, so the step re-runs instead of serving a stale artifact. */
112
+ function stepSalt(step, cfg, version) {
113
+ if (step === "render") return `${version}\0render:${JSON.stringify(renderDefaults(cfg))}`;
114
+ if (step === "measure-loudness") return `${version}\0mix:${JSON.stringify(mixDefaults(cfg))}`;
115
+ return version;
116
+ }
80
117
  /** Identity helper for a typed `glissade.config.ts` default export. */
81
118
  function defineProject(config) {
82
119
  return config;
83
120
  }
84
121
  async function loadConfig(configPath) {
85
122
  const { createJiti } = await import("jiti");
86
- const cfg = await createJiti(pathToFileURL(`${process.cwd()}/`).href).import(pathToFileURL(resolve(configPath)).href, { default: true });
123
+ const cfg = await createJiti(pathToFileURL(`${process.cwd()}/`).href, { moduleCache: false }).import(pathToFileURL(resolve(configPath)).href, { default: true });
87
124
  if (!cfg || !Array.isArray(cfg.scenes)) throw new Error(`${configPath}: config must default-export { scenes: string[] } — use defineProject({ scenes: [...] })`);
88
125
  return cfg;
89
126
  }
@@ -216,7 +253,7 @@ async function buildCommand(opts, deps = { runStep: defaultRunStep }) {
216
253
  const rec = manifest.scenes[key] ?? {};
217
254
  const videoPath = outputVideo(scene, cfg, root);
218
255
  const plans = planScene(key, applicableSteps(scene), {
219
- currentHash: (step) => hashInputs(stepInputs(scene, step), version),
256
+ currentHash: (step) => hashInputs(stepInputs(scene, step), stepSalt(step, cfg, version)),
220
257
  recordedHash: (step) => rec[step],
221
258
  outputExists: (step) => existsSync(stepOutput(scene, step, videoPath))
222
259
  });
@@ -229,7 +266,7 @@ async function buildCommand(opts, deps = { runStep: defaultRunStep }) {
229
266
  ran++;
230
267
  if (!opts.explain) {
231
268
  await deps.runStep(scene, plan.step, cfg, videoPath);
232
- rec[plan.step] = hashInputs(stepInputs(scene, plan.step), version);
269
+ rec[plan.step] = hashInputs(stepInputs(scene, plan.step), stepSalt(plan.step, cfg, version));
233
270
  }
234
271
  }
235
272
  manifest.scenes[key] = rec;
@@ -258,7 +295,10 @@ async function defaultRunStep(scene, step, cfg, videoPath) {
258
295
  }
259
296
  case "measure-loudness": {
260
297
  const { measureLoudnessCommand } = await import("./loudness.js").then((n) => n.c);
261
- await measureLoudnessCommand({ modulePath: scene });
298
+ await measureLoudnessCommand({
299
+ modulePath: scene,
300
+ ...mixDefaults(cfg)
301
+ });
262
302
  return;
263
303
  }
264
304
  case "render": {
@@ -266,7 +306,7 @@ async function defaultRunStep(scene, step, cfg, videoPath) {
266
306
  await render({
267
307
  modulePath: scene,
268
308
  out: videoPath,
269
- ...cfg.defaults?.fps !== void 0 ? { fps: cfg.defaults.fps } : {},
309
+ ...renderDefaults(cfg),
270
310
  ...cfg.defaults?.cache ? { cache: {
271
311
  dir: cfg.defaults.cache,
272
312
  mode: "read-write"
package/dist/cli.js CHANGED
@@ -56,6 +56,13 @@ function fail(msg) {
56
56
  console.error(`gs: ${msg}`);
57
57
  process.exit(1);
58
58
  }
59
+ /** Validate --fps: a non-positive fps silently renders the WRONG frame
60
+ * (t = frame/0 = Infinity clamps to the timeline end) — fail loud instead. */
61
+ function parseFpsOrFail(raw) {
62
+ const fps = Number(raw);
63
+ if (!Number.isFinite(fps) || fps <= 0) fail(`--fps must be a positive number, got '${raw}'`);
64
+ return fps;
65
+ }
59
66
  function parseCaptionsModeOrFail(raw) {
60
67
  try {
61
68
  return parseCaptionsMode(raw);
@@ -80,6 +87,7 @@ const USAGE = `usage:
80
87
  gs build [filter...] [--config <glissade.config.ts>] [--explain] content-graph DAG runner: narrate→sfx→loudness→render per scene, runs ONLY the stale subtree
81
88
  gs describe [--out <api.json>] [--examples] snapshot THIS engine's describe() API manifest (stdout, or --out to a file) — the input to gs migrate
82
89
  gs migrate <baseline-api.json> [--json] diff a saved API manifest against the current engine: moved imports / removed / added / changed, with a suggested fix per breaking item (advisory; never rewrites your files)
90
+ gs --version print the engine version
83
91
 
84
92
  render options:
85
93
  --out <path> output directory for a PNG sequence, or .mp4/.webm (needs ffmpeg). default: ./out
@@ -170,6 +178,11 @@ narration-lint options (lint the committed *.narration.timing.json + the real ca
170
178
  `;
171
179
  async function main() {
172
180
  const [command, ...rest] = process.argv.slice(2);
181
+ if (command === "--version" || command === "-v" || command === "version") {
182
+ const { describe } = await import("@glissade/scene/describe");
183
+ process.stdout.write(`${describe().version}\n`);
184
+ return;
185
+ }
173
186
  if (command !== "render" && command !== "diff" && command !== "verify-determinism" && command !== "dev" && command !== "import" && command !== "narrate" && command !== "narration-lint" && command !== "sfx" && command !== "prepare" && command !== "measure-loudness" && command !== "fonts" && command !== "cache" && command !== "mcp" && command !== "build" && command !== "describe" && command !== "migrate") {
174
187
  console.error(USAGE);
175
188
  process.exit(command === void 0 || command === "help" || command === "--help" ? 0 : 1);
@@ -233,7 +246,7 @@ async function main() {
233
246
  modulePath: sceneModule,
234
247
  ...cvRange ? { frameRange: cvRange } : {},
235
248
  ...sampleFlag !== void 0 ? { sample: Number(sampleFlag) } : {},
236
- ...cvFpsFlag ? { fps: parseInt(cvFpsFlag, 10) } : {}
249
+ ...cvFpsFlag ? { fps: parseFpsOrFail(cvFpsFlag) } : {}
237
250
  });
238
251
  process.stdout.write(`${result.report}\n`);
239
252
  if (!result.ok) process.exit(1);
@@ -435,7 +448,7 @@ async function main() {
435
448
  ...frameRange ? { frameRange } : {},
436
449
  ...flags.has("bisect") ? { bisect: true } : {},
437
450
  ...flags.has("emit") ? { emit: flags.get("emit") } : {},
438
- ...fpsFlag ? { fps: parseInt(fpsFlag, 10) } : {}
451
+ ...fpsFlag ? { fps: parseFpsOrFail(fpsFlag) } : {}
439
452
  });
440
453
  process.stdout.write(`${result.report}\n`);
441
454
  if (!result.ok) process.exit(1);
@@ -532,7 +545,7 @@ async function main() {
532
545
  const sharedOpts = {
533
546
  modulePath,
534
547
  out: flags.get("out") ?? "out",
535
- ...fpsFlag ? { fps: parseInt(fpsFlag, 10) } : {},
548
+ ...fpsFlag ? { fps: parseFpsOrFail(fpsFlag) } : {},
536
549
  ...frame !== void 0 ? { frame } : {},
537
550
  ...frameRange ? { frameRange } : {},
538
551
  ...formatFlag === "png-seq" ? { format: "png-seq" } : {},
package/dist/config.d.ts CHANGED
@@ -8,9 +8,29 @@ interface ProjectConfig {
8
8
  /** per-scene render/cache defaults. */
9
9
  defaults?: {
10
10
  fps?: number;
11
+ /** persistent frame-cache dir (speed only — NEVER changes output, so it is
12
+ * excluded from the staleness hash). */
11
13
  cache?: string;
14
+ /** captions mode for every render (0.33, consumer-pulled): a series that
15
+ * ships SOFT captions sets `captions: 'sidecar'` here — before this, gs
16
+ * build always used render's `burn` default and baked captions into the
17
+ * masters. Folded into the render staleness hash, so flipping it re-renders. */
18
+ captions?: 'burn' | 'sidecar' | 'off';
19
+ /** narration/music/sfx auto-mix modes ('auto' default). Threaded into BOTH
20
+ * render and measure-loudness so the measured mix always matches the
21
+ * rendered mix (a mismatch would trip render's stale-mixHash guard). */
22
+ narration?: 'auto' | 'off';
23
+ music?: 'auto' | 'off';
24
+ sfx?: 'auto' | 'off';
25
+ /** apply the committed publish gain at render ('auto' default). */
26
+ loudness?: 'auto' | 'off';
12
27
  };
13
28
  }
29
+ /** The defaults that change a RENDER's output — folded into its staleness hash
30
+ * (a captions/mix-mode flip must re-run render, never serve a stale cached
31
+ * master) and spread into the render call. `cache` is deliberately absent
32
+ * (byte-identical speed knob). */
33
+
14
34
  /** Identity helper for a typed `glissade.config.ts` default export. */
15
35
  declare function defineProject(config: ProjectConfig): ProjectConfig;
16
36
  //#endregion
package/dist/import.js CHANGED
@@ -29,6 +29,7 @@ async function importCommand(opts) {
29
29
  const inputAbs = resolve(opts.input);
30
30
  const outDir = resolve(opts.out);
31
31
  mkdirSync(outDir, { recursive: true });
32
+ if (!/\.(svg|json)$/i.test(inputAbs)) throw new Error(`${opts.input}: unsupported input — gs import expects a Lottie .json or an .svg`);
32
33
  if (/\.svg$/i.test(inputAbs)) {
33
34
  let svg;
34
35
  try {
package/dist/index.d.ts CHANGED
@@ -298,12 +298,6 @@ declare function render(opts: RenderOptions): Promise<{
298
298
  frames: number;
299
299
  out: string;
300
300
  }>;
301
- /**
302
- * Collect the timeline + auto-mixed (narration/music/sfx) audio clips for a
303
- * scene — the shared front half of the mix used by both `planFinalAudio` (the
304
- * render/shard path) and `buildMixWav` (the measure-loudness path), so the mix
305
- * CONTENT measured at commit-time is byte-for-byte the mix rendered later.
306
- */
307
301
  declare function collectAudioClips(opts: Pick<RenderOptions, 'modulePath' | 'narration' | 'music' | 'sfx' | 'locale'>, timelineClips: AudioClip[]): Promise<AudioClip[]>;
308
302
  /**
309
303
  * Resolve the ACTUAL audio files that feed the final mix (timeline clips +
@@ -59,6 +59,7 @@ function decodeEntry(buf) {
59
59
  off += BOUNDS_BYTES;
60
60
  }
61
61
  const raw = inflateSync(buf.subarray(off));
62
+ if (raw.byteLength !== w * h * 4) throw new Error(`corrupt .gsl layer entry (payload ${raw.byteLength} bytes ≠ ${w}×${h}×4)`);
62
63
  return {
63
64
  rgba: new Uint8ClampedArray(raw.buffer, raw.byteOffset, raw.byteLength),
64
65
  w,
package/dist/loudness.js CHANGED
@@ -2,7 +2,7 @@ import { t as __exportAll } from "./rolldown-runtime.js";
2
2
  import { spawnSync } from "node:child_process";
3
3
  import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
- import { join } from "node:path";
5
+ import { dirname, join, relative, resolve } from "node:path";
6
6
  import { createHash } from "node:crypto";
7
7
  //#region src/loudness.ts
8
8
  /**
@@ -130,7 +130,9 @@ function peakClampBinds(profile, inputI, inputTp) {
130
130
  * hash. The scene module path itself is excluded.
131
131
  */
132
132
  function computeMixHash(modulePath, extraInputs = []) {
133
- const base = modulePath.replace(/\.[jt]sx?$/, "");
133
+ const abs = resolve(modulePath);
134
+ const sceneDir = dirname(abs);
135
+ const base = abs.replace(/\.[jt]sx?$/, "");
134
136
  const siblings = [
135
137
  `${base}.narration.timing.json`,
136
138
  `${base}.music.timing.json`,
@@ -138,7 +140,7 @@ function computeMixHash(modulePath, extraInputs = []) {
138
140
  ];
139
141
  const h = createHash("sha256");
140
142
  for (const path of [...siblings, ...extraInputs]) {
141
- h.update(path);
143
+ h.update(relative(sceneDir, resolve(path)));
142
144
  h.update("\0");
143
145
  if (existsSync(path)) h.update(readFileSync(path));
144
146
  else h.update("\0ABSENT\0");
package/dist/mcp.js CHANGED
@@ -3,6 +3,7 @@ import { t as glissadeVersion } from "./version.js";
3
3
  import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
+ import { validateTrack } from "@glissade/core";
6
7
  import { evaluate } from "@glissade/scene";
7
8
  import { SkiaBackend } from "@glissade/backend-skia";
8
9
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
@@ -94,6 +95,24 @@ var McpSession = class McpSession {
94
95
  };
95
96
  const r = applyPatches(this.sidecar, patches, this.baseline);
96
97
  if (r.ok) {
98
+ for (const p of patches) {
99
+ if (!("target" in p)) continue;
100
+ const tlId = "timelineId" in p && typeof p.timelineId === "string" ? p.timelineId : "main";
101
+ const entry = r.doc.timelines[tlId]?.tracks[p.target];
102
+ if (entry === void 0) continue;
103
+ try {
104
+ validateTrack({
105
+ target: p.target,
106
+ type: entry.type,
107
+ keys: entry.keys.map((k) => ({ ...k }))
108
+ });
109
+ } catch (err) {
110
+ return {
111
+ ok: false,
112
+ error: err instanceof Error ? err.message : String(err)
113
+ };
114
+ }
115
+ }
97
116
  this.sidecar = r.doc;
98
117
  this.undoStack.push(r.inverse);
99
118
  }
package/dist/migrate.js CHANGED
@@ -65,9 +65,11 @@ function diffManifests(from, to) {
65
65
  action: `import { ${name} } from '${toPath}'`
66
66
  });
67
67
  }
68
- for (const p of keys(a.props)) {
69
- const pa = a.props[p];
70
- const pb = b.props[p];
68
+ const aProps = a.props ?? {};
69
+ const bProps = b.props ?? {};
70
+ for (const p of keys(aProps)) {
71
+ const pa = aProps[p];
72
+ const pb = bProps[p];
71
73
  if (pa === void 0) continue;
72
74
  if (pb === void 0) {
73
75
  out.push({
@@ -82,12 +84,12 @@ function diffManifests(from, to) {
82
84
  }
83
85
  diffProp(name, p, pa, pb, out);
84
86
  }
85
- for (const p of keys(b.props)) if (a.props[p] === void 0) out.push({
87
+ for (const p of keys(bProps)) if (aProps[p] === void 0) out.push({
86
88
  kind: "added",
87
89
  category: "prop",
88
90
  name: `${name}.${p}`,
89
91
  breaking: false,
90
- detail: `new prop (${b.props[p]?.animatable ? "animatable" : "construction-only"})`
92
+ detail: `new prop (${bProps[p]?.animatable ? "animatable" : "construction-only"})`
91
93
  });
92
94
  }
93
95
  for (const name of keys(toNodes)) if (fromNodes[name] === void 0) {
package/dist/render.js CHANGED
@@ -269,6 +269,13 @@ async function loadSceneModule(modulePath, locale) {
269
269
  setMessageTable(loadMessageTable(modulePath, locale));
270
270
  } else setMessageTable(void 0);
271
271
  const abs = isAbsolute(modulePath) ? modulePath : resolve(process.cwd(), modulePath);
272
+ if (![
273
+ abs,
274
+ abs.replace(/\.js$/, ".ts"),
275
+ abs.replace(/\.ts$/, ".js"),
276
+ `${abs}.ts`,
277
+ `${abs}.js`
278
+ ].some((c) => existsSync(c))) throw new SceneModuleError(modulePath, "scene module not found (check the path)");
272
279
  const loaded = await createJiti(pathToFileURL(process.cwd() + "/").href).import(pathToFileURL(abs).href, { default: true });
273
280
  if (typeof loaded?.createScene !== "function" || loaded?.timeline === void 0) throw new SceneModuleError(modulePath, "default export is not a SceneModule");
274
281
  return loaded;
@@ -318,8 +325,11 @@ async function render(opts) {
318
325
  lastFrame = Math.max(firstFrame, Math.ceil(to * fps) - 1);
319
326
  }
320
327
  const total = lastFrame - firstFrame + 1;
328
+ const lastTimelineFrame = Math.max(0, Math.ceil(duration * fps) - 1);
329
+ if (lastFrame > lastTimelineFrame) process.stderr.write(`warning: frame range ends at ${lastFrame} but the timeline ends at frame ${lastTimelineFrame} (${duration}s @ ${fps}fps) — the ${lastFrame - lastTimelineFrame} extra frame(s) repeat the frozen last frame\n`);
321
330
  const isVideo = opts.format !== "png-seq" && /\.(mp4|webm)$/i.test(opts.out);
322
331
  const singleFile = !isVideo && total === 1 && /\.png$/i.test(opts.out);
332
+ if (!isVideo && !singleFile && total > 1 && /\.png$/i.test(opts.out)) throw new Error(`--out '${opts.out}' is a .png path but the render covers ${total} frames — pass --frame <n> for one still, or use a directory / .mp4 out`);
323
333
  if (isVideo && !ffmpegAvailable()) throw new Error(`'${opts.out}' needs FFmpeg on PATH and none was found. Render a PNG sequence instead (--out <directory>) or install ffmpeg.`);
324
334
  const workers = Math.max(1, Math.floor(opts.workers ?? 1));
325
335
  if (workers > 1 && isVideo && total > 1) {
@@ -447,6 +457,7 @@ async function render(opts) {
447
457
  encName: enc.name,
448
458
  ...enc.note ? { encNote: enc.note } : {}
449
459
  };
460
+ await resolveLoudnessGainDb(opts, [...compiled.audio]);
450
461
  if (frameCache && keyCtx && opts.cache.mode !== "off") {
451
462
  const prev = readRenderManifest(outAbs);
452
463
  if (prev && existsSync(outAbs)) {
@@ -652,6 +663,12 @@ async function render(opts) {
652
663
  * render/shard path) and `buildMixWav` (the measure-loudness path), so the mix
653
664
  * CONTENT measured at commit-time is byte-for-byte the mix rendered later.
654
665
  */
666
+ const mixNotesSeen = /* @__PURE__ */ new Set();
667
+ function mixNote(line) {
668
+ if (mixNotesSeen.has(line)) return;
669
+ mixNotesSeen.add(line);
670
+ process.stderr.write(`${line}\n`);
671
+ }
655
672
  async function collectAudioClips(opts, timelineClips) {
656
673
  const { timingPathFor } = await import("./captions.js").then((n) => n.t);
657
674
  const audioClips = [...timelineClips];
@@ -660,7 +677,7 @@ async function collectAudioClips(opts, timelineClips) {
660
677
  const narrationPath = timingPathFor(opts.modulePath, opts.locale);
661
678
  if (narrationPath) {
662
679
  const voice = buildNarrationClips(narrationPath);
663
- if (voice) if (voice.clips.some((c) => bedAlreadyReferenced(audioClips, c.asset.url, opts.modulePath))) process.stderr.write("note: narration already in the timeline audio — auto-mix skipped\n");
680
+ if (voice) if (voice.clips.some((c) => bedAlreadyReferenced(audioClips, c.asset.url, opts.modulePath))) mixNote("note: narration already in the timeline audio — auto-mix skipped");
664
681
  else {
665
682
  audioClips.push(...voice.clips);
666
683
  process.stderr.write(`note: auto-mixing ${voice.note}\n`);
@@ -671,7 +688,7 @@ async function collectAudioClips(opts, timelineClips) {
671
688
  const musicPath = musicPathFor(opts.modulePath);
672
689
  if (musicPath) {
673
690
  const bed = buildMusicClip(musicPath, timingPathFor(opts.modulePath, opts.locale));
674
- if (bed) if (bedAlreadyReferenced(audioClips, bed.clip.asset.url, opts.modulePath)) process.stderr.write("note: music bed already in the timeline audio — auto-mix skipped\n");
691
+ if (bed) if (bedAlreadyReferenced(audioClips, bed.clip.asset.url, opts.modulePath)) mixNote("note: music bed already in the timeline audio — auto-mix skipped");
675
692
  else {
676
693
  audioClips.push(bed.clip);
677
694
  process.stderr.write(`note: auto-mixing ${bed.note}\n`);
@@ -683,7 +700,7 @@ async function collectAudioClips(opts, timelineClips) {
683
700
  const sfxPath = sfxTimingPathFor(opts.modulePath);
684
701
  if (sfxPath) {
685
702
  const fx = buildSfxClipsFromTiming(sfxPath);
686
- if (fx) if (fx.clips.some((c) => bedAlreadyReferenced(audioClips, c.asset.url, opts.modulePath))) process.stderr.write("note: sfx already in the timeline audio — auto-mix skipped\n");
703
+ if (fx) if (fx.clips.some((c) => bedAlreadyReferenced(audioClips, c.asset.url, opts.modulePath))) mixNote("note: sfx already in the timeline audio — auto-mix skipped");
687
704
  else {
688
705
  audioClips.push(...fx.clips);
689
706
  process.stderr.write(`note: auto-mixing ${fx.note}\n`);
@@ -765,7 +782,7 @@ async function resolveLoudnessGainDb(opts, timelineClips) {
765
782
  if (actual !== measurement.mixHash) {
766
783
  const path = loudnessPathFor(opts.modulePath, opts.locale);
767
784
  const reRun = hasLocale ? `gs measure-loudness ${opts.modulePath} --locale ${opts.locale}` : `gs measure-loudness ${opts.modulePath}`;
768
- throw new Error(`loudness: ${path} is stale — the mix inputs changed since it was measured (committed mixHash ${measurement.mixHash.slice(0, 23)}…, current ${actual.slice(0, 23)}…). Re-run \`${reRun}\` (or pass --loudness off to render without normalization).`);
785
+ throw new Error(`loudness: ${path} is stale — the mix inputs changed since it was measured (committed mixHash ${measurement.mixHash.slice(0, 23)}…, current ${actual.slice(0, 23)}…). Re-run \`${reRun}\` (or pass --loudness off to render without normalization). Note: 0.33 made the mixHash invocation-path-independent — if your mix inputs did NOT change, one re-measure migrates the committed hash.`);
769
786
  }
770
787
  return measurement.gain;
771
788
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/cli",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "description": "glissade CLI: headless rendering via backend-skia (+ FFmpeg mux).",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
@@ -28,15 +28,15 @@
28
28
  "@napi-rs/canvas": "^0.1.65",
29
29
  "esbuild": "0.28.0",
30
30
  "jiti": "^2.4.2",
31
- "@glissade/backend-skia": "0.32.0",
32
- "@glissade/core": "0.32.0",
33
- "@glissade/interact": "0.32.0",
34
- "@glissade/lottie": "0.32.0",
35
- "@glissade/narrate": "0.32.0",
36
- "@glissade/player": "0.32.0",
37
- "@glissade/scene": "0.32.0",
38
- "@glissade/sfx": "0.32.0",
39
- "@glissade/svg": "0.32.0"
31
+ "@glissade/backend-skia": "0.33.0",
32
+ "@glissade/core": "0.33.0",
33
+ "@glissade/interact": "0.33.0",
34
+ "@glissade/lottie": "0.33.0",
35
+ "@glissade/narrate": "0.33.0",
36
+ "@glissade/player": "0.33.0",
37
+ "@glissade/scene": "0.33.0",
38
+ "@glissade/sfx": "0.33.0",
39
+ "@glissade/svg": "0.33.0"
40
40
  },
41
41
  "repository": {
42
42
  "type": "git",