@glissade/cli 0.49.0-pre.0 → 0.49.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/build.js CHANGED
Binary file
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { c as parseFrameRange, d as render, f as renderLocales, l as parseLocalesList, t as LocaleArgsError } from "./render.js";
2
+ import { c as parseFrameRange, f as render, l as parseLocalesList, p as renderLocales, t as LocaleArgsError } from "./render.js";
3
3
  import { n as parseCaptionsMode } from "./captions.js";
4
4
  //#region src/args.ts
5
5
  /**
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { A as peakClampBinds, C as PublishProfile, D as measureFile, E as loudnessPathFor, M as resolveProfile, O as measureLoudnessCommand, S as PUBLISH_PROFILES, T as computeMixHash, _ as LOUDNESS_SCHEMA_VERSION, a as MasterMemberResult, b as MeasureLoudnessOptions, d as masterAfChain, f as masterCommand, g as DEFAULT_PROFILE_ID, h as CommittedLimiter, i as MasterLimiter, j as readLoudness, k as parseLoudnormJson, l as MemberMeasure, m as planMaster, n as MasterConfig, o as MasterPlan, p as normalizeMasterConfig, r as MasterError, s as MasterResult, t as DEFAULT_MAX_GR_DB, u as MemberPlan, v as LoudnessError, w as computeGainDb, x as MeasureLoudnessResult, y as LoudnessMeasurement } from "./master.js";
2
2
  import { AudioClip, CompiledTimeline, Key, Timeline } from "@glissade/core";
3
3
  import { DisplayList, Scene, SceneModule, VideoFrameSource } from "@glissade/scene";
4
+ import { SkiaBackend } from "@glissade/backend-skia";
4
5
  import { NarrationTiming } from "@glissade/narrate";
5
6
  import { Image } from "@napi-rs/canvas";
6
7
  import * as _glissade_core_i18n0 from "@glissade/core/i18n";
@@ -130,6 +131,37 @@ declare function probeEntryHeader(file: string): {
130
131
  /** Remove the whole `.gscache` dir (a `gs cache clear` convenience / test cleanup). */
131
132
  declare function clearFrameCache(dir: string): void;
132
133
  //#endregion
134
+ //#region src/videoSource.d.ts
135
+ declare class VideoProbeError extends Error {
136
+ constructor(path: string, detail: string);
137
+ }
138
+ interface VideoInfo {
139
+ fps: number;
140
+ duration: number;
141
+ width: number;
142
+ height: number;
143
+ }
144
+ declare function probeVideo(path: string): VideoInfo;
145
+ /**
146
+ * v1 strategy: warm() extracts the full requested media range once at the
147
+ * source's own fps; getFrameSync indexes the extracted sequence. Disk-bound,
148
+ * trivially correct; the streaming-pipe variant is a later optimization.
149
+ */
150
+ declare class FfmpegVideoFrameSource implements VideoFrameSource {
151
+ readonly fps: number;
152
+ readonly duration: number;
153
+ private readonly path;
154
+ private framesDir;
155
+ private warmedFrom;
156
+ private warmedTo;
157
+ private readonly cache;
158
+ constructor(path: string, info?: VideoInfo);
159
+ private frameIndex;
160
+ warm(fromT: number, toT: number): Promise<void>;
161
+ getFrameSync(mediaT: number): Image;
162
+ close(): void;
163
+ }
164
+ //#endregion
133
165
  //#region src/render.d.ts
