@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.
@@ -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
- for (let f = firstFrame; f <= lastFrame; f++) {
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 = resolve(opts.out);
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
- const isWebm = /\.webm$/i.test(outAbs);
437
- const container = isWebm ? "webm" : "mp4";
438
- const { pickEncoder } = await import("./encoders.js").then((n) => n.r);
439
- const videoEnc = pickEncoder("video", container);
440
- if (videoEnc.note) process.stderr.write(`note: ${videoEnc.note}\n`);
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
- videoEnc.name,
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
- }[videoEnc.name] ?? [],
456
- ...isWebm ? [] : [
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.26.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.26.0",
27
- "@glissade/core": "0.26.0",
28
- "@glissade/interact": "0.26.0",
29
- "@glissade/lottie": "0.26.0",
30
- "@glissade/svg": "0.26.0",
31
- "@glissade/narrate": "0.26.0",
32
- "@glissade/player": "0.26.0",
33
- "@glissade/scene": "0.26.0",
34
- "@glissade/sfx": "0.26.0"
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",