@glissade/cli 0.9.1 → 0.10.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/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { a as render, i as parseFrameRange } from "./render.js";
2
+ import { i as parseFrameRange, o as render } from "./render.js";
3
3
  import { n as parseCaptionsMode } from "./captions.js";
4
4
  //#region src/cli.ts
5
5
  /**
@@ -53,6 +53,11 @@ render options:
53
53
  --range <a..b> integer FRAME indices to render, inclusive (default: whole timeline)
54
54
  --frame <n> render a single frame; --out foo.png writes that one file, --out <dir> writes a PNG into it
55
55
  --format png-seq force a PNG sequence even when --out looks like a video
56
+ --workers <n> shard the frame range across n separate render processes, then concat (§5.6; video out only).
57
+ byte-identical to a single-worker render at the frame level
58
+ --lossless-intermediate render shards as FFV1 + one final encode — the guaranteed byte-correct join
59
+ (auto-enabled when the encoder can't honor precise boundary keyframes, e.g. mpeg4/openh264)
60
+ --allow-gpu-shards permit sharding a scene with GPU/shader nodes (output is not reproducible across shards; §3.7)
56
61
  --trace <file> replay an InputTrace and bake it (machine scenes, §A.6)
57
62
  --state <name> render one machine state's timeline linearly
58
63
  --force downgrade a trace hash mismatch to a warning
@@ -178,7 +183,12 @@ async function main() {
178
183
  }
179
184
  const formatFlag = flags.get("format");
180
185
  if (formatFlag !== void 0 && formatFlag !== "png-seq") fail(`--format must be 'png-seq', got '${formatFlag}'`);
181
- if (flags.has("workers")) process.stderr.write("note: --workers is accepted but parallel sharding is not yet implemented; rendering single-threaded\n");
186
+ let workers;
187
+ const workersFlag = flags.get("workers");
188
+ if (workersFlag !== void 0) {
189
+ if (!/^\d+$/.test(workersFlag) || Number(workersFlag) < 1) fail(`--workers must be a positive integer, got '${workersFlag}'`);
190
+ workers = Number(workersFlag);
191
+ }
182
192
  if (flags.has("watch")) process.stderr.write("note: --watch is not yet implemented in this release; rendering once\n");
183
193
  const fpsFlag = flags.get("fps");
184
194
  const started = performance.now();
@@ -196,6 +206,9 @@ async function main() {
196
206
  ...flags.has("state") ? { state: flags.get("state") } : {},
197
207
  ...flags.has("force") ? { force: true } : {},
198
208
  ...flags.has("strict") ? { strictFonts: true } : {},
209
+ ...workers !== void 0 ? { workers } : {},
210
+ ...flags.has("lossless-intermediate") ? { losslessIntermediate: true } : {},
211
+ ...flags.has("allow-gpu-shards") ? { allowGpuShards: true } : {},
199
212
  captions: parseCaptionsModeOrFail(flags.get("captions")),
200
213
  narration: flags.get("narration") === "off" ? "off" : "auto",
201
214
  music: flags.get("music") === "off" ? "off" : "auto",
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { AudioClip, Key, Timeline } from "@glissade/core";
1
+ import { AudioClip, CompiledTimeline, Key, Timeline } from "@glissade/core";
2
2
  import { Scene, SceneModule, VideoFrameSource } from "@glissade/scene";
3
3
  import { Image } from "@napi-rs/canvas";
4
4
 
@@ -36,6 +36,30 @@ interface RenderOptions {
36
36
  chapterKinds?: ReadonlySet<string>;
37
37
  /** --strict: font validation throws on an unregistered family / missing glyph (§3.6). Default dev-warn. */
38
38
  strictFonts?: boolean;
39
+ /**
40
+ * --workers N (§5.6): split the frame range into N contiguous sub-ranges, render
41
+ * each in a separate `gs` child process, and join the shard videos. Ignored for
42
+ * a single frame or N <= 1. Only meaningful for a video `out`.
43
+ */
44
+ workers?: number;
45
+ /**
46
+ * --lossless-intermediate (§5.6, §8.1): render shards as FFV1 (lossless) and do a
47
+ * single final encode after the concat — the guaranteed byte-correct join path.
48
+ * Forced on automatically when the picked encoder can't honor precise boundary
49
+ * keyframes (mpeg4 / openh264).
50
+ */
51
+ losslessIntermediate?: boolean;
52
+ /**
53
+ * --allow-gpu-shards (§5.6): sharded GPU/shader output isn't reproducible across
54
+ * processes/machines, so a scene containing a ShaderEffect refuses to shard unless
55
+ * this is set.
56
+ */
57
+ allowGpuShards?: boolean;
58
+ /**
59
+ * Internal (shard children): render video-only — skip the audio mix and the
60
+ * caption/cue sidecars, which the orchestrator emits once over the joined result.
61
+ */
62
+ videoOnly?: boolean;
39
63
  onProgress?: (frame: number, total: number) => void;
