@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.
@@ -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
- if (![
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
- ].some((c) => existsSync(c))) throw new SceneModuleError(modulePath, "scene module not found (check the path)");
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
- if (opts.cache && opts.cache.mode !== "off") {
426
- const { FrameCache, capsId, combineAssetDigests } = await import("./frameCache.js").then((n) => n.s);
427
- const { glissadeVersion } = await import("./version.js").then((n) => n.n);
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 = glissadeVersion();
434
- const caps = capsId(backend.caps);
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)) remuxDigest = digest;
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 { version_exports as n, glissadeVersion as t };
24
+ export { glissadeVersion as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/cli",
3
- "version": "0.40.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.40.0",
32
- "@glissade/core": "0.40.0",
33
- "@glissade/interact": "0.40.0",
34
- "@glissade/lottie": "0.40.0",
35
- "@glissade/narrate": "0.40.0",
36
- "@glissade/player": "0.40.0",
37
- "@glissade/scene": "0.40.0",
38
- "@glissade/sfx": "0.40.0",
39
- "@glissade/svg": "0.40.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",