@glissade/cli 0.26.0 → 0.27.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/cacheVerify.js +1 -1
- package/dist/render.js +161 -13
- package/package.json +10 -10
package/dist/cacheVerify.js
CHANGED
|
@@ -6,8 +6,8 @@ import { mkdtempSync, rmSync } from "node:fs";
|
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import { evaluate, withDeterminismGuards } from "@glissade/scene";
|
|
9
|
-
import { SkiaBackend } from "@glissade/backend-skia";
|
|
10
9
|
import { createHash } from "node:crypto";
|
|
10
|
+
import { SkiaBackend } from "@glissade/backend-skia";
|
|
11
11
|
//#region src/cacheVerify.ts
|
|
12
12
|
/**
|
|
13
13
|
* gs cache verify — THE verify gate for the persistent `.gscache` (DESIGN.md §3.5,
|
package/dist/render.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { t as __exportAll } from "./rolldown-runtime.js";
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
|
-
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
6
6
|
import { pathToFileURL } from "node:url";
|
|
@@ -8,7 +8,59 @@ import { readFile } from "node:fs/promises";
|
|
|
8
8
|
import { createJiti } from "jiti";
|
|
9
9
|
import { buildFontRegistry } from "@glissade/core";
|
|
10
10
|
import { collectLocalizedTextUsages, evaluate, validateSceneFonts, withDeterminismGuards } from "@glissade/scene";
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
11
12
|
import { SkiaBackend } from "@glissade/backend-skia";
|
|
13
|
+
//#region src/renderManifest.ts
|
|
14
|
+
/**
|
|
15
|
+
* Render manifest (0.27): the byte-identical-video proof that powers the
|
|
16
|
+
* audio-only REMUX fast path. When `gs render --cache` finishes a video, it
|
|
17
|
+
* writes `<out>.gsrender.json` recording the ORDERED digest of every frame's
|
|
18
|
+
* content-cache key plus the video-encode parameters. On a re-render, a cheap
|
|
19
|
+
* key-only pre-pass (evaluate + frameCacheKey, NO raster) recomputes that digest;
|
|
20
|
+
* if it matches — the visual inputs are byte-identical, so the encoded video
|
|
21
|
+
* would be too — and the prior output still exists with the same encode params,
|
|
22
|
+
* we skip re-encoding entirely and `ffmpeg -c:v copy` the existing video stream
|
|
23
|
+
* with the new audio. An audio-only re-master becomes a remux in seconds.
|
|
24
|
+
*
|
|
25
|
+
* The digest IS a determinism proof: identical DisplayList per frame (the same
|
|
26
|
+
* thing the frame cache and the golden corpus hash) ⇒ identical raster on the
|
|
27
|
+
* pinned Skia toolchain ⇒ identical encode. Any pixel change flips the digest and
|
|
28
|
+
* falls back to a full encode — the cache-hit ≡ cold-render invariant, one level up.
|
|
29
|
+
*/
|
|
30
|
+
/** sha256 of the ordered frame keys (NUL-separated so keys can't run together). */
|
|
31
|
+
function frameKeyDigest(keys) {
|
|
32
|
+
const h = createHash("sha256");
|
|
33
|
+
for (const k of keys) {
|
|
34
|
+
h.update(k);
|
|
35
|
+
h.update("\0");
|
|
36
|
+
}
|
|
37
|
+
return h.digest("hex");
|
|
38
|
+
}
|
|
39
|
+
const manifestPathFor = (videoPath) => `${videoPath}.gsrender.json`;
|
|
40
|
+
/** Read the manifest beside a video output, or undefined if absent/unreadable/old-format. */
|
|
41
|
+
function readRenderManifest(videoPath) {
|
|
42
|
+
const p = manifestPathFor(videoPath);
|
|
43
|
+
if (!existsSync(p)) return void 0;
|
|
44
|
+
try {
|
|
45
|
+
const m = JSON.parse(readFileSync(p, "utf8"));
|
|
46
|
+
return m && m.v === 1 && typeof m.frameKeyDigest === "string" ? m : void 0;
|
|
47
|
+
} catch {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function writeRenderManifest(videoPath, m) {
|
|
52
|
+
writeFileSync(manifestPathFor(videoPath), JSON.stringify(m));
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Decide whether a re-render can take the remux fast path: the prior manifest's
|
|
56
|
+
* frame-key digest matches this render's, the prior output still exists, and the
|
|
57
|
+
* encode parameters are unchanged. Pure — the caller supplies the freshly computed
|
|
58
|
+
* digest + params and whether the output file exists.
|
|
59
|
+
*/
|
|
60
|
+
function canRemux(prev, now, outputExists) {
|
|
61
|
+
return prev !== void 0 && outputExists && prev.frameKeyDigest === now.frameKeyDigest && prev.container === now.container && prev.videoCodec === now.videoCodec && prev.fps === now.fps && prev.firstFrame === now.firstFrame && prev.frames === now.frames;
|
|
62
|
+
}
|
|
63
|
+
//#endregion
|
|
12
64
|
//#region src/assetValidation.ts
|
|
13
65
|
/**
|
|
14
66
|
* Walk a scene's node tree and collect every Image/Video asset reference. Pure
|
|
@@ -374,12 +426,48 @@ async function render(opts) {
|
|
|
374
426
|
assetsDigest: combineAssetDigests(assetDigests)
|
|
375
427
|
};
|
|
376
428
|
}
|
|
377
|
-
|
|
429
|
+
let videoOut;
|
|
430
|
+
let remuxDigest;
|
|
431
|
+
if (isVideo) {
|
|
432
|
+
const outAbs = resolve(opts.out);
|
|
433
|
+
const container = /\.webm$/i.test(outAbs) ? "webm" : "mp4";
|
|
434
|
+
const { pickEncoder } = await import("./encoders.js").then((n) => n.r);
|
|
435
|
+
const enc = pickEncoder("video", container);
|
|
436
|
+
videoOut = {
|
|
437
|
+
outAbs,
|
|
438
|
+
container,
|
|
439
|
+
encName: enc.name,
|
|
440
|
+
...enc.note ? { encNote: enc.note } : {}
|
|
441
|
+
};
|
|
442
|
+
if (frameCache && keyCtx && opts.cache.mode !== "off") {
|
|
443
|
+
const prev = readRenderManifest(outAbs);
|
|
444
|
+
if (prev && existsSync(outAbs)) {
|
|
445
|
+
const { frameCacheKey } = await import("./frameCache.js").then((n) => n.s);
|
|
446
|
+
const keys = [];
|
|
447
|
+
for (let f = firstFrame; f <= lastFrame; f++) {
|
|
448
|
+
const dl = withDeterminismGuards("throw", () => evaluate(scene, doc, f / fps));
|
|
449
|
+
keys.push(frameCacheKey(dl, keyCtx));
|
|
450
|
+
}
|
|
451
|
+
const digest = frameKeyDigest(keys);
|
|
452
|
+
if (canRemux(prev, {
|
|
453
|
+
frameKeyDigest: digest,
|
|
454
|
+
container,
|
|
455
|
+
videoCodec: enc.name,
|
|
456
|
+
fps,
|
|
457
|
+
firstFrame,
|
|
458
|
+
frames: total
|
|
459
|
+
}, true)) remuxDigest = digest;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
const frameKeys = [];
|
|
464
|
+
if (!remuxDigest) for (let f = firstFrame; f <= lastFrame; f++) {
|
|
378
465
|
const dl = withDeterminismGuards("throw", () => evaluate(scene, doc, f / fps));
|
|
379
466
|
let pngBytes;
|
|
380
467
|
if (frameCache && keyCtx) {
|
|
381
468
|
const { frameCacheKey } = await import("./frameCache.js").then((n) => n.s);
|
|
382
469
|
const key = frameCacheKey(dl, keyCtx);
|
|
470
|
+
frameKeys.push(key);
|
|
383
471
|
const cached = frameCache.get(key);
|
|
384
472
|
if (cached) {
|
|
385
473
|
backend.putPixels(cached);
|
|
@@ -397,10 +485,11 @@ async function render(opts) {
|
|
|
397
485
|
opts.onProgress?.(f - firstFrame + 1, total);
|
|
398
486
|
}
|
|
399
487
|
backend.dispose();
|
|
400
|
-
if (frameCache) {
|
|
488
|
+
if (frameCache && !remuxDigest) {
|
|
401
489
|
const s = frameCache.getStats();
|
|
402
490
|
process.stderr.write(`cache (${opts.cache.mode}): ${s.hits} hit${s.hits === 1 ? "" : "s"}, ${s.misses} miss${s.misses === 1 ? "" : "es"}` + (s.stored ? `, ${s.stored} stored` : "") + (s.evicted ? `, ${s.evicted} evicted (LRU cap ${frameCache.maxSize} B)` : "") + ` → ${opts.cache.dir}\n`);
|
|
403
491
|
}
|
|
492
|
+
const newDigest = frameCache && !remuxDigest && frameKeys.length === total ? frameKeyDigest(frameKeys) : void 0;
|
|
404
493
|
for (const source of videoSources) source.close();
|
|
405
494
|
const emitSidecars = (target) => {
|
|
406
495
|
if (captionsMode === "off") return;
|
|
@@ -429,18 +518,69 @@ async function render(opts) {
|
|
|
429
518
|
out: framesDir
|
|
430
519
|
};
|
|
431
520
|
}
|
|
432
|
-
const outAbs =
|
|
521
|
+
const outAbs = videoOut.outAbs;
|
|
522
|
+
const container = videoOut.container;
|
|
433
523
|
mkdirSync(dirname(outAbs), { recursive: true });
|
|
434
524
|
emitSidecars(outAbs);
|
|
435
525
|
emitCues(outAbs);
|
|
436
|
-
|
|
437
|
-
const
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
526
|
+
if (videoOut.encNote) process.stderr.write(`note: ${videoOut.encNote}\n`);
|
|
527
|
+
const { audioInputs, audioArgs } = await planFinalAudio(opts, [...compiled.audio], duration, container);
|
|
528
|
+
if (remuxDigest) {
|
|
529
|
+
const remuxArgs = [
|
|
530
|
+
"-y",
|
|
531
|
+
"-i",
|
|
532
|
+
outAbs,
|
|
533
|
+
...audioInputs,
|
|
534
|
+
...audioArgs,
|
|
535
|
+
"-map",
|
|
536
|
+
"0:v:0",
|
|
537
|
+
"-c:v",
|
|
538
|
+
"copy",
|
|
539
|
+
...container === "webm" ? [] : ["-movflags", "+faststart"],
|
|
540
|
+
"-t",
|
|
541
|
+
String(duration)
|
|
542
|
+
];
|
|
543
|
+
const encodeDir = mkdtempSync(join(dirname(outAbs), ".gs-remux-"));
|
|
544
|
+
const tmpOut = join(encodeDir, `out.${container}`);
|
|
545
|
+
const result = spawnSync("ffmpeg", [...remuxArgs, tmpOut], { stdio: [
|
|
546
|
+
"ignore",
|
|
547
|
+
"ignore",
|
|
548
|
+
"pipe"
|
|
549
|
+
] });
|
|
550
|
+
rmSync(framesDir, {
|
|
551
|
+
recursive: true,
|
|
552
|
+
force: true
|
|
553
|
+
});
|
|
554
|
+
if (result.status !== 0) {
|
|
555
|
+
rmSync(encodeDir, {
|
|
556
|
+
recursive: true,
|
|
557
|
+
force: true
|
|
558
|
+
});
|
|
559
|
+
throw new Error(`ffmpeg remux failed (exit ${result.status}):\n${result.stderr?.toString().slice(-2e3)}`);
|
|
560
|
+
}
|
|
561
|
+
renameSync(tmpOut, outAbs);
|
|
562
|
+
rmSync(encodeDir, {
|
|
563
|
+
recursive: true,
|
|
564
|
+
force: true
|
|
565
|
+
});
|
|
566
|
+
writeRenderManifest(outAbs, {
|
|
567
|
+
v: 1,
|
|
568
|
+
frameKeyDigest: remuxDigest,
|
|
569
|
+
container,
|
|
570
|
+
videoCodec: videoOut.encName,
|
|
571
|
+
fps,
|
|
572
|
+
firstFrame,
|
|
573
|
+
frames: total
|
|
574
|
+
});
|
|
575
|
+
process.stderr.write(`cache: ${total}/${total} frames unchanged (audio-only) — video copy + remux → ${outAbs}\n`);
|
|
576
|
+
return {
|
|
577
|
+
frames: total,
|
|
578
|
+
out: outAbs
|
|
579
|
+
};
|
|
580
|
+
}
|
|
441
581
|
const codec = [
|
|
442
582
|
"-c:v",
|
|
443
|
-
|
|
583
|
+
videoOut.encName,
|
|
444
584
|
...{
|
|
445
585
|
"libx264": ["-crf", "18"],
|
|
446
586
|
"libvpx-vp9": [
|
|
@@ -452,15 +592,14 @@ async function render(opts) {
|
|
|
452
592
|
"libvpx": ["-b:v", "2M"],
|
|
453
593
|
"libopenh264": ["-b:v", "4M"],
|
|
454
594
|
"mpeg4": ["-q:v", "3"]
|
|
455
|
-
}[
|
|
456
|
-
...
|
|
595
|
+
}[videoOut.encName] ?? [],
|
|
596
|
+
...container === "webm" ? [] : [
|
|
457
597
|
"-pix_fmt",
|
|
458
598
|
"yuv420p",
|
|
459
599
|
"-movflags",
|
|
460
600
|
"+faststart"
|
|
461
601
|
]
|
|
462
602
|
];
|
|
463
|
-
const { audioInputs, audioArgs } = await planFinalAudio(opts, [...compiled.audio], duration, container);
|
|
464
603
|
const result = spawnSync("ffmpeg", [
|
|
465
604
|
"-y",
|
|
466
605
|
"-framerate",
|
|
@@ -485,6 +624,15 @@ async function render(opts) {
|
|
|
485
624
|
force: true
|
|
486
625
|
});
|
|
487
626
|
if (result.status !== 0) throw new Error(`ffmpeg failed (exit ${result.status}):\n${result.stderr?.toString().slice(-2e3)}`);
|
|
627
|
+
if (newDigest) writeRenderManifest(outAbs, {
|
|
628
|
+
v: 1,
|
|
629
|
+
frameKeyDigest: newDigest,
|
|
630
|
+
container,
|
|
631
|
+
videoCodec: videoOut.encName,
|
|
632
|
+
fps,
|
|
633
|
+
firstFrame,
|
|
634
|
+
frames: total
|
|
635
|
+
});
|
|
488
636
|
return {
|
|
489
637
|
frames: total,
|
|
490
638
|
out: outAbs
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glissade/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"description": "glissade CLI: headless rendering via backend-skia (+ FFmpeg mux).",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"engines": {
|
|
@@ -23,15 +23,15 @@
|
|
|
23
23
|
"@napi-rs/canvas": "^0.1.65",
|
|
24
24
|
"esbuild": "0.28.0",
|
|
25
25
|
"jiti": "^2.4.2",
|
|
26
|
-
"@glissade/backend-skia": "0.
|
|
27
|
-
"@glissade/core": "0.
|
|
28
|
-
"@glissade/interact": "0.
|
|
29
|
-
"@glissade/lottie": "0.
|
|
30
|
-
"@glissade/svg": "0.
|
|
31
|
-
"@glissade/narrate": "0.
|
|
32
|
-
"@glissade/player": "0.
|
|
33
|
-
"@glissade/
|
|
34
|
-
"@glissade/
|
|
26
|
+
"@glissade/backend-skia": "0.27.0",
|
|
27
|
+
"@glissade/core": "0.27.0",
|
|
28
|
+
"@glissade/interact": "0.27.0",
|
|
29
|
+
"@glissade/lottie": "0.27.0",
|
|
30
|
+
"@glissade/svg": "0.27.0",
|
|
31
|
+
"@glissade/narrate": "0.27.0",
|
|
32
|
+
"@glissade/player": "0.27.0",
|
|
33
|
+
"@glissade/sfx": "0.27.0",
|
|
34
|
+
"@glissade/scene": "0.27.0"
|
|
35
35
|
},
|
|
36
36
|
"repository": {
|
|
37
37
|
"type": "git",
|