@glissade/cli 0.40.0 → 0.41.0-pre.1
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/cacheVerify.js +1 -1
- package/dist/cli.js +7 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +1 -1
- package/dist/mcp.js +1 -1
- package/dist/render.js +122 -16
- package/dist/shards.js +298 -3
- package/dist/version.js +1 -3
- package/package.json +10 -10
package/dist/cacheVerify.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { t as __exportAll } from "./rolldown-runtime.js";
|
|
2
|
-
import { o as loadSceneModule } from "./render.js";
|
|
3
2
|
import { t as glissadeVersion } from "./version.js";
|
|
3
|
+
import { o as loadSceneModule } from "./render.js";
|
|
4
4
|
import { i as capsId, n as FrameCache, o as frameCacheKey } from "./frameCache.js";
|
|
5
5
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
6
6
|
import { tmpdir } from "node:os";
|
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/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { a as ffmpegAvailable, d as render, f as renderLocales, i as collectAudioClips, l as parseLocalesList, m as resolveLoudnessGainDb, n as SceneModuleError, o as loadSceneModule, r as buildMixWav, s as localeOutPath, t as LocaleArgsError, u as planFinalAudio } from "./render.js";
|
|
2
1
|
import { t as glissadeVersion } from "./version.js";
|
|
2
|
+
import { a as ffmpegAvailable, d as render, f as renderLocales, i as collectAudioClips, l as parseLocalesList, m as resolveLoudnessGainDb, n as SceneModuleError, o as loadSceneModule, r as buildMixWav, s as localeOutPath, t as LocaleArgsError, u as planFinalAudio } from "./render.js";
|
|
3
3
|
import { a as computeGainDb, d as parseLoudnormJson, f as peakClampBinds, i as PUBLISH_PROFILES, l as measureFile, m as resolveProfile, n as LOUDNESS_SCHEMA_VERSION, o as computeMixHash, p as readLoudness, r as LoudnessError, s as loudnessPathFor, t as DEFAULT_PROFILE_ID, u as measureLoudnessCommand } from "./loudness.js";
|
|
4
4
|
import { i as masterCommand, n as MasterError, o as normalizeMasterConfig, r as masterAfChain, s as planMaster, t as DEFAULT_MAX_GR_DB } from "./master.js";
|
|
5
5
|
import { a as pickEncoder, i as parseEncoderList, n as availableEncoders, t as NoEncoderError } from "./encoders.js";
|
package/dist/mcp.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { o as loadSceneModule } from "./render.js";
|
|
2
1
|
import { t as glissadeVersion } from "./version.js";
|
|
2
|
+
import { o as loadSceneModule } from "./render.js";
|
|
3
3
|
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
package/dist/render.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { t as __exportAll } from "./rolldown-runtime.js";
|
|
2
|
+
import { t as glissadeVersion } from "./version.js";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
2
4
|
import { spawnSync } from "node:child_process";
|
|
3
5
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
4
6
|
import { tmpdir } from "node:os";
|
|
@@ -36,6 +38,37 @@ function frameKeyDigest(keys) {
|
|
|
36
38
|
}
|
|
37
39
|
return h.digest("hex");
|
|
38
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* 0.41 dirty-beat: the changed frames between a prior render's key vector and this
|
|
43
|
+
* render's, as contiguous inclusive [start, end] index ranges (0-based within the
|
|
44
|
+
* range). `null` when incremental is impossible — no prior vector, or the frame
|
|
45
|
+
* COUNT differs (a duration change is a structural re-render, not a splice). An
|
|
46
|
+
* empty array means NOTHING changed (the remux/copy path). A returned range set
|
|
47
|
+
* names exactly the frames to re-render; every other frame is spliced verbatim
|
|
48
|
+
* from the retained lossless intermediate, so the final output is byte-identical
|
|
49
|
+
* to a full cold render (the determinism contract holds through the optimization).
|
|
50
|
+
*/
|
|
51
|
+
function changedFrameRanges(prev, now) {
|
|
52
|
+
if (prev === void 0 || prev.length !== now.length) return null;
|
|
53
|
+
const ranges = [];
|
|
54
|
+
let runStart = -1;
|
|
55
|
+
for (let i = 0; i < now.length; i++) {
|
|
56
|
+
const changed = prev[i] !== now[i];
|
|
57
|
+
if (changed && runStart < 0) runStart = i;
|
|
58
|
+
else if (!changed && runStart >= 0) {
|
|
59
|
+
ranges.push({
|
|
60
|
+
start: runStart,
|
|
61
|
+
end: i - 1
|
|
62
|
+
});
|
|
63
|
+
runStart = -1;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (runStart >= 0) ranges.push({
|
|
67
|
+
start: runStart,
|
|
68
|
+
end: now.length - 1
|
|
69
|
+
});
|
|
70
|
+
return ranges;
|
|
71
|
+
}
|
|
39
72
|
const manifestPathFor = (videoPath) => `${videoPath}.gsrender.json`;
|
|
40
73
|
/** Read the manifest beside a video output, or undefined if absent/unreadable/old-format. */
|
|
41
74
|
function readRenderManifest(videoPath) {
|
|
@@ -262,6 +295,45 @@ function parseFrameRange(flag) {
|
|
|
262
295
|
if (b < a) throw new Error(`--range end (${b}) is before start (${a})`);
|
|
263
296
|
return [a, b];
|
|
264
297
|
}
|
|
298
|
+
/**
|
|
299
|
+
* Resolve the `@glissade/core` version the SCENE will bind to (anchored at the
|
|
300
|
+
* scene's own directory, so it's the copy in the user's project), by resolving
|
|
301
|
+
* core's entry and walking up to its package root — `@glissade/core/package.json`
|
|
302
|
+
* isn't an exported subpath, so it can't be required directly. Returns undefined
|
|
303
|
+
* if it can't be resolved (an unusual layout) — the caller then skips the check.
|
|
304
|
+
*/
|
|
305
|
+
function resolveSceneCoreVersion(scenePath) {
|
|
306
|
+
try {
|
|
307
|
+
let dir = dirname(createRequire(pathToFileURL(scenePath)).resolve("@glissade/core"));
|
|
308
|
+
for (let i = 0; i < 8; i++) {
|
|
309
|
+
const pkgPath = join(dir, "package.json");
|
|
310
|
+
if (existsSync(pkgPath)) {
|
|
311
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
312
|
+
if (pkg.name === "@glissade/core") return pkg.version;
|
|
313
|
+
}
|
|
314
|
+
const parent = dirname(dir);
|
|
315
|
+
if (parent === dir) break;
|
|
316
|
+
dir = parent;
|
|
317
|
+
}
|
|
318
|
+
} catch {}
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Warn (once, loudly) on a `@glissade/*` version SKEW between the running `gs`
|
|
322
|
+
* (cli) and the `@glissade/core` the scene resolves. glissade is lockstep, and
|
|
323
|
+
* the subpath side-effect registries (`/expr`'s sampler, Yoga `layout`) register
|
|
324
|
+
* per-package-INSTANCE — so under a skew a correctly-imported `@glissade/core/expr`
|
|
325
|
+
* or `layout` still fails with a misleading "expr tracks need import …" / "no
|
|
326
|
+
* LayoutEngine registered". Naming the skew up front turns a confusing failure
|
|
327
|
+
* into an actionable one. A warning, never a throw: legitimate edge layouts exist,
|
|
328
|
+
* and the check must never break a render (video-canary 0.41 adopt finding).
|
|
329
|
+
*/
|
|
330
|
+
function warnOnVersionSkew(scenePath) {
|
|
331
|
+
const cliVer = glissadeVersion();
|
|
332
|
+
if (cliVer === "0.0.0") return;
|
|
333
|
+
const coreVer = resolveSceneCoreVersion(scenePath);
|
|
334
|
+
if (coreVer === void 0 || coreVer === cliVer) return;
|
|
335
|
+
process.stderr.write(`warning: @glissade version skew — gs (@glissade/cli@${cliVer}) is rendering a scene that resolves @glissade/core@${coreVer}.\n glissade is LOCKSTEP: subpath features register per-package-instance (the /expr sampler, Yoga layout), so under a skew a\n correctly-imported '@glissade/core/expr' or 'layout' can still fail with "need import" / "no LayoutEngine registered".\n Align every @glissade/* dependency to ${cliVer} (e.g. npm i @glissade/core@${cliVer} @glissade/scene@${cliVer}).\n`);
|
|
336
|
+
}
|
|
265
337
|
async function loadSceneModule(modulePath, locale) {
|
|
266
338
|
const { setMessageTable } = await import("@glissade/core/i18n");
|
|
267
339
|
if (locale !== void 0 && locale !== "") {
|
|
@@ -269,13 +341,15 @@ async function loadSceneModule(modulePath, locale) {
|
|
|
269
341
|
setMessageTable(loadMessageTable(modulePath, locale));
|
|
270
342
|
} else setMessageTable(void 0);
|
|
271
343
|
const abs = isAbsolute(modulePath) ? modulePath : resolve(process.cwd(), modulePath);
|
|
272
|
-
|
|
344
|
+
const candidates = [
|
|
273
345
|
abs,
|
|
274
346
|
abs.replace(/\.js$/, ".ts"),
|
|
275
347
|
abs.replace(/\.ts$/, ".js"),
|
|
276
348
|
`${abs}.ts`,
|
|
277
349
|
`${abs}.js`
|
|
278
|
-
]
|
|
350
|
+
];
|
|
351
|
+
if (!candidates.some((c) => existsSync(c))) throw new SceneModuleError(modulePath, "scene module not found (check the path)");
|
|
352
|
+
warnOnVersionSkew(candidates.find((c) => existsSync(c)) ?? abs);
|
|
279
353
|
const loaded = await createJiti(pathToFileURL(process.cwd() + "/").href).import(pathToFileURL(abs).href, { default: true });
|
|
280
354
|
if (typeof loaded?.createScene !== "function" || loaded?.timeline === void 0) throw new SceneModuleError(modulePath, "default export is not a SceneModule");
|
|
281
355
|
return loaded;
|
|
@@ -422,21 +496,24 @@ async function render(opts) {
|
|
|
422
496
|
}
|
|
423
497
|
let frameCache;
|
|
424
498
|
let keyCtx;
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
const {
|
|
499
|
+
const cacheOn = !!(opts.cache && opts.cache.mode !== "off");
|
|
500
|
+
if (cacheOn || opts.incremental) {
|
|
501
|
+
const { capsId, combineAssetDigests } = await import("./frameCache.js").then((n) => n.s);
|
|
502
|
+
keyCtx = {
|
|
503
|
+
version: glissadeVersion(),
|
|
504
|
+
capsId: capsId(backend.caps),
|
|
505
|
+
assetsDigest: combineAssetDigests(assetDigests)
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
if (cacheOn) {
|
|
509
|
+
const { FrameCache } = await import("./frameCache.js").then((n) => n.s);
|
|
428
510
|
frameCache = new FrameCache({
|
|
429
511
|
dir: opts.cache.dir,
|
|
430
512
|
mode: opts.cache.mode,
|
|
431
513
|
...opts.cache.maxSize !== void 0 ? { maxSize: opts.cache.maxSize } : {}
|
|
432
514
|
});
|
|
433
|
-
const version =
|
|
434
|
-
const caps = capsId
|
|
435
|
-
keyCtx = {
|
|
436
|
-
version,
|
|
437
|
-
capsId: caps,
|
|
438
|
-
assetsDigest: combineAssetDigests(assetDigests)
|
|
439
|
-
};
|
|
515
|
+
const version = keyCtx.version;
|
|
516
|
+
const caps = keyCtx.capsId;
|
|
440
517
|
const { LayerCache } = await import("./layerCache.js");
|
|
441
518
|
backend.setLayerStore(new LayerCache({
|
|
442
519
|
dir: join(opts.cache.dir, "layers"),
|
|
@@ -446,6 +523,7 @@ async function render(opts) {
|
|
|
446
523
|
}
|
|
447
524
|
let videoOut;
|
|
448
525
|
let remuxDigest;
|
|
526
|
+
let remuxKeys;
|
|
449
527
|
if (isVideo) {
|
|
450
528
|
const outAbs = resolve(opts.out);
|
|
451
529
|
const container = /\.webm$/i.test(outAbs) ? "webm" : "mp4";
|
|
@@ -475,10 +553,36 @@ async function render(opts) {
|
|
|
475
553
|
fps,
|
|
476
554
|
firstFrame,
|
|
477
555
|
frames: total
|
|
478
|
-
}, true))
|
|
556
|
+
}, true)) {
|
|
557
|
+
remuxDigest = digest;
|
|
558
|
+
remuxKeys = keys;
|
|
559
|
+
}
|
|
479
560
|
}
|
|
480
561
|
}
|
|
481
562
|
}
|
|
563
|
+
if (opts.incremental && isVideo && !remuxDigest && keyCtx && total > 1 && videoOut) {
|
|
564
|
+
const { sceneHasGpuNodes, renderIncremental } = await import("./shards.js").then((n) => n.i);
|
|
565
|
+
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");
|
|
566
|
+
else {
|
|
567
|
+
backend.dispose();
|
|
568
|
+
for (const source of videoSources) source.close();
|
|
569
|
+
return renderIncremental({
|
|
570
|
+
opts,
|
|
571
|
+
scene,
|
|
572
|
+
doc,
|
|
573
|
+
compiled,
|
|
574
|
+
keyCtx,
|
|
575
|
+
fps,
|
|
576
|
+
duration,
|
|
577
|
+
firstFrame,
|
|
578
|
+
lastFrame,
|
|
579
|
+
container: videoOut.container,
|
|
580
|
+
timingPathFor,
|
|
581
|
+
writeCaptionSidecars,
|
|
582
|
+
writeCueSidecars
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
}
|
|
482
586
|
const frameKeys = [];
|
|
483
587
|
if (!remuxDigest) for (let f = firstFrame; f <= lastFrame; f++) {
|
|
484
588
|
const dl = withDeterminismGuards("throw", () => evaluate(scene, doc, f / fps));
|
|
@@ -589,7 +693,8 @@ async function render(opts) {
|
|
|
589
693
|
videoCodec: videoOut.encName,
|
|
590
694
|
fps,
|
|
591
695
|
firstFrame,
|
|
592
|
-
frames: total
|
|
696
|
+
frames: total,
|
|
697
|
+
...remuxKeys && remuxKeys.length === total ? { frameKeys: remuxKeys } : {}
|
|
593
698
|
});
|
|
594
699
|
process.stderr.write(`cache: ${total}/${total} frames unchanged (audio-only) — video copy + remux → ${outAbs}\n`);
|
|
595
700
|
return {
|
|
@@ -650,7 +755,8 @@ async function render(opts) {
|
|
|
650
755
|
videoCodec: videoOut.encName,
|
|
651
756
|
fps,
|
|
652
757
|
firstFrame,
|
|
653
|
-
frames: total
|
|
758
|
+
frames: total,
|
|
759
|
+
frameKeys
|
|
654
760
|
});
|
|
655
761
|
return {
|
|
656
762
|
frames: total,
|
|
@@ -883,4 +989,4 @@ async function buildMixWav(opts, wavOut) {
|
|
|
883
989
|
});
|
|
884
990
|
}
|
|
885
991
|
//#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 };
|
|
992
|
+
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/dist/version.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { t as __exportAll } from "./rolldown-runtime.js";
|
|
2
1
|
import { createRequire } from "node:module";
|
|
3
2
|
//#region src/version.ts
|
|
4
3
|
/**
|
|
@@ -10,7 +9,6 @@ import { createRequire } from "node:module";
|
|
|
10
9
|
* single source of truth) rather than hard-coded so it can never drift from the
|
|
11
10
|
* published version.
|
|
12
11
|
*/
|
|
13
|
-
var version_exports = /* @__PURE__ */ __exportAll({ glissadeVersion: () => glissadeVersion });
|
|
14
12
|
let cached;
|
|
15
13
|
/** The @glissade/cli package version (the glissade VERSION for the cache key). */
|
|
16
14
|
function glissadeVersion() {
|
|
@@ -23,4 +21,4 @@ function glissadeVersion() {
|
|
|
23
21
|
return cached;
|
|
24
22
|
}
|
|
25
23
|
//#endregion
|
|
26
|
-
export {
|
|
24
|
+
export { glissadeVersion 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.1",
|
|
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.1",
|
|
32
|
+
"@glissade/core": "0.41.0-pre.1",
|
|
33
|
+
"@glissade/interact": "0.41.0-pre.1",
|
|
34
|
+
"@glissade/lottie": "0.41.0-pre.1",
|
|
35
|
+
"@glissade/narrate": "0.41.0-pre.1",
|
|
36
|
+
"@glissade/player": "0.41.0-pre.1",
|
|
37
|
+
"@glissade/scene": "0.41.0-pre.1",
|
|
38
|
+
"@glissade/sfx": "0.41.0-pre.1",
|
|
39
|
+
"@glissade/svg": "0.41.0-pre.1"
|
|
40
40
|
},
|
|
41
41
|
"repository": {
|
|
42
42
|
"type": "git",
|