@glissade/cli 0.40.0 → 0.41.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 +7 -0
- package/dist/index.d.ts +12 -0
- package/dist/render.js +77 -13
- package/dist/shards.js +298 -3
- package/package.json +10 -10
package/dist/cli.js
CHANGED
|
@@ -16,6 +16,7 @@ const KNOWN_BOOLEAN_FLAGS = new Set([
|
|
|
16
16
|
"fix",
|
|
17
17
|
"no-warnings",
|
|
18
18
|
"lossless-intermediate",
|
|
19
|
+
"incremental",
|
|
19
20
|
"allow-gpu-shards",
|
|
20
21
|
"allow-system-fonts",
|
|
21
22
|
"verbose",
|
|
@@ -104,6 +105,11 @@ render options:
|
|
|
104
105
|
--lossless-intermediate render shards as FFV1 + one final encode — the guaranteed byte-correct join
|
|
105
106
|
(auto-enabled when the encoder can't honor precise boundary keyframes, e.g. mpeg4/openh264)
|
|
106
107
|
--allow-gpu-shards permit sharding a scene with GPU/shader nodes (output is not reproducible across shards; §3.7)
|
|
108
|
+
--incremental dirty-beat: re-render ONLY the frames whose per-frame key changed since the last render, splicing
|
|
109
|
+
the rest verbatim from a retained FFV1 intermediate (video out only). WINS the re-narrate / move-one-
|
|
110
|
+
beat edit that MISSES the whole-frame cache: a timing shift changes every downstream frame's key, so
|
|
111
|
+
the cache re-renders all of them — incremental re-renders only the changed run. A warm splice is
|
|
112
|
+
byte-identical to a cold --incremental render. First run builds the intermediate; edits splice it
|
|
107
113
|
--cache[=<dir>] persistent whole-frame raster cache in <dir> (default .gscache; §3.5). OFF by default — opting in
|
|
108
114
|
never changes output, only speed. A hit serves a stored frame byte-identical to a cold render.
|
|
109
115
|
WINS: repeated renders + the UNCHANGED-PREFIX of a single-segment edit. Does NOT win a re-narrate —
|
|
@@ -616,6 +622,7 @@ async function main() {
|
|
|
616
622
|
...flags.has("allow-system-fonts") ? { allowSystemFonts: true } : {},
|
|
617
623
|
...workers !== void 0 ? { workers } : {},
|
|
618
624
|
...flags.has("lossless-intermediate") ? { losslessIntermediate: true } : {},
|
|
625
|
+
...flags.has("incremental") ? { incremental: true } : {},
|
|
619
626
|
...flags.has("allow-gpu-shards") ? { allowGpuShards: true } : {},
|
|
620
627
|
...cache !== void 0 ? { cache } : {},
|
|
621
628
|
captions: parseCaptionsModeOrFail(flags.get("captions")),
|
package/dist/index.d.ts
CHANGED
|
@@ -383,6 +383,17 @@ interface RenderOptions {
|
|
|
383
383
|
* keyframes (mpeg4 / openh264).
|
|
384
384
|
*/
|
|
385
385
|
losslessIntermediate?: boolean;
|
|
386
|
+
/**
|
|
387
|
+
* --incremental (§8.1, 0.41 dirty-beat): re-render ONLY the frames whose per-frame
|
|
388
|
+
* content key changed since the last render, splicing the unchanged runs verbatim
|
|
389
|
+
* out of a retained FFV1 lossless intermediate. Kills the full re-render an edit
|
|
390
|
+
* that shifts timing (move one beat) otherwise forces — every downstream frame's
|
|
391
|
+
* DisplayList shifts, defeating both the whole-frame cache and the remux fast path.
|
|
392
|
+
* Implies the lossless-intermediate pipeline (FFV1 → single final encode); a warm
|
|
393
|
+
* splice is byte-identical to a cold `--incremental` render by construction. Video
|
|
394
|
+
* output only; requires the per-frame key (folds the same context the cache uses).
|
|
395
|
+
*/
|
|
396
|
+
incremental?: boolean;
|
|
386
397
|
/**
|
|
387
398
|
* --allow-gpu-shards (§5.6): sharded GPU/shader output isn't reproducible across
|
|
388
399
|
* processes/machines, so a scene containing a ShaderEffect refuses to shard unless
|
|
@@ -723,6 +734,7 @@ declare function renderSharded(a: RenderShardedArgs): Promise<{
|
|
|
723
734
|
frames: number;
|
|
724
735
|
out: string;
|
|
725
736
|
}>;
|
|
737
|
+
/** The FFV1 lossless intermediate retained beside a `--incremental` output for the next splice. */
|
|
726
738
|
//#endregion
|
|
727
739
|
//#region src/videoSource.d.ts
|
|
728
740
|
declare class VideoProbeError extends Error {
|
package/dist/render.js
CHANGED
|
@@ -36,6 +36,37 @@ function frameKeyDigest(keys) {
|
|
|
36
36
|
}
|
|
37
37
|
return h.digest("hex");
|
|
38
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* 0.41 dirty-beat: the changed frames between a prior render's key vector and this
|
|
41
|
+
* render's, as contiguous inclusive [start, end] index ranges (0-based within the
|
|
42
|
+
* range). `null` when incremental is impossible — no prior vector, or the frame
|
|
43
|
+
* COUNT differs (a duration change is a structural re-render, not a splice). An
|
|
44
|
+
* empty array means NOTHING changed (the remux/copy path). A returned range set
|
|
45
|
+
* names exactly the frames to re-render; every other frame is spliced verbatim
|
|
46
|
+
* from the retained lossless intermediate, so the final output is byte-identical
|
|
47
|
+
* to a full cold render (the determinism contract holds through the optimization).
|
|
48
|
+
*/
|
|
49
|
+
function changedFrameRanges(prev, now) {
|
|
50
|
+
if (prev === void 0 || prev.length !== now.length) return null;
|
|
51
|
+
const ranges = [];
|
|
52
|
+
let runStart = -1;
|
|
53
|
+
for (let i = 0; i < now.length; i++) {
|
|
54
|
+
const changed = prev[i] !== now[i];
|
|
55
|
+
if (changed && runStart < 0) runStart = i;
|
|
56
|
+
else if (!changed && runStart >= 0) {
|
|
57
|
+
ranges.push({
|
|
58
|
+
start: runStart,
|
|
59
|
+
end: i - 1
|
|
60
|
+
});
|
|
61
|
+
runStart = -1;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (runStart >= 0) ranges.push({
|
|
65
|
+
start: runStart,
|
|
66
|
+
end: now.length - 1
|
|
67
|
+
});
|
|
68
|
+
return ranges;
|
|
69
|
+
}
|
|
39
70
|
const manifestPathFor = (videoPath) => `${videoPath}.gsrender.json`;
|
|
40
71
|
/** Read the manifest beside a video output, or undefined if absent/unreadable/old-format. */
|
|
41
72
|
function readRenderManifest(videoPath) {
|
|
@@ -422,21 +453,25 @@ async function render(opts) {
|
|
|
422
453
|
}
|
|
423
454
|
let frameCache;
|
|
424
455
|
let keyCtx;
|
|
425
|
-
|
|
426
|
-
|
|
456
|
+
const cacheOn = !!(opts.cache && opts.cache.mode !== "off");
|
|
457
|
+
if (cacheOn || opts.incremental) {
|
|
458
|
+
const { capsId, combineAssetDigests } = await import("./frameCache.js").then((n) => n.s);
|
|
427
459
|
const { glissadeVersion } = await import("./version.js").then((n) => n.n);
|
|
460
|
+
keyCtx = {
|
|
461
|
+
version: glissadeVersion(),
|
|
462
|
+
capsId: capsId(backend.caps),
|
|
463
|
+
assetsDigest: combineAssetDigests(assetDigests)
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
if (cacheOn) {
|
|
467
|
+
const { FrameCache } = await import("./frameCache.js").then((n) => n.s);
|
|
428
468
|
frameCache = new FrameCache({
|
|
429
469
|
dir: opts.cache.dir,
|
|
430
470
|
mode: opts.cache.mode,
|
|
431
471
|
...opts.cache.maxSize !== void 0 ? { maxSize: opts.cache.maxSize } : {}
|
|
432
472
|
});
|
|
433
|
-
const version =
|
|
434
|
-
const caps = capsId
|
|
435
|
-
keyCtx = {
|
|
436
|
-
version,
|
|
437
|
-
capsId: caps,
|
|
438
|
-
assetsDigest: combineAssetDigests(assetDigests)
|
|
439
|
-
};
|
|
473
|
+
const version = keyCtx.version;
|
|
474
|
+
const caps = keyCtx.capsId;
|
|
440
475
|
const { LayerCache } = await import("./layerCache.js");
|
|
441
476
|
backend.setLayerStore(new LayerCache({
|
|
442
477
|
dir: join(opts.cache.dir, "layers"),
|
|
@@ -446,6 +481,7 @@ async function render(opts) {
|
|
|
446
481
|
}
|
|
447
482
|
let videoOut;
|
|
448
483
|
let remuxDigest;
|
|
484
|
+
let remuxKeys;
|
|
449
485
|
if (isVideo) {
|
|
450
486
|
const outAbs = resolve(opts.out);
|
|
451
487
|
const container = /\.webm$/i.test(outAbs) ? "webm" : "mp4";
|
|
@@ -475,10 +511,36 @@ async function render(opts) {
|
|
|
475
511
|
fps,
|
|
476
512
|
firstFrame,
|
|
477
513
|
frames: total
|
|
478
|
-
}, true))
|
|
514
|
+
}, true)) {
|
|
515
|
+
remuxDigest = digest;
|
|
516
|
+
remuxKeys = keys;
|
|
517
|
+
}
|
|
479
518
|
}
|
|
480
519
|
}
|
|
481
520
|
}
|
|
521
|
+
if (opts.incremental && isVideo && !remuxDigest && keyCtx && total > 1 && videoOut) {
|
|
522
|
+
const { sceneHasGpuNodes, renderIncremental } = await import("./shards.js").then((n) => n.i);
|
|
523
|
+
if (sceneHasGpuNodes(scene) && !opts.allowGpuShards) process.stderr.write("note: --incremental skipped — scene has GPU/shader nodes (not reproducible across the splice child process); pass --allow-gpu-shards to override\n");
|
|
524
|
+
else {
|
|
525
|
+
backend.dispose();
|
|
526
|
+
for (const source of videoSources) source.close();
|
|
527
|
+
return renderIncremental({
|
|
528
|
+
opts,
|
|
529
|
+
scene,
|
|
530
|
+
doc,
|
|
531
|
+
compiled,
|
|
532
|
+
keyCtx,
|
|
533
|
+
fps,
|
|
534
|
+
duration,
|
|
535
|
+
firstFrame,
|
|
536
|
+
lastFrame,
|
|
537
|
+
container: videoOut.container,
|
|
538
|
+
timingPathFor,
|
|
539
|
+
writeCaptionSidecars,
|
|
540
|
+
writeCueSidecars
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
}
|
|
482
544
|
const frameKeys = [];
|
|
483
545
|
if (!remuxDigest) for (let f = firstFrame; f <= lastFrame; f++) {
|
|
484
546
|
const dl = withDeterminismGuards("throw", () => evaluate(scene, doc, f / fps));
|
|
@@ -589,7 +651,8 @@ async function render(opts) {
|
|
|
589
651
|
videoCodec: videoOut.encName,
|
|
590
652
|
fps,
|
|
591
653
|
firstFrame,
|
|
592
|
-
frames: total
|
|
654
|
+
frames: total,
|
|
655
|
+
...remuxKeys && remuxKeys.length === total ? { frameKeys: remuxKeys } : {}
|
|
593
656
|
});
|
|
594
657
|
process.stderr.write(`cache: ${total}/${total} frames unchanged (audio-only) — video copy + remux → ${outAbs}\n`);
|
|
595
658
|
return {
|
|
@@ -650,7 +713,8 @@ async function render(opts) {
|
|
|
650
713
|
videoCodec: videoOut.encName,
|
|
651
714
|
fps,
|
|
652
715
|
firstFrame,
|
|
653
|
-
frames: total
|
|
716
|
+
frames: total,
|
|
717
|
+
frameKeys
|
|
654
718
|
});
|
|
655
719
|
return {
|
|
656
720
|
frames: total,
|
|
@@ -883,4 +947,4 @@ async function buildMixWav(opts, wavOut) {
|
|
|
883
947
|
});
|
|
884
948
|
}
|
|
885
949
|
//#endregion
|
|
886
|
-
export { ffmpegAvailable as a, parseFrameRange as c, render as d, renderLocales as f, collectAudioClips as i, parseLocalesList as l, resolveLoudnessGainDb as m, SceneModuleError as n, loadSceneModule as o, render_exports as p, buildMixWav as r, localeOutPath as s, LocaleArgsError as t, planFinalAudio as u };
|
|
950
|
+
export { readRenderManifest as _, ffmpegAvailable as a, parseFrameRange as c, render as d, renderLocales as f, frameKeyDigest as g, changedFrameRanges as h, collectAudioClips as i, parseLocalesList as l, resolveLoudnessGainDb as m, SceneModuleError as n, loadSceneModule as o, render_exports as p, buildMixWav as r, localeOutPath as s, LocaleArgsError as t, planFinalAudio as u, writeRenderManifest as v };
|
package/dist/shards.js
CHANGED
|
@@ -1,12 +1,83 @@
|
|
|
1
1
|
import { t as __exportAll } from "./rolldown-runtime.js";
|
|
2
|
-
import { u as planFinalAudio } from "./render.js";
|
|
2
|
+
import { _ as readRenderManifest, g as frameKeyDigest, h as changedFrameRanges, u as planFinalAudio, v as writeRenderManifest } from "./render.js";
|
|
3
3
|
import { a as pickEncoder } from "./encoders.js";
|
|
4
4
|
import { spawnSync } from "node:child_process";
|
|
5
|
-
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
7
|
import { dirname, join, resolve } from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
|
-
import { ShaderEffect } from "@glissade/scene";
|
|
9
|
+
import { ShaderEffect, evaluate, withDeterminismGuards } from "@glissade/scene";
|
|
10
|
+
//#region src/incremental.ts
|
|
11
|
+
/**
|
|
12
|
+
* Dirty-beat incremental (0.41): render ONLY the frames whose per-frame content
|
|
13
|
+
* key actually changed, and splice the rest — verbatim, byte-identical — from a
|
|
14
|
+
* lossless FFV1 intermediate retained beside the prior output.
|
|
15
|
+
*
|
|
16
|
+
* The pain it kills: an edit that shifts timing (move one beat) changes every
|
|
17
|
+
* DOWNSTREAM frame's DisplayList, so every whole-frame cache key misses AND the
|
|
18
|
+
* rolled-up remux digest flips — a 35-min episode re-renders in full even though
|
|
19
|
+
* the visible change is three seconds long. The per-frame key VECTOR (persisted
|
|
20
|
+
* in the manifest, {@link RenderManifest.frameKeys}) lets us diff old→new and
|
|
21
|
+
* re-render exactly the changed runs.
|
|
22
|
+
*
|
|
23
|
+
* Determinism (the north star): FFV1 is intra-only and lossless, so a kept
|
|
24
|
+
* segment decodes to byte-identical pixels and the single final encode over the
|
|
25
|
+
* spliced stream is byte-for-byte a full cold render. The perf optimization does
|
|
26
|
+
* NOT touch the determinism contract — the frame key is the same proof the frame
|
|
27
|
+
* cache and the golden corpus trust. This module is PURE planning; the ffmpeg
|
|
28
|
+
* splice execution (EXPORT-gated) consumes the plan.
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* The complement-and-interleave: given the total frame count and the CHANGED
|
|
32
|
+
* ranges (from {@link changedFrameRanges}), produce the ordered, gap-free segment
|
|
33
|
+
* list covering `[0, total-1]` — changed runs become `render`, everything between
|
|
34
|
+
* them becomes `keep`. Adjacent same-kind segments never occur (changed ranges are
|
|
35
|
+
* already coalesced and disjoint), so the list alternates.
|
|
36
|
+
*/
|
|
37
|
+
function spliceSegments(total, changed) {
|
|
38
|
+
const segs = [];
|
|
39
|
+
let cursor = 0;
|
|
40
|
+
for (const r of changed) {
|
|
41
|
+
if (r.start > cursor) segs.push({
|
|
42
|
+
start: cursor,
|
|
43
|
+
end: r.start - 1,
|
|
44
|
+
kind: "keep"
|
|
45
|
+
});
|
|
46
|
+
segs.push({
|
|
47
|
+
start: r.start,
|
|
48
|
+
end: r.end,
|
|
49
|
+
kind: "render"
|
|
50
|
+
});
|
|
51
|
+
cursor = r.end + 1;
|
|
52
|
+
}
|
|
53
|
+
if (cursor < total) segs.push({
|
|
54
|
+
start: cursor,
|
|
55
|
+
end: total - 1,
|
|
56
|
+
kind: "keep"
|
|
57
|
+
});
|
|
58
|
+
return segs;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Decide the incremental strategy. Pure: the caller supplies the prior manifest,
|
|
62
|
+
* this render's ordered keys, and whether the retained FFV1 intermediate exists.
|
|
63
|
+
* Encode-parameter changes (codec/container/fps/range) force `full` — a kept
|
|
64
|
+
* segment is only byte-faithful if the surrounding encode is identical.
|
|
65
|
+
*/
|
|
66
|
+
function planIncremental(prev, nowKeys, intermediateExists, encode) {
|
|
67
|
+
if (prev === void 0 || !intermediateExists || prev.container !== encode.container || prev.videoCodec !== encode.videoCodec || prev.fps !== encode.fps || prev.firstFrame !== encode.firstFrame || prev.frames !== encode.frames) return { kind: "full" };
|
|
68
|
+
const changed = changedFrameRanges(prev.frameKeys, nowKeys);
|
|
69
|
+
if (changed === null) return { kind: "full" };
|
|
70
|
+
if (changed.length === 0) return { kind: "unchanged" };
|
|
71
|
+
const renderFrames = changed.reduce((n, r) => n + (r.end - r.start + 1), 0);
|
|
72
|
+
return {
|
|
73
|
+
kind: "splice",
|
|
74
|
+
changed,
|
|
75
|
+
segments: spliceSegments(nowKeys.length, changed),
|
|
76
|
+
renderFrames,
|
|
77
|
+
totalFrames: nowKeys.length
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
//#endregion
|
|
10
81
|
//#region src/shards.ts
|
|
11
82
|
/**
|
|
12
83
|
* Sharded export (DESIGN.md §5.6, §8.1 item 1): `gs render --workers N` splits
|
|
@@ -32,6 +103,8 @@ import { ShaderEffect } from "@glissade/scene";
|
|
|
32
103
|
*/
|
|
33
104
|
var shards_exports = /* @__PURE__ */ __exportAll({
|
|
34
105
|
ShardError: () => ShardError,
|
|
106
|
+
intermediatePathFor: () => intermediatePathFor,
|
|
107
|
+
renderIncremental: () => renderIncremental,
|
|
35
108
|
renderSharded: () => renderSharded,
|
|
36
109
|
sceneHasGpuNodes: () => sceneHasGpuNodes,
|
|
37
110
|
splitFrameRange: () => splitFrameRange
|
|
@@ -278,5 +351,227 @@ async function renderSharded(a) {
|
|
|
278
351
|
out: outAbs
|
|
279
352
|
};
|
|
280
353
|
}
|
|
354
|
+
/** The FFV1 lossless intermediate retained beside a `--incremental` output for the next splice. */
|
|
355
|
+
function intermediatePathFor(videoPath) {
|
|
356
|
+
return `${videoPath}.gsintermediate.mkv`;
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Dirty-beat incremental render (0.41). Computes the per-frame key vector, diffs
|
|
360
|
+
* it against the prior manifest, and re-renders ONLY the changed frame runs —
|
|
361
|
+
* splicing the unchanged runs verbatim from the retained FFV1 intermediate.
|
|
362
|
+
*
|
|
363
|
+
* ONE pipeline for every outcome: each output segment becomes an FFV1 clip (a
|
|
364
|
+
* `render` segment from a fresh child render; a `keep` segment trimmed losslessly
|
|
365
|
+
* out of the prior intermediate), the clips concat-copy into the new intermediate,
|
|
366
|
+
* and a single final encode produces the output. A cold `--incremental` render is
|
|
367
|
+
* the degenerate all-`render` case, so a warm splice is byte-identical to a cold
|
|
368
|
+
* full render by construction: FFV1 is lossless and intra-only, so a kept segment
|
|
369
|
+
* decodes to the exact pixels a re-render would, and the final encode over the
|
|
370
|
+
* spliced stream is byte-for-byte the cold render. That IS the determinism
|
|
371
|
+
* contract, preserved THROUGH the optimization (the per-frame key is the same
|
|
372
|
+
* proof the frame cache and golden corpus trust).
|
|
373
|
+
*/
|
|
374
|
+
async function renderIncremental(a) {
|
|
375
|
+
const { opts, scene, doc, compiled, keyCtx, fps, duration, firstFrame, lastFrame, container } = a;
|
|
376
|
+
const outAbs = resolve(opts.out);
|
|
377
|
+
const total = lastFrame - firstFrame + 1;
|
|
378
|
+
const finalEnc = pickEncoder("video", container);
|
|
379
|
+
if (finalEnc.note) process.stderr.write(`note: ${finalEnc.note}\n`);
|
|
380
|
+
const { frameCacheKey } = await import("./frameCache.js").then((n) => n.s);
|
|
381
|
+
const frameKeys = [];
|
|
382
|
+
for (let f = firstFrame; f <= lastFrame; f++) {
|
|
383
|
+
const dl = withDeterminismGuards("throw", () => evaluate(scene, doc, f / fps));
|
|
384
|
+
frameKeys.push(frameCacheKey(dl, keyCtx));
|
|
385
|
+
}
|
|
386
|
+
const newDigest = frameKeyDigest(frameKeys);
|
|
387
|
+
const intermediate = intermediatePathFor(outAbs);
|
|
388
|
+
const prev = readRenderManifest(outAbs);
|
|
389
|
+
const plan = planIncremental(prev, frameKeys, existsSync(intermediate), {
|
|
390
|
+
container,
|
|
391
|
+
videoCodec: finalEnc.name,
|
|
392
|
+
fps,
|
|
393
|
+
firstFrame,
|
|
394
|
+
frames: total
|
|
395
|
+
});
|
|
396
|
+
const segments = plan.kind === "splice" ? [...plan.segments] : plan.kind === "unchanged" ? [{
|
|
397
|
+
start: 0,
|
|
398
|
+
end: total - 1,
|
|
399
|
+
kind: "keep"
|
|
400
|
+
}] : [{
|
|
401
|
+
start: 0,
|
|
402
|
+
end: total - 1,
|
|
403
|
+
kind: "render"
|
|
404
|
+
}];
|
|
405
|
+
const renderFrames = segments.filter((s) => s.kind === "render").reduce((n, s) => n + (s.end - s.start + 1), 0);
|
|
406
|
+
process.stderr.write(plan.kind === "splice" ? `incremental: ${renderFrames}/${total} frames changed — re-rendering those, splicing ${total - renderFrames} from the intermediate\n` : plan.kind === "unchanged" ? `incremental: 0/${total} frames changed — re-using the intermediate verbatim\n` : `incremental: full render (${prev ? "ineligible for splice" : "no prior intermediate"}) — building the intermediate for next time\n`);
|
|
407
|
+
const work = mkdtempSync(join(tmpdir(), "glissade-incr-"));
|
|
408
|
+
const segVideos = [];
|
|
409
|
+
let done = 0;
|
|
410
|
+
try {
|
|
411
|
+
for (let i = 0; i < segments.length; i++) {
|
|
412
|
+
const seg = segments[i];
|
|
413
|
+
const segVideo = join(work, `seg-${String(i).padStart(3, "0")}.mkv`);
|
|
414
|
+
if (seg.kind === "keep") {
|
|
415
|
+
const t = spawnSync("ffmpeg", [
|
|
416
|
+
"-y",
|
|
417
|
+
"-i",
|
|
418
|
+
intermediate,
|
|
419
|
+
"-vf",
|
|
420
|
+
`trim=start_frame=${seg.start}:end_frame=${seg.end + 1},setpts=PTS-STARTPTS`,
|
|
421
|
+
"-fps_mode",
|
|
422
|
+
"passthrough",
|
|
423
|
+
"-c:v",
|
|
424
|
+
"ffv1",
|
|
425
|
+
"-level",
|
|
426
|
+
"3",
|
|
427
|
+
"-pix_fmt",
|
|
428
|
+
"rgb24",
|
|
429
|
+
segVideo
|
|
430
|
+
], { stdio: [
|
|
431
|
+
"ignore",
|
|
432
|
+
"ignore",
|
|
433
|
+
"pipe"
|
|
434
|
+
] });
|
|
435
|
+
if (t.status !== 0) throw new ShardError(`incremental keep-trim [${seg.start}..${seg.end}] failed (exit ${t.status}):\n${t.stderr?.toString().slice(-2e3)}`);
|
|
436
|
+
} else {
|
|
437
|
+
const segFrames = join(work, `seg-${String(i).padStart(3, "0")}-frames`);
|
|
438
|
+
mkdirSync(segFrames, { recursive: true });
|
|
439
|
+
const first = firstFrame + seg.start;
|
|
440
|
+
const last = firstFrame + seg.end;
|
|
441
|
+
const childArgs = [
|
|
442
|
+
cliEntry(),
|
|
443
|
+
"render",
|
|
444
|
+
opts.modulePath,
|
|
445
|
+
"--out",
|
|
446
|
+
segFrames,
|
|
447
|
+
"--range",
|
|
448
|
+
`${first}..${last}`,
|
|
449
|
+
"--format",
|
|
450
|
+
"png-seq",
|
|
451
|
+
"--fps",
|
|
452
|
+
String(fps),
|
|
453
|
+
"--narration",
|
|
454
|
+
"off",
|
|
455
|
+
"--music",
|
|
456
|
+
"off",
|
|
457
|
+
"--sfx",
|
|
458
|
+
"off",
|
|
459
|
+
...opts.force ? ["--force"] : [],
|
|
460
|
+
...opts.strictFonts ? ["--strict"] : [],
|
|
461
|
+
...opts.allowSystemFonts ? ["--allow-system-fonts"] : []
|
|
462
|
+
];
|
|
463
|
+
const child = spawnSync(process.execPath, childArgs, { stdio: [
|
|
464
|
+
"ignore",
|
|
465
|
+
"ignore",
|
|
466
|
+
"pipe"
|
|
467
|
+
] });
|
|
468
|
+
if (child.status !== 0) throw new ShardError(`incremental render [${first}..${last}] failed (exit ${child.status}):\n${child.stderr?.toString().slice(-2e3)}`);
|
|
469
|
+
const enc = spawnSync("ffmpeg", [
|
|
470
|
+
"-y",
|
|
471
|
+
"-framerate",
|
|
472
|
+
String(fps),
|
|
473
|
+
"-start_number",
|
|
474
|
+
String(first),
|
|
475
|
+
"-i",
|
|
476
|
+
join(segFrames, "frame-%05d.png"),
|
|
477
|
+
"-c:v",
|
|
478
|
+
"ffv1",
|
|
479
|
+
"-level",
|
|
480
|
+
"3",
|
|
481
|
+
"-pix_fmt",
|
|
482
|
+
"rgb24",
|
|
483
|
+
segVideo
|
|
484
|
+
], { stdio: [
|
|
485
|
+
"ignore",
|
|
486
|
+
"ignore",
|
|
487
|
+
"pipe"
|
|
488
|
+
] });
|
|
489
|
+
if (enc.status !== 0) throw new ShardError(`incremental segment encode [${first}..${last}] failed (exit ${enc.status}):\n${enc.stderr?.toString().slice(-2e3)}`);
|
|
490
|
+
rmSync(segFrames, {
|
|
491
|
+
recursive: true,
|
|
492
|
+
force: true
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
segVideos.push(segVideo);
|
|
496
|
+
done += seg.end - seg.start + 1;
|
|
497
|
+
opts.onProgress?.(Math.min(done, total), total);
|
|
498
|
+
}
|
|
499
|
+
const listFile = join(work, "concat.txt");
|
|
500
|
+
writeFileSync(listFile, segVideos.map((p) => `file '${p.replace(/'/g, "'\\''")}'`).join("\n") + "\n");
|
|
501
|
+
const newIntermediate = join(work, "intermediate.mkv");
|
|
502
|
+
const concat = spawnSync("ffmpeg", [
|
|
503
|
+
"-y",
|
|
504
|
+
"-f",
|
|
505
|
+
"concat",
|
|
506
|
+
"-safe",
|
|
507
|
+
"0",
|
|
508
|
+
"-i",
|
|
509
|
+
listFile,
|
|
510
|
+
"-c",
|
|
511
|
+
"copy",
|
|
512
|
+
newIntermediate
|
|
513
|
+
], { stdio: [
|
|
514
|
+
"ignore",
|
|
515
|
+
"ignore",
|
|
516
|
+
"pipe"
|
|
517
|
+
] });
|
|
518
|
+
if (concat.status !== 0) throw new ShardError(`incremental concat failed (exit ${concat.status}):\n${concat.stderr?.toString().slice(-2e3)}`);
|
|
519
|
+
const { audioInputs, audioArgs } = await planFinalAudio(opts, [...compiled.audio], duration, container);
|
|
520
|
+
const fin = spawnSync("ffmpeg", [
|
|
521
|
+
"-y",
|
|
522
|
+
"-i",
|
|
523
|
+
newIntermediate,
|
|
524
|
+
...audioInputs,
|
|
525
|
+
...audioArgs,
|
|
526
|
+
"-c:v",
|
|
527
|
+
finalEnc.name,
|
|
528
|
+
...VIDEO_QUALITY[finalEnc.name] ?? [],
|
|
529
|
+
...container === "webm" ? [] : [
|
|
530
|
+
"-pix_fmt",
|
|
531
|
+
"yuv420p",
|
|
532
|
+
"-movflags",
|
|
533
|
+
"+faststart"
|
|
534
|
+
],
|
|
535
|
+
"-t",
|
|
536
|
+
String(duration),
|
|
537
|
+
outAbs
|
|
538
|
+
], { stdio: [
|
|
539
|
+
"ignore",
|
|
540
|
+
"ignore",
|
|
541
|
+
"pipe"
|
|
542
|
+
] });
|
|
543
|
+
if (fin.status !== 0) throw new ShardError(`incremental final encode failed (exit ${fin.status}):\n${fin.stderr?.toString().slice(-2e3)}`);
|
|
544
|
+
rmSync(intermediate, { force: true });
|
|
545
|
+
renameSync(newIntermediate, intermediate);
|
|
546
|
+
writeRenderManifest(outAbs, {
|
|
547
|
+
v: 1,
|
|
548
|
+
frameKeyDigest: newDigest,
|
|
549
|
+
frameKeys,
|
|
550
|
+
container,
|
|
551
|
+
videoCodec: finalEnc.name,
|
|
552
|
+
fps,
|
|
553
|
+
firstFrame,
|
|
554
|
+
frames: total
|
|
555
|
+
});
|
|
556
|
+
} finally {
|
|
557
|
+
rmSync(work, {
|
|
558
|
+
recursive: true,
|
|
559
|
+
force: true
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
if ((opts.captions ?? "burn") !== "off") {
|
|
563
|
+
const timingPath = a.timingPathFor(opts.modulePath);
|
|
564
|
+
if (timingPath) {
|
|
565
|
+
const { srt, vtt } = a.writeCaptionSidecars(timingPath, outAbs);
|
|
566
|
+
process.stderr.write(`captions: ${srt}, ${vtt}\n`);
|
|
567
|
+
} else if (opts.captions === "sidecar") process.stderr.write("note: --captions sidecar: no narration timing manifest found; run gs narrate first\n");
|
|
568
|
+
}
|
|
569
|
+
const cueFiles = a.writeCueSidecars(outAbs, compiled.markers, duration, opts.chapters === "vtt", opts.chapterKinds);
|
|
570
|
+
if (cueFiles.length) process.stderr.write(`cues: ${cueFiles.join(", ")}\n`);
|
|
571
|
+
return {
|
|
572
|
+
frames: total,
|
|
573
|
+
out: outAbs
|
|
574
|
+
};
|
|
575
|
+
}
|
|
281
576
|
//#endregion
|
|
282
577
|
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.41.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.41.0-pre.0",
|
|
32
|
+
"@glissade/core": "0.41.0-pre.0",
|
|
33
|
+
"@glissade/interact": "0.41.0-pre.0",
|
|
34
|
+
"@glissade/lottie": "0.41.0-pre.0",
|
|
35
|
+
"@glissade/narrate": "0.41.0-pre.0",
|
|
36
|
+
"@glissade/player": "0.41.0-pre.0",
|
|
37
|
+
"@glissade/scene": "0.41.0-pre.0",
|
|
38
|
+
"@glissade/sfx": "0.41.0-pre.0",
|
|
39
|
+
"@glissade/svg": "0.41.0-pre.0"
|
|
40
40
|
},
|
|
41
41
|
"repository": {
|
|
42
42
|
"type": "git",
|