134
166
  interface RenderOptions {
135
167
  modulePath: string;
@@ -307,6 +339,10 @@ declare class SceneModuleError extends Error {
307
339
 
308
340
  declare function loadSceneModule(modulePath: string, locale?: string, messageTableOverride?: _glissade_core_i18n0.MessageTable): Promise<SceneModule>;
309
341
  declare function ffmpegAvailable(): boolean;
342
+ /** What `prepareSkiaRenderEnv` needs to make a Skia render FAITHFUL (fonts+axes,
343
+ * Yoga, assets) — the setup that `gs render` and any parity/preview render must
344
+ * share so a headless render is byte-faithful to `gs render` by construction. */
345
+
310
346
  declare function render(opts: RenderOptions): Promise<{
311
347
  frames: number;
312
348
  out: string;
@@ -424,37 +460,6 @@ declare function renderSharded(a: RenderShardedArgs): Promise<{
424
460
  }>;
425
461
  /** The FFV1 lossless intermediate retained beside a `--incremental` output for the next splice. */
426
462
  //#endregion
427
- //#region src/videoSource.d.ts
428
- declare class VideoProbeError extends Error {
429
- constructor(path: string, detail: string);
430
- }
431
- interface VideoInfo {
432
- fps: number;
433
- duration: number;
434
- width: number;
435
- height: number;
436
- }
437
- declare function probeVideo(path: string): VideoInfo;
438
- /**
439
- * v1 strategy: warm() extracts the full requested media range once at the
440
- * source's own fps; getFrameSync indexes the extracted sequence. Disk-bound,
441
- * trivially correct; the streaming-pipe variant is a later optimization.
442
- */
443
- declare class FfmpegVideoFrameSource implements VideoFrameSource {
444
- readonly fps: number;
445
- readonly duration: number;
446
- private readonly path;
447
- private framesDir;
448
- private warmedFrom;
449
- private warmedTo;
450
- private readonly cache;
451
- constructor(path: string, info?: VideoInfo);
452
- private frameIndex;
453
- warm(fromT: number, toT: number): Promise<void>;
454
- getFrameSync(mediaT: number): Image;
455
- close(): void;
456
- }
457
- //#endregion
458
463
  //#region src/audioMix.d.ts
459
464
  declare class AudioMixError extends Error {
460
465
  constructor(detail: string);
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
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";
2
+ import { a as ffmpegAvailable, f as render, h as resolveLoudnessGainDb, i as collectAudioClips, l as parseLocalesList, n as SceneModuleError, o as loadSceneModule, p as renderLocales, 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/localize.js CHANGED
@@ -156,7 +156,7 @@ async function harvestMessageIds(modulePath) {
156
156
  get: (_t, prop) => typeof prop === "string" ? prop : void 0
157
157
  });
158
158
  const { getConsumedMessageIds } = await import("@glissade/core/i18n");
159
- const { loadSceneModule } = await import("./render.js").then((n) => n.p);
159
+ const { loadSceneModule } = await import("./render.js").then((n) => n.m);
160
160
  const mod = await loadSceneModule(modulePath, void 0, recording);
161
161
  mod.createScene();
162
162
  const tIds = new Set(getConsumedMessageIds());
package/dist/loudness.js CHANGED
@@ -225,7 +225,7 @@ function measureFile(audioPath) {
225
225
  */
226
226
  async function measureLoudnessCommand(opts) {
227
227
  const profile = resolveProfile(opts.profile ?? "youtube");
228
- const { buildMixWav } = await import("./render.js").then((n) => n.p);
228
+ const { buildMixWav } = await import("./render.js").then((n) => n.m);
229
229
  const tmp = mkdtempSync(join(tmpdir(), "glissade-loudness-"));
230
230
  try {
231
231
  const wavPath = join(tmp, "mix.wav");
@@ -239,7 +239,7 @@ async function measureLoudnessCommand(opts) {
239
239
  const { inputI, inputTp, inputLra } = measureFile(wavPath);
240
240
  const gain = computeGainDb(profile, inputI, inputTp);
241
241
  const clampBound = peakClampBinds(profile, inputI, inputTp);
242
- const { collectMixAudioInputs } = await import("./render.js").then((n) => n.p);
242
+ const { collectMixAudioInputs } = await import("./render.js").then((n) => n.m);
243
243
  const extraInputs = await collectMixAudioInputs({
244
244
  modulePath: opts.modulePath,
245
245
  narration: opts.narration ?? "auto",
package/dist/master.js CHANGED
@@ -132,7 +132,7 @@ async function runMaster(members, opts = {}, onLog) {
132
132
  const consistency = opts.consistency ?? "shared-target";
133
133
  const limiter = opts.limiter || null;
134
134
  const log = onLog ?? (() => {});
135
- const { buildMixWav, collectMixAudioInputs } = await import("./render.js").then((n) => n.p);
135
+ const { buildMixWav, collectMixAudioInputs } = await import("./render.js").then((n) => n.m);
136
136
  if (members.length === 0) throw new MasterError("master: no members to master");
137
137
  const ceilingDb = limiter?.ceilingDb ?? profile.truePeakDb;
138
138
  const committedLimiter = limiter ? {
package/dist/parity.js CHANGED
@@ -1,7 +1,7 @@
1
- import { o as loadSceneModule } from "./render.js";
1
+ import { d as prepareSkiaRenderEnv, o as loadSceneModule } from "./render.js";
2
2
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
3
3
  import { basename, join } from "node:path";
4
- import { evaluate } from "@glissade/scene";
4
+ import { evaluate, withDeterminismGuards } from "@glissade/scene";
5
5
  import { SkiaBackend, heatmapRgba, ssimMap } from "@glissade/backend-skia";
6
6
  import { exportLottie, importLottie } from "@glissade/lottie";
7
7
  //#region src/parity.ts
@@ -57,32 +57,51 @@ function parseBackends(raw) {
57
57
  }
58
58
  return list;
59
59
  }
60
- /** skia = the direct headless render (repin's render→pixels shape) — the reference. */
61
- function skiaSource(mod, w, h) {
60
+ /**
61
+ * skia = the direct headless render — the reference every leg is diffed against.
62
+ * CRITICAL: it renders through the SAME environment as `gs render`
63
+ * (`prepareSkiaRenderEnv`: font faces incl. variable-font axes registered, Yoga
64
+ * for flexbox, assets decoded) and wraps `evaluate` in the determinism guard. Without
65
+ * this the reference would render a variable-font scene at DEFAULT weight (the face
66
+ * never registered) and match a fontAxes-dropping Lottie leg at a FALSE SSIM 1.0 —
67
+ * a fidelity gate silently reporting perfect on a real interchange loss.
68
+ *
69
+ * The scene + backend are built ONCE and reused across frames (evaluate is pure of
70
+ * time — the ONE contract — exactly as `gs render`'s frame loop reuses them).
71
+ */
72
+ async function skiaSource(mod, w, h, modulePath) {
73
+ const scene = mod.createScene();
74
+ const backend = new SkiaBackend(w, h);
75
+ await prepareSkiaRenderEnv({
76
+ scene,
77
+ doc: mod.timeline,
78
+ backend,
79
+ modulePath
80
+ });
62
81
  return async (t) => {
63
- const scene = mod.createScene();
64
- const backend = new SkiaBackend(w, h);
65
- scene.setTextMeasurer(backend);
66
- backend.render(evaluate(scene, mod.timeline, t));
82
+ const dl = withDeterminismGuards("throw", () => evaluate(scene, mod.timeline, t));
83
+ backend.render(dl);
67
84
  return backend.readPixels();
68
85
  };
69
86
  }
70
87
  /**
71
88
  * lottie = the export↔import round-trip rendered on Skia (roundtrip.test's renderPixels
72
- * shape). The document is exported/imported ONCE (it's time-independent); each frame
73
- * evaluates the re-imported module so the leg measures the Lottie bijection per frame.
89
+ * shape). The document is exported/imported ONCE (it's time-independent); the re-imported
90
+ * module renders through the SAME faithful environment as the reference (so its render is
91
+ * honest too). Asset URLs resolve against the ORIGINAL module path. This leg measures the
92
+ * Lottie bijection per frame — e.g. the dropped `fontAxes` surfaces as a real SSIM drop.
74
93
  */
75
- function lottieSource(mod, w, h, fps) {
94
+ async function lottieSource(mod, w, h, fps, modulePath) {
76
95
  return skiaSource(importLottie(exportLottie(mod, {
77
96
  width: w,
78
97
  height: h,
79
98
  fps
80
- })).toSceneModule(), w, h);
99
+ })).toSceneModule(), w, h, modulePath);
81
100
  }
82
- function makeSource(backend, mod, w, h, fps) {
101
+ function makeSource(backend, mod, w, h, fps, modulePath) {
83
102
  switch (backend) {
84
- case "skia": return skiaSource(mod, w, h);
85
- case "lottie": return lottieSource(mod, w, h, fps);
103
+ case "skia": return skiaSource(mod, w, h, modulePath);
104
+ case "lottie": return lottieSource(mod, w, h, fps, modulePath);
86
105
  default: throw new ParityBackendError(`unknown parity backend '${backend}'`);
87
106
  }
88
107
  }
@@ -97,8 +116,9 @@ async function parityCommand(opts) {
97
116
  const frames = opts.frames ?? DEFAULT_PARITY_FRAMES;
98
117
  const floor = opts.min ?? .98;
99
118
  const name = opts.name ?? basename(opts.modulePath).replace(/\.[jt]sx?$/, "");
100
- const reference = skiaSource(mod, w, h);
101
- const sources = new Map(compared.map((b) => [b, makeSource(b, mod, w, h, fps)]));
119
+ const reference = await skiaSource(mod, w, h, opts.modulePath);
120
+ const sources = /* @__PURE__ */ new Map();
121
+ for (const b of compared) sources.set(b, await makeSource(b, mod, w, h, fps, opts.modulePath));
102
122
  if (opts.heatmapDir && !existsSync(opts.heatmapDir)) mkdirSync(opts.heatmapDir, { recursive: true });
103
123
  const results = [];
104
124
  let belowFloor = 0;
package/dist/render.js CHANGED
@@ -166,6 +166,7 @@ var render_exports = /* @__PURE__ */ __exportAll({
166
166
  parseFrameRange: () => parseFrameRange,
167
167
  parseLocalesList: () => parseLocalesList,
168
168
  planFinalAudio: () => planFinalAudio,
169
+ prepareSkiaRenderEnv: () => prepareSkiaRenderEnv,
169
170
  render: () => render,
170
171
  renderLocales: () => renderLocales,
171
172
  resolveLoudnessGainDb: () => resolveLoudnessGainDb
@@ -358,6 +359,95 @@ async function loadSceneModule(modulePath, locale, messageTableOverride) {
358
359
  function ffmpegAvailable() {
359
360
  return spawnSync("ffmpeg", ["-version"], { stdio: "ignore" }).status === 0;
360
361
  }
362
+ /**
363
+ * Prepare the Skia render environment so `evaluate(scene, doc, t)` → `backend.render`
364
+ * is FAITHFUL: line-break measurer, Yoga (flexbox scenes), font faces registered
365
+ * under their families (variable-font axes included, so `fontAxes` reaches the
366
+ * glyphs), font validation, and image/video asset decode bound to the backend.
367
+ *
368
+ * This is the SINGLE SOURCE OF TRUTH for render setup — `gs render` AND `gs parity`
369
+ * both call it, so a parity render cannot silently diverge from a real render (e.g.
370
+ * report a false-perfect SSIM on a variable-font scene by rendering BOTH legs at the
371
+ * default weight because neither registered the face). Extracted verbatim from the
372
+ * `render()` body; the ordering (yoga → asset-validate → font-register → validate →
373
+ * asset-decode) is preserved so `gs render` stays byte-identical.
374
+ */
375
+ async function prepareSkiaRenderEnv(o) {
376
+ const { scene, doc, backend, modulePath } = o;
377
+ scene.setTextMeasurer(backend);
378
+ if ([...scene.nodes.values()].some((n) => n.constructor.isLayoutNode === true)) {
379
+ const { loadYogaLayoutEngine } = await import("@glissade/scene/layout");
380
+ await loadYogaLayoutEngine();
381
+ }
382
+ validateAssetReferences(collectAssetReferences(scene.root), Object.keys(doc.assets ?? {}));
383
+ const videoSources = [];
384
+ const { resolveAssetPath: resolveAsset } = await import("./audioMix.js").then((n) => n.i);
385
+ const { createHash } = await import("node:crypto");
386
+ const assetDigests = /* @__PURE__ */ new Map();
387
+ const digestBytes = (bytes) => createHash("sha256").update(bytes).digest("hex");
388
+ const registeredFamilies = /* @__PURE__ */ new Set();
389
+ const fontRegistry = buildFontRegistry(doc.assets);
390
+ if (fontRegistry.faces().length > 0) {
391
+ const { GlobalFonts } = await import("@napi-rs/canvas");
392
+ let ingest;
393
+ for (const face of fontRegistry.faces()) {
394
+ registeredFamilies.add(face.family.toLowerCase());
395
+ const path = resolveAsset(face.url, modulePath);
396
+ if (/\.woff2?$/i.test(face.url)) {
397
+ ingest ??= await import("@glissade/core/font-ingest");
398
+ const src = await readFile(path);
399
+ assetDigests.set(`font:${face.family}:${face.url}`, digestBytes(src));
400
+ const result = await ingest.ingestFont({
401
+ family: face.family,
402
+ src
403
+ });
404
+ GlobalFonts.register(Buffer.from(result.bytes), face.family);
405
+ } else {
406
+ assetDigests.set(`font:${face.family}:${face.url}`, digestBytes(await readFile(path)));
407
+ GlobalFonts.registerFromPath(path, face.family);
408
+ }
409
+ }
410
+ }
411
+ const osCatalog = !!o.allowSystemFonts && !o.strictFonts ? new Set((await import("@napi-rs/canvas")).GlobalFonts.families.map((f) => f.family.toLowerCase())) : /* @__PURE__ */ new Set();
412
+ const osFamilies = buildFontExemptSet(registeredFamilies, {
413
+ allowSystemFonts: !!o.allowSystemFonts,
414
+ strict: !!o.strictFonts,
415
+ osCatalog
416
+ });
417
+ const localizedUsages = o.locale !== void 0 && o.locale !== "" ? collectLocalizedTextUsages(scene, doc) : [];
418
+ await validateSceneFonts(scene, doc, async (url) => {
419
+ try {
420
+ const buf = await readFile(resolveAsset(url, modulePath));
421
+ return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
422
+ } catch {
423
+ return;
424
+ }
425
+ }, {
426
+ mode: o.strictFonts ? "strict" : "dev",
427
+ osFamilies,
428
+ extraUsages: localizedUsages
429
+ });
430
+ for (const [assetId, ref] of Object.entries(doc.assets ?? {})) if (ref.kind === "font") {} else if (ref.kind === "image") {
431
+ const { loadImage } = await import("@napi-rs/canvas");
432
+ const imgPath = resolveAsset(ref.url, modulePath);
433
+ assetDigests.set(`image:${assetId}`, digestBytes(await readFile(imgPath)));
434
+ backend.setImageAsset(assetId, await loadImage(imgPath));
435
+ } else if (ref.kind === "video") {
436
+ if (!ffmpegAvailable()) throw new Error(`video asset '${assetId}' needs FFmpeg on PATH for frame extraction (§5.4)`);
437
+ const { FfmpegVideoFrameSource } = await import("./videoSource.js").then((n) => n.i);
438
+ const videoPath = resolveAsset(ref.url, modulePath);
439
+ assetDigests.set(`video:${assetId}`, digestBytes(await readFile(videoPath)));
440
+ const source = new FfmpegVideoFrameSource(videoPath);
441
+ await source.warm(0, source.duration);
442
+ backend.setVideoAsset(assetId, source);
443
+ videoSources.push(source);
444
+ }
445
+ return {
446
+ assetDigests,
447
+ videoSources,
448
+ registeredFamilies
449
+ };
450
+ }
361
451
  async function render(opts) {
362
452
  const mod = await loadSceneModule(opts.modulePath, opts.locale);
363
453
  const scene = mod.createScene();
@@ -427,74 +517,15 @@ async function render(opts) {
427
517
  const framesDir = isVideo ? mkdtempSync(join(tmpdir(), "glissade-frames-")) : singleFile ? dirname(resolve(opts.out)) : resolve(opts.out);
428
518
  mkdirSync(framesDir, { recursive: true });
429
519
  const backend = new SkiaBackend(scene.size.w, scene.size.h);
430
- scene.setTextMeasurer(backend);
431
- if ([...scene.nodes.values()].some((n) => n.constructor.isLayoutNode === true)) {
432
- const { loadYogaLayoutEngine } = await import("@glissade/scene/layout");
433
- await loadYogaLayoutEngine();
434
- }
435
- validateAssetReferences(collectAssetReferences(scene.root), Object.keys(doc.assets ?? {}));
436
- const videoSources = [];
437
- const { resolveAssetPath: resolveAsset } = await import("./audioMix.js").then((n) => n.i);
438
- const { createHash } = await import("node:crypto");
439
- const assetDigests = /* @__PURE__ */ new Map();
440
- const digestBytes = (bytes) => createHash("sha256").update(bytes).digest("hex");
441
- const registeredFamilies = /* @__PURE__ */ new Set();
442
- const fontRegistry = buildFontRegistry(doc.assets);
443
- if (fontRegistry.faces().length > 0) {
444
- const { GlobalFonts } = await import("@napi-rs/canvas");
445
- let ingest;
446
- for (const face of fontRegistry.faces()) {
447
- registeredFamilies.add(face.family.toLowerCase());
448
- const path = resolveAsset(face.url, opts.modulePath);
449
- if (/\.woff2?$/i.test(face.url)) {
450
- ingest ??= await import("@glissade/core/font-ingest");
451
- const src = await readFile(path);
452
- assetDigests.set(`font:${face.family}:${face.url}`, digestBytes(src));
453
- const result = await ingest.ingestFont({
454
- family: face.family,
455
- src
456
- });
457
- GlobalFonts.register(Buffer.from(result.bytes), face.family);
458
- } else {
459
- assetDigests.set(`font:${face.family}:${face.url}`, digestBytes(await readFile(path)));
460
- GlobalFonts.registerFromPath(path, face.family);
461
- }
462
- }
463
- }
464
- const osCatalog = !!opts.allowSystemFonts && !opts.strictFonts ? new Set((await import("@napi-rs/canvas")).GlobalFonts.families.map((f) => f.family.toLowerCase())) : /* @__PURE__ */ new Set();
465
- const osFamilies = buildFontExemptSet(registeredFamilies, {
466
- allowSystemFonts: !!opts.allowSystemFonts,
467
- strict: !!opts.strictFonts,
468
- osCatalog
469
- });
470
- const localizedUsages = opts.locale !== void 0 && opts.locale !== "" ? collectLocalizedTextUsages(scene, doc) : [];
471
- await validateSceneFonts(scene, doc, async (url) => {
472
- try {
473
- const buf = await readFile(resolveAsset(url, opts.modulePath));
474
- return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
475
- } catch {
476
- return;
477
- }
478
- }, {
479
- mode: opts.strictFonts ? "strict" : "dev",
480
- osFamilies,
481
- extraUsages: localizedUsages
520
+ const { assetDigests, videoSources } = await prepareSkiaRenderEnv({
521
+ scene,
522
+ doc,
523
+ backend,
524
+ modulePath: opts.modulePath,
525
+ ...opts.strictFonts !== void 0 ? { strictFonts: opts.strictFonts } : {},
526
+ ...opts.allowSystemFonts !== void 0 ? { allowSystemFonts: opts.allowSystemFonts } : {},
527
+ ...opts.locale !== void 0 ? { locale: opts.locale } : {}
482
528
  });
483
- for (const [assetId, ref] of Object.entries(doc.assets ?? {})) if (ref.kind === "font") {} else if (ref.kind === "image") {
484
- const { loadImage } = await import("@napi-rs/canvas");
485
- const imgPath = resolveAsset(ref.url, opts.modulePath);
486
- assetDigests.set(`image:${assetId}`, digestBytes(await readFile(imgPath)));
487
- backend.setImageAsset(assetId, await loadImage(imgPath));
488
- } else if (ref.kind === "video") {
489
- if (!ffmpegAvailable()) throw new Error(`video asset '${assetId}' needs FFmpeg on PATH for frame extraction (§5.4)`);
490
- const { FfmpegVideoFrameSource } = await import("./videoSource.js").then((n) => n.i);
491
- const videoPath = resolveAsset(ref.url, opts.modulePath);
492
- assetDigests.set(`video:${assetId}`, digestBytes(await readFile(videoPath)));
493
- const source = new FfmpegVideoFrameSource(videoPath);
494
- await source.warm(0, source.duration);
495
- backend.setVideoAsset(assetId, source);
496
- videoSources.push(source);
497
- }
498
529
  let frameCache;
499
530
  let keyCtx;
500
531
  const cacheOn = !!(opts.cache && opts.cache.mode !== "off");
@@ -995,4 +1026,4 @@ async function buildMixWav(opts, wavOut) {
995
1026
  });
996
1027
  }
997
1028
  //#endregion
998
- 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 };
1029
+ export { frameKeyDigest as _, ffmpegAvailable as a, parseFrameRange as c, prepareSkiaRenderEnv as d, render as f, changedFrameRanges as g, resolveLoudnessGainDb as h, collectAudioClips as i, parseLocalesList as l, render_exports as m, SceneModuleError as n, loadSceneModule as o, renderLocales as p, buildMixWav as r, localeOutPath as s, LocaleArgsError as t, planFinalAudio as u, readRenderManifest as v, writeRenderManifest as y };
package/dist/shards.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime.js";
2
- import { _ as readRenderManifest, g as frameKeyDigest, h as changedFrameRanges, u as planFinalAudio, v as writeRenderManifest } from "./render.js";
2
+ import { _ as frameKeyDigest, g as changedFrameRanges, u as planFinalAudio, v as readRenderManifest, y as writeRenderManifest } from "./render.js";
3
3
  import { a as pickEncoder } from "./encoders.js";
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/cli",
3
- "version": "0.49.0-pre.0",
3
+ "version": "0.49.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.49.0-pre.0",
32
- "@glissade/core": "0.49.0-pre.0",
33
- "@glissade/interact": "0.49.0-pre.0",
34
- "@glissade/lottie": "0.49.0-pre.0",
35
- "@glissade/narrate": "0.49.0-pre.0",
36
- "@glissade/player": "0.49.0-pre.0",
37
- "@glissade/scene": "0.49.0-pre.0",
38
- "@glissade/sfx": "0.49.0-pre.0",
39
- "@glissade/svg": "0.49.0-pre.0"
31
+ "@glissade/backend-skia": "0.49.0",
32
+ "@glissade/core": "0.49.0",
33
+ "@glissade/interact": "0.49.0",
34
+ "@glissade/lottie": "0.49.0",
35
+ "@glissade/narrate": "0.49.0",
36
+ "@glissade/player": "0.49.0",
37
+ "@glissade/scene": "0.49.0",
38
+ "@glissade/sfx": "0.49.0",
39
+ "@glissade/svg": "0.49.0"
40
40
  },
41
41
  "repository": {
42
42
  "type": "git",