@glissade/cli 0.32.0 → 0.33.0-pre.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 +2 -0
- package/dist/build.js +46 -6
- package/dist/cli.js +16 -3
- package/dist/config.d.ts +20 -0
- package/dist/import.js +1 -0
- package/dist/index.d.ts +0 -6
- package/dist/layerCache.js +1 -0
- package/dist/loudness.js +5 -3
- package/dist/mcp.js +19 -0
- package/dist/migrate.js +7 -5
- package/dist/render.js +21 -4
- package/package.json +10 -10
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({
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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 +
|
package/dist/layerCache.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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(
|
|
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 (${
|
|
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)))
|
|
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))
|
|
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)))
|
|
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.
|
|
3
|
+
"version": "0.33.0-pre.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
|
-
"@glissade/core": "0.
|
|
33
|
-
"@glissade/interact": "0.
|
|
34
|
-
"@glissade/lottie": "0.
|
|
35
|
-
"@glissade/narrate": "0.
|
|
36
|
-
"@glissade/player": "0.
|
|
37
|
-
"@glissade/scene": "0.
|
|
38
|
-
"@glissade/sfx": "0.
|
|
39
|
-
"@glissade/svg": "0.
|
|
31
|
+
"@glissade/backend-skia": "0.33.0-pre.0",
|
|
32
|
+
"@glissade/core": "0.33.0-pre.0",
|
|
33
|
+
"@glissade/interact": "0.33.0-pre.0",
|
|
34
|
+
"@glissade/lottie": "0.33.0-pre.0",
|
|
35
|
+
"@glissade/narrate": "0.33.0-pre.0",
|
|
36
|
+
"@glissade/player": "0.33.0-pre.0",
|
|
37
|
+
"@glissade/scene": "0.33.0-pre.0",
|
|
38
|
+
"@glissade/sfx": "0.33.0-pre.0",
|
|
39
|
+
"@glissade/svg": "0.33.0-pre.0"
|
|
40
40
|
},
|
|
41
41
|
"repository": {
|
|
42
42
|
"type": "git",
|