@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 +15 -2
- package/dist/index.d.ts +82 -2
- package/dist/index.js +4 -3
- package/dist/render.js +96 -57
- package/dist/shards.js +280 -0
- package/package.json +10 -10
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
148
|
-
|
|
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
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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 {
|
|
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.
|
|
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.
|
|
24
|
-
"@glissade/core": "0.
|
|
25
|
-
"@glissade/interact": "0.
|
|
26
|
-
"@glissade/lottie": "0.
|
|
27
|
-
"@glissade/svg": "0.
|
|
28
|
-
"@glissade/narrate": "0.
|
|
29
|
-
"@glissade/player": "0.
|
|
30
|
-
"@glissade/scene": "0.
|
|
31
|
-
"@glissade/sfx": "0.
|
|
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",
|