40
64
  }
41
65
  declare class SceneModuleError extends Error {
@@ -53,6 +77,62 @@ declare function render(opts: RenderOptions): Promise<{
53
77
  frames: number;
54
78
  out: string;
55
79
  }>;
80
+ /**
81
+ * Collect timeline + auto-mixed (narration/music/sfx) audio clips and plan the
82
+ * FFmpeg audio graph, returning the `-i`/`-filter_complex`/`-map` argument
83
+ * fragments. Shared by the linear `render()` path and the sharded orchestrator
84
+ * (which mixes audio once, over the concatenated video). Returns empty args when
85
+ * there is nothing to mix.
86
+ */
87
+ declare function planFinalAudio(opts: RenderOptions, timelineClips: AudioClip[], duration: number, container: 'mp4' | 'webm'): Promise<{
88
+ audioInputs: string[];
89
+ audioArgs: string[];
90
+ }>;
91
+ //#endregion
92
+ //#region src/shards.d.ts
93
+ declare class ShardError extends Error {
94
+ constructor(message: string);
95
+ }
96
+ interface ShardRange {
97
+ /** inclusive first frame */
98
+ first: number;
99
+ /** inclusive last frame */
100
+ last: number;
101
+ }
102
+ /**
103
+ * Split inclusive `[first, last]` into up to `workers` contiguous sub-ranges,
104
+ * front-loading the remainder so earlier shards are at most one frame larger.
105
+ * Never returns more ranges than there are frames.
106
+ */
107
+ declare function splitFrameRange(first: number, last: number, workers: number): ShardRange[];
108
+ /** Does this scene contain a GPU/shader node (outside the §3.7 determinism guarantee)? */
109
+ declare function sceneHasGpuNodes(scene: Scene): boolean;
110
+ interface RenderShardedArgs {
111
+ opts: RenderOptions;
112
+ scene: Scene;
113
+ compiled: CompiledTimeline;
114
+ fps: number;
115
+ duration: number;
116
+ firstFrame: number;
117
+ lastFrame: number;
118
+ container: 'mp4' | 'webm';
119
+ workers: number;
120
+ timingPathFor: (modulePath: string) => string | null;
121
+ writeCaptionSidecars: (timingPath: string, target: string) => {
122
+ srt: string;
123
+ vtt: string;
124
+ };
125
+ writeCueSidecars: (target: string, markers: CompiledTimeline['markers'], duration: number, chapters: boolean, chapterKinds?: ReadonlySet<string>) => string[];
126
+ }
127
+ /**
128
+ * Orchestrate a sharded render. Returns the same `{ frames, out }` shape as the
129
+ * linear `render()`. Throws on a GPU scene without --allow-gpu-shards, or on any
130
+ * child / ffmpeg failure.
131
+ */
132
+ declare function renderSharded(a: RenderShardedArgs): Promise<{
133
+ frames: number;
134
+ out: string;
135
+ }>;
56
136
  //#endregion
57
137
  //#region src/videoSource.d.ts
58
138
  declare class VideoProbeError extends Error {
@@ -184,4 +264,4 @@ interface ImportCommandResult {
184
264
  }
185
265
  declare function importCommand(opts: ImportOptions): Promise<ImportCommandResult>;
186
266
  //#endregion
187
- export { AudioMixError, type AudioMixPlan, type DevOptions, type DevServer, type EncoderChoice, FfmpegVideoFrameSource, type ImportCommandResult, type ImportOptions, MachineExportError, type MachineRenderFlags, NoEncoderError, type RenderOptions, SceneModuleError, type VideoInfo, VideoProbeError, atempoChain, availableEncoders, dev, ffmpegAvailable, gainExpression, importCommand, loadSceneModule, parseEncoderList, pickEncoder, planAudioMix, probeVideo, render, resolveAssetPath, resolveRenderDoc };
267
+ export { AudioMixError, type AudioMixPlan, type DevOptions, type DevServer, type EncoderChoice, FfmpegVideoFrameSource, type ImportCommandResult, type ImportOptions, MachineExportError, type MachineRenderFlags, NoEncoderError, type RenderOptions, type RenderShardedArgs, SceneModuleError, ShardError, type ShardRange, type VideoInfo, VideoProbeError, atempoChain, availableEncoders, dev, ffmpegAvailable, gainExpression, importCommand, loadSceneModule, parseEncoderList, pickEncoder, planAudioMix, planFinalAudio, probeVideo, render, renderSharded, resolveAssetPath, resolveRenderDoc, sceneHasGpuNodes, splitFrameRange };
package/dist/index.js CHANGED
@@ -1,8 +1,9 @@
1
- import { a as render, n as ffmpegAvailable, r as loadSceneModule, t as SceneModuleError } from "./render.js";
1
+ import { a as planFinalAudio, n as ffmpegAvailable, o as render, r as loadSceneModule, t as SceneModuleError } from "./render.js";
2
+ import { a as pickEncoder, i as parseEncoderList, n as availableEncoders, t as NoEncoderError } from "./encoders.js";
3
+ import { a as splitFrameRange, n as renderSharded, r as sceneHasGpuNodes, t as ShardError } from "./shards.js";
2
4
  import { n as VideoProbeError, r as probeVideo, t as FfmpegVideoFrameSource } from "./videoSource.js";
3
5
  import { a as planAudioMix, i as gainExpression, n as atempoChain, o as resolveAssetPath, t as AudioMixError } from "./audioMix.js";
4
- import { a as pickEncoder, i as parseEncoderList, n as availableEncoders, t as NoEncoderError } from "./encoders.js";
5
6
  import { r as resolveRenderDoc, t as MachineExportError } from "./machines.js";
6
7
  import { t as dev } from "./dev.js";
7
8
  import { t as importCommand } from "./import.js";
8
- export { AudioMixError, FfmpegVideoFrameSource, MachineExportError, NoEncoderError, SceneModuleError, VideoProbeError, atempoChain, availableEncoders, dev, ffmpegAvailable, gainExpression, importCommand, loadSceneModule, parseEncoderList, pickEncoder, planAudioMix, probeVideo, render, resolveAssetPath, resolveRenderDoc };
9
+ export { AudioMixError, FfmpegVideoFrameSource, MachineExportError, NoEncoderError, SceneModuleError, ShardError, VideoProbeError, atempoChain, availableEncoders, dev, ffmpegAvailable, gainExpression, importCommand, loadSceneModule, parseEncoderList, pickEncoder, planAudioMix, planFinalAudio, probeVideo, render, renderSharded, resolveAssetPath, resolveRenderDoc, sceneHasGpuNodes, splitFrameRange };
package/dist/render.js CHANGED
@@ -74,6 +74,24 @@ async function render(opts) {
74
74
  const isVideo = opts.format !== "png-seq" && /\.(mp4|webm)$/i.test(opts.out);
75
75
  const singleFile = !isVideo && total === 1 && /\.png$/i.test(opts.out);
76
76
  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.`);
77
+ const workers = opts.videoOnly ? 1 : Math.max(1, Math.floor(opts.workers ?? 1));
78
+ if (workers > 1 && isVideo && total > 1) {
79
+ const { renderSharded } = await import("./shards.js").then((n) => n.i);
80
+ return renderSharded({
81
+ opts,
82
+ scene,
83
+ compiled,
84
+ fps,
85
+ duration,
86
+ firstFrame,
87
+ lastFrame,
88
+ container: /\.webm$/i.test(opts.out) ? "webm" : "mp4",
89
+ workers,
90
+ timingPathFor,
91
+ writeCaptionSidecars,
92
+ writeCueSidecars
93
+ });
94
+ }
77
95
  const framesDir = isVideo ? mkdtempSync(join(tmpdir(), "glissade-frames-")) : singleFile ? dirname(resolve(opts.out)) : resolve(opts.out);
78
96
  mkdirSync(framesDir, { recursive: true });
79
97
  const backend = new SkiaBackend(scene.size.w, scene.size.h);
@@ -144,8 +162,10 @@ async function render(opts) {
144
162
  }
145
163
  const outAbs = resolve(opts.out);
146
164
  mkdirSync(dirname(outAbs), { recursive: true });
147
- emitSidecars(outAbs);
148
- emitCues(outAbs);
165
+ if (!opts.videoOnly) {
166
+ emitSidecars(outAbs);
167
+ emitCues(outAbs);
168
+ }
149
169
  const isWebm = /\.webm$/i.test(outAbs);
150
170
  const container = isWebm ? "webm" : "mp4";
151
171
  const { pickEncoder } = await import("./encoders.js").then((n) => n.r);
@@ -173,60 +193,10 @@ async function render(opts) {
173
193
  "+faststart"
174
194
  ]
175
195
  ];
176
- const audioClips = [...compiled.audio];
177
- {
178
- const { bedAlreadyReferenced, buildMusicClip, buildNarrationClips, musicPathFor } = await import("./music.js");
179
- if ((opts.narration ?? "auto") === "auto") {
180
- const narrationPath = timingPathFor(opts.modulePath);
181
- if (narrationPath) {
182
- const voice = buildNarrationClips(narrationPath);
183
- 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");
184
- else {
185
- audioClips.push(...voice.clips);
186
- process.stderr.write(`note: auto-mixing ${voice.note}\n`);
187
- }
188
- }
189
- }
190
- if ((opts.music ?? "auto") === "auto") {
191
- const musicPath = musicPathFor(opts.modulePath);
192
- if (musicPath) {
193
- const bed = buildMusicClip(musicPath, timingPathFor(opts.modulePath));
194
- 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");
195
- else {
196
- audioClips.push(bed.clip);
197
- process.stderr.write(`note: auto-mixing ${bed.note}\n`);
198
- }
199
- }
200
- }
201
- if ((opts.sfx ?? "auto") === "auto") {
202
- const { buildSfxClipsFromTiming, sfxTimingPathFor } = await import("./sfx.js");
203
- const sfxPath = sfxTimingPathFor(opts.modulePath);
204
- if (sfxPath) {
205
- const fx = buildSfxClipsFromTiming(sfxPath);
206
- 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");
207
- else {
208
- audioClips.push(...fx.clips);
209
- process.stderr.write(`note: auto-mixing ${fx.note}\n`);
210
- }
211
- }
212
- }
213
- }
214
- const { planAudioMix } = await import("./audioMix.js").then((n) => n.r);
215
- const mix = planAudioMix(audioClips, opts.modulePath, duration);
216
- if (mix?.hasEasedGain) process.stderr.write("note: eased gain keys are approximated linearly in the FFmpeg mix\n");
217
- const audioInputs = mix ? mix.inputs.flatMap((p) => ["-i", p]) : [];
218
- const audioEnc = mix ? pickEncoder("audio", container) : null;
219
- const audioArgs = mix && audioEnc ? [
220
- "-filter_complex",
221
- mix.filterComplex,
222
- "-map",
223
- "0:v",
224
- "-map",
225
- "[aout]",
226
- "-c:a",
227
- audioEnc.name,
228
- ...container === "mp4" ? ["-b:a", "192k"] : []
229
- ] : [];
196
+ const { audioInputs, audioArgs } = opts.videoOnly ? {
197
+ audioInputs: [],
198
+ audioArgs: []
199
+ } : await planFinalAudio(opts, [...compiled.audio], duration, container);
230
200
  const result = spawnSync("ffmpeg", [
231
201
  "-y",
232
202
  "-framerate",
@@ -256,5 +226,74 @@ async function render(opts) {
256
226
  out: outAbs
257
227
  };
258
228
  }
229
+ /**
230
+ * Collect timeline + auto-mixed (narration/music/sfx) audio clips and plan the
231
+ * FFmpeg audio graph, returning the `-i`/`-filter_complex`/`-map` argument
232
+ * fragments. Shared by the linear `render()` path and the sharded orchestrator
233
+ * (which mixes audio once, over the concatenated video). Returns empty args when
234
+ * there is nothing to mix.
235
+ */
236
+ async function planFinalAudio(opts, timelineClips, duration, container) {
237
+ const { timingPathFor } = await import("./captions.js").then((n) => n.t);
238
+ const audioClips = [...timelineClips];
239
+ const { bedAlreadyReferenced, buildMusicClip, buildNarrationClips, musicPathFor } = await import("./music.js");
240
+ if ((opts.narration ?? "auto") === "auto") {
241
+ const narrationPath = timingPathFor(opts.modulePath);
242
+ if (narrationPath) {
243
+ const voice = buildNarrationClips(narrationPath);
244
+ 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");
245
+ else {
246
+ audioClips.push(...voice.clips);
247
+ process.stderr.write(`note: auto-mixing ${voice.note}\n`);
248
+ }
249
+ }
250
+ }
251
+ if ((opts.music ?? "auto") === "auto") {
252
+ const musicPath = musicPathFor(opts.modulePath);
253
+ if (musicPath) {
254
+ const bed = buildMusicClip(musicPath, timingPathFor(opts.modulePath));
255
+ 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");
256
+ else {
257
+ audioClips.push(bed.clip);
258
+ process.stderr.write(`note: auto-mixing ${bed.note}\n`);
259
+ }
260
+ }
261
+ }
262
+ if ((opts.sfx ?? "auto") === "auto") {
263
+ const { buildSfxClipsFromTiming, sfxTimingPathFor } = await import("./sfx.js");
264
+ const sfxPath = sfxTimingPathFor(opts.modulePath);
265
+ if (sfxPath) {
266
+ const fx = buildSfxClipsFromTiming(sfxPath);
267
+ 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");
268
+ else {
269
+ audioClips.push(...fx.clips);
270
+ process.stderr.write(`note: auto-mixing ${fx.note}\n`);
271
+ }
272
+ }
273
+ }
274
+ const { planAudioMix } = await import("./audioMix.js").then((n) => n.r);
275
+ const { pickEncoder } = await import("./encoders.js").then((n) => n.r);
276
+ const mix = planAudioMix(audioClips, opts.modulePath, duration);
277
+ if (mix?.hasEasedGain) process.stderr.write("note: eased gain keys are approximated linearly in the FFmpeg mix\n");
278
+ if (!mix) return {
279
+ audioInputs: [],
280
+ audioArgs: []
281
+ };
282
+ const audioEnc = pickEncoder("audio", container);
283
+ return {
284
+ audioInputs: mix.inputs.flatMap((p) => ["-i", p]),
285
+ audioArgs: [
286
+ "-filter_complex",
287
+ mix.filterComplex,
288
+ "-map",
289
+ "0:v",
290
+ "-map",
291
+ "[aout]",
292
+ "-c:a",
293
+ audioEnc.name,
294
+ ...container === "mp4" ? ["-b:a", "192k"] : []
295
+ ]
296
+ };
297
+ }
259
298
  //#endregion
260
- export { render as a, parseFrameRange as i, ffmpegAvailable as n, loadSceneModule as r, SceneModuleError as t };
299
+ export { planFinalAudio as a, parseFrameRange as i, ffmpegAvailable as n, render as o, loadSceneModule as r, SceneModuleError as t };
package/dist/shards.js ADDED
@@ -0,0 +1,280 @@
1
+ import { t as __exportAll } from "./rolldown-runtime.js";
2
+ import { a as planFinalAudio } from "./render.js";
3
+ import { a as pickEncoder } from "./encoders.js";
4
+ import { spawnSync } from "node:child_process";
5
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { dirname, join, resolve } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { ShaderEffect } from "@glissade/scene";
10
+ //#region src/shards.ts
11
+ /**
12
+ * Sharded export (DESIGN.md §5.6, §8.1 item 1): `gs render --workers N` splits
13
+ * the frame range into N contiguous sub-ranges, renders each in a SEPARATE `gs`
14
+ * child process (NOT worker_threads — @napi-rs/canvas / GlobalFonts hold unsafe
15
+ * process-global state), then joins the shard videos with the FFmpeg concat
16
+ * demuxer. Determinism is frame-level: every shard re-runs the scene module from
17
+ * scratch (re-deriving any module-level `bake()` for its prefix), so an N-worker
18
+ * render of a range is byte-identical to a single-worker render of the same range
19
+ * at the PNG level; the join is pure engineering above the purity guarantee.
20
+ *
21
+ * Two join strategies (the §8.1 decision):
22
+ * - default: per-shard encode with a forced keyframe at each range start +
23
+ * identical encoder settings, concat-demuxer copy.
24
+ * - --lossless-intermediate: FFV1 shards + a single final encode. Forced on
25
+ * automatically when the picked encoder can't honor precise boundary
26
+ * keyframes (mpeg4 / openh264) — a concat-copy of imprecise-GOP codecs drops
27
+ * or dupes boundary frames, so it would not be byte-faithful.
28
+ *
29
+ * GPU/shader scenes are outside the cross-process reproducibility guarantee
30
+ * (§3.7), so a scene containing a ShaderEffect refuses to shard without
31
+ * --allow-gpu-shards.
32
+ */
33
+ var shards_exports = /* @__PURE__ */ __exportAll({
34
+ ShardError: () => ShardError,
35
+ renderSharded: () => renderSharded,
36
+ sceneHasGpuNodes: () => sceneHasGpuNodes,
37
+ splitFrameRange: () => splitFrameRange
38
+ });
39
+ /** Encoders that can't place a keyframe exactly on a forced boundary → not concat-copy-safe. */
40
+ const IMPRECISE_KEYFRAME_ENCODERS = new Set(["mpeg4", "libopenh264"]);
41
+ /** Per-encoder quality flags — must match render.ts's linear path byte-for-byte. */
42
+ const VIDEO_QUALITY = {
43
+ "libx264": ["-crf", "18"],
44
+ "libvpx-vp9": [
45
+ "-b:v",
46
+ "0",
47
+ "-crf",
48
+ "32"
49
+ ],
50
+ "libvpx": ["-b:v", "2M"],
51
+ "libopenh264": ["-b:v", "4M"],
52
+ "mpeg4": ["-q:v", "3"]
53
+ };
54
+ var ShardError = class extends Error {
55
+ constructor(message) {
56
+ super(message);
57
+ this.name = "ShardError";
58
+ }
59
+ };
60
+ /**
61
+ * Split inclusive `[first, last]` into up to `workers` contiguous sub-ranges,
62
+ * front-loading the remainder so earlier shards are at most one frame larger.
63
+ * Never returns more ranges than there are frames.
64
+ */
65
+ function splitFrameRange(first, last, workers) {
66
+ const totalFrames = last - first + 1;
67
+ const n = Math.min(Math.max(1, Math.floor(workers)), totalFrames);
68
+ const base = Math.floor(totalFrames / n);
69
+ const extra = totalFrames % n;
70
+ const ranges = [];
71
+ let cursor = first;
72
+ for (let i = 0; i < n; i++) {
73
+ const len = base + (i < extra ? 1 : 0);
74
+ ranges.push({
75
+ first: cursor,
76
+ last: cursor + len - 1
77
+ });
78
+ cursor += len;
79
+ }
80
+ return ranges;
81
+ }
82
+ /** Does this scene contain a GPU/shader node (outside the §3.7 determinism guarantee)? */
83
+ function sceneHasGpuNodes(scene) {
84
+ for (const node of scene.nodes.values()) if (node instanceof ShaderEffect) return true;
85
+ return false;
86
+ }
87
+ /**
88
+ * Resolve the built `gs` CLI entry. In production this module is `dist/shards.js`,
89
+ * so the sibling `dist/cli.js` is the entry. Under test/source the sibling
90
+ * `cli.js` doesn't exist, so fall back to the package's built `dist/cli.js`.
91
+ */
92
+ function cliEntry() {
93
+ const here = dirname(fileURLToPath(import.meta.url));
94
+ const sibling = resolve(here, "cli.js");
95
+ if (existsSync(sibling)) return sibling;
96
+ const dist = resolve(here, "..", "dist", "cli.js");
97
+ if (existsSync(dist)) return dist;
98
+ return sibling;
99
+ }
100
+ /**
101
+ * Orchestrate a sharded render. Returns the same `{ frames, out }` shape as the
102
+ * linear `render()`. Throws on a GPU scene without --allow-gpu-shards, or on any
103
+ * child / ffmpeg failure.
104
+ */
105
+ async function renderSharded(a) {
106
+ const { opts, scene, compiled, fps, duration, firstFrame, lastFrame, container } = a;
107
+ if (sceneHasGpuNodes(scene) && !opts.allowGpuShards) throw new ShardError("this scene contains a GPU/shader node, whose output is not reproducible across shard processes (§3.7). Re-run with --allow-gpu-shards to override, or render single-threaded (drop --workers).");
108
+ const ranges = splitFrameRange(firstFrame, lastFrame, a.workers);
109
+ const total = lastFrame - firstFrame + 1;
110
+ const outAbs = resolve(opts.out);
111
+ mkdirSync(dirname(outAbs), { recursive: true });
112
+ const finalEnc = pickEncoder("video", container);
113
+ if (finalEnc.note) process.stderr.write(`note: ${finalEnc.note}\n`);
114
+ let lossless = opts.losslessIntermediate === true;
115
+ if (!lossless && IMPRECISE_KEYFRAME_ENCODERS.has(finalEnc.name)) {
116
+ lossless = true;
117
+ process.stderr.write(`note: '${finalEnc.name}' can't honor precise shard-boundary keyframes; falling back to --lossless-intermediate (FFV1 shards + one final encode) for a byte-faithful join
118
+ `);
119
+ }
120
+ const work = mkdtempSync(join(tmpdir(), "glissade-shards-"));
121
+ const shardVideos = [];
122
+ let done = 0;
123
+ try {
124
+ process.stderr.write(`sharding ${total} frames across ${ranges.length} worker${ranges.length === 1 ? "" : "s"}${lossless ? " (lossless intermediate)" : ""}\n`);
125
+ for (let i = 0; i < ranges.length; i++) {
126
+ const { first, last } = ranges[i];
127
+ const shardFrames = join(work, `shard-${String(i).padStart(3, "0")}-frames`);
128
+ mkdirSync(shardFrames, { recursive: true });
129
+ const childArgs = [
130
+ cliEntry(),
131
+ "render",
132
+ opts.modulePath,
133
+ "--out",
134
+ shardFrames,
135
+ "--range",
136
+ `${first}..${last}`,
137
+ "--format",
138
+ "png-seq",
139
+ "--fps",
140
+ String(fps),
141
+ "--narration",
142
+ "off",
143
+ "--music",
144
+ "off",
145
+ "--sfx",
146
+ "off",
147
+ ...opts.captions ? ["--captions", opts.captions] : [],
148
+ ...opts.trace !== void 0 ? ["--trace", opts.trace] : [],
149
+ ...opts.state !== void 0 ? ["--state", opts.state] : [],
150
+ ...opts.force ? ["--force"] : [],
151
+ ...opts.strictFonts ? ["--strict"] : []
152
+ ];
153
+ const child = spawnSync(process.execPath, childArgs, { stdio: [
154
+ "ignore",
155
+ "ignore",
156
+ "pipe"
157
+ ] });
158
+ if (child.status !== 0) throw new ShardError(`shard ${i} (frames ${first}..${last}) failed (exit ${child.status}):\n${child.stderr?.toString().slice(-2e3) ?? ""}`);
159
+ const ext = container === "webm" && !lossless ? "webm" : "mkv";
160
+ const shardVideo = join(work, `shard-${String(i).padStart(3, "0")}.${ext}`);
161
+ const codecArgs = lossless ? [
162
+ "-c:v",
163
+ "ffv1",
164
+ "-level",
165
+ "3"
166
+ ] : [
167
+ "-c:v",
168
+ finalEnc.name,
169
+ ...VIDEO_QUALITY[finalEnc.name] ?? [],
170
+ "-force_key_frames",
171
+ "0",
172
+ ...container === "webm" ? [] : ["-pix_fmt", "yuv420p"]
173
+ ];
174
+ const enc = spawnSync("ffmpeg", [
175
+ "-y",
176
+ "-framerate",
177
+ String(fps),
178
+ "-start_number",
179
+ String(first),
180
+ "-i",
181
+ join(shardFrames, "frame-%05d.png"),
182
+ ...codecArgs,
183
+ shardVideo
184
+ ], { stdio: [
185
+ "ignore",
186
+ "ignore",
187
+ "pipe"
188
+ ] });
189
+ if (enc.status !== 0) throw new ShardError(`shard ${i} encode failed (exit ${enc.status}):\n${enc.stderr?.toString().slice(-2e3)}`);
190
+ rmSync(shardFrames, {
191
+ recursive: true,
192
+ force: true
193
+ });
194
+ shardVideos.push(shardVideo);
195
+ done += last - first + 1;
196
+ opts.onProgress?.(done, total);
197
+ }
198
+ const listFile = join(work, "concat.txt");
199
+ writeFileSync(listFile, shardVideos.map((p) => `file '${p.replace(/'/g, "'\\''")}'`).join("\n") + "\n");
200
+ const { audioInputs, audioArgs } = await planFinalAudio(opts, [...compiled.audio], duration, container);
201
+ let joinArgs;
202
+ if (lossless) {
203
+ const videoEnc = finalEnc;
204
+ joinArgs = [
205
+ "-y",
206
+ "-f",
207
+ "concat",
208
+ "-safe",
209
+ "0",
210
+ "-i",
211
+ listFile,
212
+ ...audioInputs,
213
+ ...audioArgs,
214
+ "-c:v",
215
+ videoEnc.name,
216
+ ...VIDEO_QUALITY[videoEnc.name] ?? [],
217
+ ...container === "webm" ? [] : [
218
+ "-pix_fmt",
219
+ "yuv420p",
220
+ "-movflags",
221
+ "+faststart"
222
+ ],
223
+ outAbs
224
+ ];
225
+ } else if (audioArgs.length) joinArgs = [
226
+ "-y",
227
+ "-f",
228
+ "concat",
229
+ "-safe",
230
+ "0",
231
+ "-i",
232
+ listFile,
233
+ ...audioInputs,
234
+ "-c:v",
235
+ "copy",
236
+ ...audioArgs,
237
+ ...container === "webm" ? [] : ["-movflags", "+faststart"],
238
+ outAbs
239
+ ];
240
+ else joinArgs = [
241
+ "-y",
242
+ "-f",
243
+ "concat",
244
+ "-safe",
245
+ "0",
246
+ "-i",
247
+ listFile,
248
+ "-c",
249
+ "copy",
250
+ ...container === "webm" ? [] : ["-movflags", "+faststart"],
251
+ outAbs
252
+ ];
253
+ const join_ = spawnSync("ffmpeg", joinArgs, { stdio: [
254
+ "ignore",
255
+ "ignore",
256
+ "pipe"
257
+ ] });
258
+ if (join_.status !== 0) throw new ShardError(`shard join failed (exit ${join_.status}):\n${join_.stderr?.toString().slice(-2e3)}`);
259
+ } finally {
260
+ rmSync(work, {
261
+ recursive: true,
262
+ force: true
263
+ });
264
+ }
265
+ if ((opts.captions ?? "burn") !== "off") {
266
+ const timingPath = a.timingPathFor(opts.modulePath);
267
+ if (timingPath) {
268
+ const { srt, vtt } = a.writeCaptionSidecars(timingPath, outAbs);
269
+ process.stderr.write(`captions: ${srt}, ${vtt}\n`);
270
+ } else if (opts.captions === "sidecar") process.stderr.write("note: --captions sidecar: no narration timing manifest found; run gs narrate first\n");
271
+ }
272
+ const cueFiles = a.writeCueSidecars(outAbs, compiled.markers, duration, opts.chapters === "vtt", opts.chapterKinds);
273
+ if (cueFiles.length) process.stderr.write(`cues: ${cueFiles.join(", ")}\n`);
274
+ return {
275
+ frames: total,
276
+ out: outAbs
277
+ };
278
+ }
279
+ //#endregion
280
+ export { splitFrameRange as a, shards_exports as i, renderSharded as n, sceneHasGpuNodes as r, ShardError as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/cli",
3
- "version": "0.9.1",
3
+ "version": "0.10.0-pre.0",
4
4
  "description": "glissade CLI: headless rendering via backend-skia (+ FFmpeg mux).",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -20,15 +20,15 @@
20
20
  "@napi-rs/canvas": "^0.1.65",
21
21
  "esbuild": "^0.28.0",
22
22
  "jiti": "^2.4.2",
23
- "@glissade/backend-skia": "0.9.1",
24
- "@glissade/core": "0.9.1",
25
- "@glissade/interact": "0.9.1",
26
- "@glissade/lottie": "0.9.1",
27
- "@glissade/svg": "0.9.1",
28
- "@glissade/narrate": "0.9.1",
29
- "@glissade/player": "0.9.1",
30
- "@glissade/scene": "0.9.1",
31
- "@glissade/sfx": "0.9.1"
23
+ "@glissade/backend-skia": "0.10.0-pre.0",
24
+ "@glissade/core": "0.10.0-pre.0",
25
+ "@glissade/interact": "0.10.0-pre.0",
26
+ "@glissade/lottie": "0.10.0-pre.0",
27
+ "@glissade/svg": "0.10.0-pre.0",
28
+ "@glissade/narrate": "0.10.0-pre.0",
29
+ "@glissade/player": "0.10.0-pre.0",
30
+ "@glissade/scene": "0.10.0-pre.0",
31
+ "@glissade/sfx": "0.10.0-pre.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",