@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 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
- if (opts.cache && opts.cache.mode !== "off") {
426
- const { FrameCache, capsId, combineAssetDigests } = await import("./frameCache.js").then((n) => n.s);
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 = glissadeVersion();
434
- const caps = capsId(backend.caps);
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)) remuxDigest = digest;
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.40.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.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.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",