@glissade/cli 0.36.0 → 0.37.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/captions.js CHANGED
@@ -78,4 +78,4 @@ function writeCaptionSidecars(timingPath, outPath) {
78
78
  };
79
79
  }
80
80
  //#endregion
81
- export { parseCaptionsMode as n, captions_exports as t };
81
+ export { parseCaptionsMode as n, timingPathFor as r, captions_exports as t };
package/dist/cli.js CHANGED
@@ -87,6 +87,7 @@ const USAGE = `usage:
87
87
  gs build [filter...] [--config <glissade.config.ts>] [--explain] content-graph DAG runner: narrate→sfx→loudness→render per scene, runs ONLY the stale subtree
88
88
  gs describe [--out <api.json>] [--examples] snapshot THIS engine's describe() API manifest (stdout, or --out to a file) — the input to gs migrate
89
89
  gs migrate <baseline-api.json> [--json] [--check] diff a saved API manifest against the current engine: moved imports / removed / added / changed, with a suggested fix per breaking item (advisory; --check exits non-zero on any breaking change for CI gating)
90
+ gs repin <scene-module> --golden <dir> [--name <p>] [--frames a,b,..] [--fps <n>] [--since <ref>] [--write] [--only a,b] [--heatmap <dir>] [--floor <ssim>] [--force] narration-aware golden reviewer: render current vs committed goldens, report perceptual delta + the re-narration cause, re-pin only frames you allow (default dry-run; --floor refuses a bigger-than-expected drop)
90
91
  gs --version print the engine version
91
92
 
92
93
  render options:
@@ -183,7 +184,7 @@ async function main() {
183
184
  process.stdout.write(`${describe().version}\n`);
184
185
  return;
185
186
  }
186
- if (command !== "render" && command !== "diff" && command !== "verify-determinism" && command !== "dev" && command !== "import" && command !== "narrate" && command !== "narration-lint" && command !== "sfx" && command !== "prepare" && command !== "measure-loudness" && command !== "fonts" && command !== "cache" && command !== "mcp" && command !== "build" && command !== "describe" && command !== "migrate") {
187
+ if (command !== "render" && command !== "diff" && command !== "verify-determinism" && command !== "dev" && command !== "import" && command !== "narrate" && command !== "narration-lint" && command !== "sfx" && command !== "prepare" && command !== "measure-loudness" && command !== "fonts" && command !== "cache" && command !== "mcp" && command !== "build" && command !== "describe" && command !== "migrate" && command !== "repin") {
187
188
  console.error(USAGE);
188
189
  process.exit(command === void 0 || command === "help" || command === "--help" ? 0 : 1);
189
190
  }
@@ -289,6 +290,45 @@ async function main() {
289
290
  if (mf.has("check") && report.summary.breaking > 0) process.exit(1);
290
291
  return;
291
292
  }
293
+ if (command === "repin") {
294
+ const { positional: rp, flags: rf } = parseArgs(rest);
295
+ const sceneModule = rp[0];
296
+ if (!sceneModule) fail(`gs repin needs <scene-module>\n${USAGE}`);
297
+ const goldenDir = rf.get("golden");
298
+ if (!goldenDir) fail(`gs repin needs --golden <dir> (the committed golden PNG directory)\n${USAGE}`);
299
+ const nums = (raw) => raw === void 0 ? void 0 : raw.split(",").map((s) => {
300
+ const n = Number(s.trim());
301
+ if (!Number.isInteger(n) || n < 0) fail(`repin: '${s}' is not a frame number (expected comma-separated non-negative integers)`);
302
+ return n;
303
+ });
304
+ const floorRaw = rf.get("floor");
305
+ let floor;
306
+ if (floorRaw !== void 0) {
307
+ floor = Number(floorRaw);
308
+ if (!(floor >= -1 && floor <= 1)) fail(`repin: --floor must be an SSIM in [-1, 1], got '${floorRaw}'`);
309
+ }
310
+ const { repinCommand } = await import("./repin.js").then((n) => n.i);
311
+ try {
312
+ const result = await repinCommand({
313
+ modulePath: sceneModule,
314
+ goldenDir,
315
+ ...rf.get("name") ? { name: rf.get("name") } : {},
316
+ ...nums(rf.get("frames")) ? { frames: nums(rf.get("frames")) } : {},
317
+ ...rf.get("fps") ? { fps: parseFpsOrFail(rf.get("fps")) } : {},
318
+ ...rf.get("since") ? { since: rf.get("since") } : {},
319
+ ...rf.has("write") ? { write: true } : {},
320
+ ...nums(rf.get("only")) ? { only: nums(rf.get("only")) } : {},
321
+ ...rf.get("heatmap") ? { heatmapDir: rf.get("heatmap") } : {},
322
+ ...floor !== void 0 ? { floor } : {},
323
+ ...rf.has("force") ? { force: true } : {}
324
+ });
325
+ process.stdout.write(`${result.report}\n`);
326
+ if (result.blocked > 0 || !rf.has("write") && result.changed > 0) process.exit(1);
327
+ } catch (err) {
328
+ fail(err instanceof Error ? err.message : String(err));
329
+ }
330
+ return;
331
+ }
292
332
  const { positional, flags } = parseArgs(rest);
293
333
  const modulePath = positional[0];
294
334
  if (!modulePath) fail(`missing ${command === "import" ? "<lottie.json|asset.svg>" : "<scene-module>"}\n${USAGE}`);
package/dist/index.d.ts CHANGED
@@ -742,6 +742,62 @@ declare function diffCommand(opts: DiffOptions): Promise<DiffResult>;
742
742
  /** Snapshot a scene's DisplayList at `t` to a `.dl.json` string (the baseline writer). */
743
743
  declare function snapshotAt(modulePath: string, t: number): Promise<string>;
744
744
  //#endregion
745
+ //#region src/repin.d.ts
746
+ /** The default golden frame set + fps — matches the Skia golden harness. */
747
+ declare const DEFAULT_FRAMES: number[];
748
+ declare const DEFAULT_FPS = 60;
749
+ interface RepinOptions {
750
+ modulePath: string;
751
+ /** directory holding the committed golden PNGs. */
752
+ goldenDir: string;
753
+ /** golden filename prefix (`<name>-f0030.png`); default = the module basename. */
754
+ name?: string;
755
+ /** frame numbers to review; default DEFAULT_FRAMES. */
756
+ frames?: number[];
757
+ /** frames-per-second for `t = frame / fps`; default 60. */
758
+ fps?: number;
759
+ /** git ref to diff the narration timing sibling against for the cause line; default 'HEAD'. */
760
+ since?: string;
761
+ /** actually overwrite changed/new goldens (default: dry-run report). */
762
+ write?: boolean;
763
+ /** when writing, restrict to these frames (per-frame confirm). */
764
+ only?: number[];
765
+ /** write per-frame SSIM heat-map PNGs here for visual review. */
766
+ heatmapDir?: string;
767
+ /** refuse to write a frame whose mean SSIM fell below this (unless force). */
768
+ floor?: number;
769
+ /** override the floor guard. */
770
+ force?: boolean;
771
+ }
772
+ type RepinStatus = 'identical' | 'changed' | 'new' | 'missing';
773
+ interface RepinFrame {
774
+ frame: number;
775
+ t: number;
776
+ status: RepinStatus;
777
+ /** mean SSIM vs the golden (changed frames only). */
778
+ ssim?: number;
779
+ /** worst-tile SSIM (changed frames only). */
780
+ minSsim?: number;
781
+ /** the narration cause line, when a timing shift explains the change. */
782
+ cause?: string;
783
+ /** true when this frame's golden PNG was (re)written. */
784
+ wrote: boolean;
785
+ /** true when a write was refused because the drop fell below --floor. */
786
+ blocked?: boolean;
787
+ /** path of the heat-map PNG, when --heatmap was given. */
788
+ heatmap?: string;
789
+ }
790
+ interface RepinResult {
791
+ frames: RepinFrame[];
792
+ changed: number;
793
+ wrote: number;
794
+ blocked: number;
795
+ /** true when the timing sibling was resolved AND diffed against `since`. */
796
+ causeSource: string | null;
797
+ report: string;
798
+ }
799
+ declare function repinCommand(opts: RepinOptions): Promise<RepinResult>;
800
+ //#endregion
745
801
  //#region src/narrationLint.d.ts
746
802
  type LintRule = 'reading-speed' | 'anchor-budget' | 'caption-fit' | 'beat-drift' | 'silence';
747
803
  interface Diagnostic {
@@ -905,4 +961,4 @@ declare function cacheVerifyCommand(opts: CacheVerifyOptions): Promise<CacheVeri
905
961
  /** The @glissade/cli package version (the glissade VERSION for the cache key). */
906
962
  declare function glissadeVersion(): string;
907
963
  //#endregion
908
- export { AudioMixError, type AudioMixPlan, type CacheKeyContext, type CacheMode, type CacheVerifyOptions, type CacheVerifyResult, type CaptionProbe, DEFAULT_CACHE_MAX_SIZE, DEFAULT_PROFILE_ID, type DevOptions, type DevServer, type Diagnostic, type DiffOptions, type DiffResult, type EncoderChoice, FfmpegVideoFrameSource, FrameCache, FrameCacheError, type FrameCacheOptions, type ImportCommandResult, type ImportOptions, LOUDNESS_SCHEMA_VERSION, type LintOptions, type LintRule, LocaleArgsError, LoudnessError, type LoudnessMeasurement, MachineExportError, type MachineRenderFlags, type MeasureLoudnessOptions, type MeasureLoudnessResult, type NarrationLintOptions, type NarrationLintResult, NoEncoderError, PUBLISH_PROFILES, type PublishProfile, type RenderOptions, type RenderShardedArgs, SceneModuleError, ShardError, type ShardRange, type VideoInfo, VideoProbeError, applyMixGainDb, atempoChain, availableEncoders, buildCaptionProbe, buildMixWav, cacheVerifyCommand, capsId, clearFrameCache, collectAudioClips, computeGainDb, computeMixHash, dev, diffCommand, evaluateAt, ffmpegAvailable, fixDiff, formatTable, frameCacheKey, gainExpression, glissadeVersion, hasErrors, importCommand, lintNarration, lintTimingPathFor, loadSceneModule, localeOutPath, loudnessPathFor, measureFile, measureLoudnessCommand, narrationLintCommand, parseCacheMaxSize, parseEncoderList, parseLocalesList, parseLoudnormJson, peakClampBinds, pickEncoder, planAudioMix, planFinalAudio, probeEntryHeader, probeVideo, readLoudness, render, renderLocales, renderSharded, resolveAssetPath, resolveLoudnessGainDb, resolveProfile, resolveRenderDoc, sceneHasGpuNodes, snapshotAt, splitFrameRange };
964
+ export { AudioMixError, type AudioMixPlan, type CacheKeyContext, type CacheMode, type CacheVerifyOptions, type CacheVerifyResult, type CaptionProbe, DEFAULT_CACHE_MAX_SIZE, DEFAULT_FPS, DEFAULT_FRAMES, DEFAULT_PROFILE_ID, type DevOptions, type DevServer, type Diagnostic, type DiffOptions, type DiffResult, type EncoderChoice, FfmpegVideoFrameSource, FrameCache, FrameCacheError, type FrameCacheOptions, type ImportCommandResult, type ImportOptions, LOUDNESS_SCHEMA_VERSION, type LintOptions, type LintRule, LocaleArgsError, LoudnessError, type LoudnessMeasurement, MachineExportError, type MachineRenderFlags, type MeasureLoudnessOptions, type MeasureLoudnessResult, type NarrationLintOptions, type NarrationLintResult, NoEncoderError, PUBLISH_PROFILES, type PublishProfile, type RenderOptions, type RenderShardedArgs, type RepinFrame, type RepinOptions, type RepinResult, type RepinStatus, SceneModuleError, ShardError, type ShardRange, type VideoInfo, VideoProbeError, applyMixGainDb, atempoChain, availableEncoders, buildCaptionProbe, buildMixWav, cacheVerifyCommand, capsId, clearFrameCache, collectAudioClips, computeGainDb, computeMixHash, dev, diffCommand, evaluateAt, ffmpegAvailable, fixDiff, formatTable, frameCacheKey, gainExpression, glissadeVersion, hasErrors, importCommand, lintNarration, lintTimingPathFor, loadSceneModule, localeOutPath, loudnessPathFor, measureFile, measureLoudnessCommand, narrationLintCommand, parseCacheMaxSize, parseEncoderList, parseLocalesList, parseLoudnormJson, peakClampBinds, pickEncoder, planAudioMix, planFinalAudio, probeEntryHeader, probeVideo, readLoudness, render, renderLocales, renderSharded, repinCommand, resolveAssetPath, resolveLoudnessGainDb, resolveProfile, resolveRenderDoc, sceneHasGpuNodes, snapshotAt, splitFrameRange };
package/dist/index.js CHANGED
@@ -9,7 +9,8 @@ import { r as resolveRenderDoc, t as MachineExportError } from "./machines.js";
9
9
  import { t as dev } from "./dev.js";
10
10
  import { t as importCommand } from "./import.js";
11
11
  import { i as snapshotAt, r as evaluateAt, t as diffCommand } from "./diff.js";
12
+ import { n as DEFAULT_FRAMES, r as repinCommand, t as DEFAULT_FPS } from "./repin.js";
12
13
  import { a as fixDiff, c as lintNarration, n as lintTimingPathFor, o as formatTable, r as narrationLintCommand, s as hasErrors, t as buildCaptionProbe } from "./narrationLintCommand.js";
13
14
  import { a as clearFrameCache, c as parseCacheMaxSize, i as capsId, l as probeEntryHeader, n as FrameCache, o as frameCacheKey, r as FrameCacheError, t as DEFAULT_CACHE_MAX_SIZE } from "./frameCache.js";
14
15
  import { t as cacheVerifyCommand } from "./cacheVerify.js";
15
- export { AudioMixError, DEFAULT_CACHE_MAX_SIZE, DEFAULT_PROFILE_ID, FfmpegVideoFrameSource, FrameCache, FrameCacheError, LOUDNESS_SCHEMA_VERSION, LocaleArgsError, LoudnessError, MachineExportError, NoEncoderError, PUBLISH_PROFILES, SceneModuleError, ShardError, VideoProbeError, applyMixGainDb, atempoChain, availableEncoders, buildCaptionProbe, buildMixWav, cacheVerifyCommand, capsId, clearFrameCache, collectAudioClips, computeGainDb, computeMixHash, dev, diffCommand, evaluateAt, ffmpegAvailable, fixDiff, formatTable, frameCacheKey, gainExpression, glissadeVersion, hasErrors, importCommand, lintNarration, lintTimingPathFor, loadSceneModule, localeOutPath, loudnessPathFor, measureFile, measureLoudnessCommand, narrationLintCommand, parseCacheMaxSize, parseEncoderList, parseLocalesList, parseLoudnormJson, peakClampBinds, pickEncoder, planAudioMix, planFinalAudio, probeEntryHeader, probeVideo, readLoudness, render, renderLocales, renderSharded, resolveAssetPath, resolveLoudnessGainDb, resolveProfile, resolveRenderDoc, sceneHasGpuNodes, snapshotAt, splitFrameRange };
16
+ export { AudioMixError, DEFAULT_CACHE_MAX_SIZE, DEFAULT_FPS, DEFAULT_FRAMES, DEFAULT_PROFILE_ID, FfmpegVideoFrameSource, FrameCache, FrameCacheError, LOUDNESS_SCHEMA_VERSION, LocaleArgsError, LoudnessError, MachineExportError, NoEncoderError, PUBLISH_PROFILES, SceneModuleError, ShardError, VideoProbeError, applyMixGainDb, atempoChain, availableEncoders, buildCaptionProbe, buildMixWav, cacheVerifyCommand, capsId, clearFrameCache, collectAudioClips, computeGainDb, computeMixHash, dev, diffCommand, evaluateAt, ffmpegAvailable, fixDiff, formatTable, frameCacheKey, gainExpression, glissadeVersion, hasErrors, importCommand, lintNarration, lintTimingPathFor, loadSceneModule, localeOutPath, loudnessPathFor, measureFile, measureLoudnessCommand, narrationLintCommand, parseCacheMaxSize, parseEncoderList, parseLocalesList, parseLoudnormJson, peakClampBinds, pickEncoder, planAudioMix, planFinalAudio, probeEntryHeader, probeVideo, readLoudness, render, renderLocales, renderSharded, repinCommand, resolveAssetPath, resolveLoudnessGainDb, resolveProfile, resolveRenderDoc, sceneHasGpuNodes, snapshotAt, splitFrameRange };
package/dist/repin.js ADDED
@@ -0,0 +1,245 @@
1
+ import { t as __exportAll } from "./rolldown-runtime.js";
2
+ import { o as loadSceneModule } from "./render.js";
3
+ import { r as timingPathFor } from "./captions.js";
4
+ import { execFileSync } from "node:child_process";
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { basename, dirname, join, relative, resolve } from "node:path";
7
+ import { evaluate } from "@glissade/scene";
8
+ import { SkiaBackend, heatmapRgba, ssimMap } from "@glissade/backend-skia";
9
+ import { createCanvas, loadImage } from "@napi-rs/canvas";
10
+ //#region src/repin.ts
11
+ /**
12
+ * `gs repin` (0.37) — the narration-aware golden reviewer.
13
+ *
14
+ * The lived pain: one re-narration re-flows every beat, so all of a project's
15
+ * goldens go stale at once and you re-pin them blind with `vitest -u`. `gs repin`
16
+ * renders the CURRENT scene frame-by-frame against the committed golden PNGs and,
17
+ * for every frame that changed, reports:
18
+ *
19
+ * • a perceptual delta (mean SSIM + the worst 8×8 tile — WHERE it changed), and
20
+ * • a one-line CAUSE, by diffing the scene's `*.narration.timing.json` sibling
21
+ * against a git ref (default HEAD): "seg-4 moved +0.21s: re-narration".
22
+ *
23
+ * Then it re-pins only the frames you allow. Default is a DRY RUN (report only);
24
+ * `--write` overwrites the changed goldens. A `--floor <ssim>` guard REFUSES to
25
+ * write any frame whose perceptual drop is bigger than you'd expect from a
26
+ * re-narration (an actual regression sneaking into a re-pin) unless `--force`.
27
+ *
28
+ * Byte-equality stays the golden contract (same as the harness): a frame is
29
+ * "identical" only when the PNG bytes match; SSIM is used solely to EXPLAIN and
30
+ * to GATE a divergence, never to accept one silently.
31
+ */
32
+ var repin_exports = /* @__PURE__ */ __exportAll({
33
+ DEFAULT_FPS: () => 60,
34
+ DEFAULT_FRAMES: () => DEFAULT_FRAMES,
35
+ causeFor: () => causeFor,
36
+ diffTiming: () => diffTiming,
37
+ repinCommand: () => repinCommand
38
+ });
39
+ /** The default golden frame set + fps — matches the Skia golden harness. */
40
+ const DEFAULT_FRAMES = [
41
+ 0,
42
+ 30,
43
+ 60,
44
+ 90,
45
+ 120,
46
+ 150,
47
+ 179
48
+ ];
49
+ const DEFAULT_FPS = 60;
50
+ /** Read `<ref>:<path>` from git as text; null when not a repo / not tracked. */
51
+ function gitShow(ref, absPath) {
52
+ try {
53
+ const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
54
+ cwd: dirname(absPath),
55
+ stdio: [
56
+ "ignore",
57
+ "pipe",
58
+ "ignore"
59
+ ]
60
+ }).toString().trim();
61
+ return execFileSync("git", ["show", `${ref}:${relative(root, absPath)}`], {
62
+ cwd: root,
63
+ stdio: [
64
+ "ignore",
65
+ "pipe",
66
+ "ignore"
67
+ ]
68
+ }).toString("utf8");
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+ /** Segment-by-id delta between two narration timings (older `a` → current `b`). */
74
+ function diffTiming(a, b) {
75
+ const old = new Map(a.segments.map((s) => [s.id, s]));
76
+ const shifts = [];
77
+ for (const s of b.segments) {
78
+ const prev = old.get(s.id);
79
+ shifts.push({
80
+ id: s.id,
81
+ start: s.start,
82
+ deltaStart: prev ? s.start - prev.start : 0,
83
+ added: !prev,
84
+ removed: false
85
+ });
86
+ old.delete(s.id);
87
+ }
88
+ for (const [id, s] of old) shifts.push({
89
+ id,
90
+ start: s.start,
91
+ deltaStart: 0,
92
+ added: false,
93
+ removed: true
94
+ });
95
+ return shifts.sort((x, y) => x.start - y.start);
96
+ }
97
+ const EPS = 1e-4;
98
+ const sign = (n) => `${n >= 0 ? "+" : ""}${n.toFixed(2)}s`;
99
+ /** One-line cause for a frame at time `t`, from the timing shifts. */
100
+ function causeFor(t, shifts) {
101
+ const before = shifts.filter((s) => s.start <= t + EPS && !s.removed);
102
+ const active = before[before.length - 1];
103
+ if (active && active.added) return `${active.id}: new segment`;
104
+ if (active && Math.abs(active.deltaStart) > EPS) return `${active.id} moved ${sign(active.deltaStart)}: re-narration`;
105
+ const upstream = before.filter((s) => Math.abs(s.deltaStart) > EPS).pop();
106
+ if (upstream) return `downstream of ${upstream.id} (${sign(upstream.deltaStart)}): re-narration`;
107
+ }
108
+ async function decodePng(buf) {
109
+ const img = await loadImage(buf);
110
+ const w = img.width;
111
+ const h = img.height;
112
+ const ctx = createCanvas(w, h).getContext("2d");
113
+ ctx.drawImage(img, 0, 0);
114
+ return {
115
+ rgba: ctx.getImageData(0, 0, w, h).data,
116
+ w,
117
+ h
118
+ };
119
+ }
120
+ function goldenPathFor(dir, name, frame) {
121
+ return join(dir, `${name}-f${String(frame).padStart(4, "0")}.png`);
122
+ }
123
+ async function repinCommand(opts) {
124
+ const fps = opts.fps ?? 60;
125
+ const frames = opts.frames ?? DEFAULT_FRAMES;
126
+ const name = opts.name ?? basename(opts.modulePath).replace(/\.[jt]sx?$/, "");
127
+ const onlySet = opts.only ? new Set(opts.only) : null;
128
+ const mod = await loadSceneModule(opts.modulePath);
129
+ let shifts = [];
130
+ let causeSource = null;
131
+ const timingPath = timingPathFor(opts.modulePath);
132
+ if (timingPath) {
133
+ const older = gitShow(opts.since ?? "HEAD", resolve(timingPath));
134
+ if (older) try {
135
+ shifts = diffTiming(JSON.parse(older), JSON.parse(readFileSync(timingPath, "utf8")));
136
+ causeSource = timingPath;
137
+ } catch {}
138
+ }
139
+ if (opts.heatmapDir && !existsSync(opts.heatmapDir)) mkdirSync(opts.heatmapDir, { recursive: true });
140
+ const results = [];
141
+ for (const frame of frames) {
142
+ const t = frame / fps;
143
+ const scene = mod.createScene();
144
+ const backend = new SkiaBackend(scene.size.w, scene.size.h);
145
+ scene.setTextMeasurer(backend);
146
+ backend.render(evaluate(scene, mod.timeline, t));
147
+ const curPng = backend.encodePng();
148
+ const curRgba = await backend.readPixels();
149
+ const goldenPath = goldenPathFor(opts.goldenDir, name, frame);
150
+ const canWrite = opts.write === true && (!onlySet || onlySet.has(frame));
151
+ if (!existsSync(goldenPath)) {
152
+ let wrote = false;
153
+ if (canWrite) {
154
+ mkdirSync(dirname(goldenPath), { recursive: true });
155
+ writeFileSync(goldenPath, curPng);
156
+ wrote = true;
157
+ }
158
+ results.push({
159
+ frame,
160
+ t,
161
+ status: "new",
162
+ wrote
163
+ });
164
+ continue;
165
+ }
166
+ const goldPng = readFileSync(goldenPath);
167
+ if (curPng.equals(goldPng)) {
168
+ results.push({
169
+ frame,
170
+ t,
171
+ status: "identical",
172
+ wrote: false
173
+ });
174
+ continue;
175
+ }
176
+ const rec = {
177
+ frame,
178
+ t,
179
+ status: "changed",
180
+ wrote: false
181
+ };
182
+ const { rgba: goldRgba, w, h } = await decodePng(goldPng);
183
+ if (w === scene.size.w && h === scene.size.h) {
184
+ const map = ssimMap(goldRgba, curRgba, w, h);
185
+ rec.ssim = map.mean;
186
+ rec.minSsim = map.min;
187
+ if (opts.heatmapDir) {
188
+ const hb = new SkiaBackend(w, h);
189
+ hb.putPixels(heatmapRgba(map, w, h));
190
+ const hp = join(opts.heatmapDir, `${name}-f${String(frame).padStart(4, "0")}.heat.png`);
191
+ writeFileSync(hp, hb.encodePng());
192
+ rec.heatmap = hp;
193
+ }
194
+ }
195
+ const cause = causeFor(t, shifts);
196
+ if (cause) rec.cause = cause;
197
+ const belowFloor = opts.floor !== void 0 && rec.ssim !== void 0 && rec.ssim < opts.floor;
198
+ if (canWrite) if (belowFloor && !opts.force) rec.blocked = true;
199
+ else {
200
+ writeFileSync(goldenPath, curPng);
201
+ rec.wrote = true;
202
+ }
203
+ results.push(rec);
204
+ }
205
+ const changed = results.filter((r) => r.status === "changed" || r.status === "new").length;
206
+ const wrote = results.filter((r) => r.wrote).length;
207
+ const blocked = results.filter((r) => r.blocked).length;
208
+ return {
209
+ frames: results,
210
+ changed,
211
+ wrote,
212
+ blocked,
213
+ causeSource,
214
+ report: formatReport(name, opts, {
215
+ frames: results,
216
+ changed,
217
+ wrote,
218
+ blocked,
219
+ causeSource
220
+ })
221
+ };
222
+ }
223
+ function formatReport(name, opts, r) {
224
+ const lines = [];
225
+ const mode = opts.write ? opts.only ? "write (--only)" : "write" : "dry-run";
226
+ lines.push(`gs repin '${name}' — ${r.frames.length} frames, ${r.changed} changed [${mode}]`);
227
+ if (r.causeSource) lines.push(` cause ← ${r.causeSource} vs ${opts.since ?? "HEAD"}`);
228
+ else lines.push(` (no narration timing sibling diffed — perceptual delta only)`);
229
+ for (const f of r.frames) {
230
+ if (f.status === "identical") continue;
231
+ const fno = `f${String(f.frame).padStart(4, "0")}`;
232
+ if (f.status === "new") {
233
+ lines.push(` ${fno} NEW ${f.wrote ? "→ written" : "(dry-run)"}`);
234
+ continue;
235
+ }
236
+ const perc = f.ssim !== void 0 ? `ssim ${f.ssim.toFixed(4)} (min ${f.minSsim.toFixed(3)})` : `dimensions changed`;
237
+ const tail = f.blocked ? "⚠ BELOW FLOOR — refused (use --force)" : f.wrote ? "→ re-pinned" : "(dry-run)";
238
+ lines.push(` ${fno} ${perc} ${f.cause ? `— ${f.cause} ` : ""}${tail}`);
239
+ }
240
+ if (!opts.write && r.changed > 0) lines.push(` ${r.changed} stale — re-run with --write to re-pin${opts.floor !== void 0 ? ` (floor ${opts.floor})` : ""}`);
241
+ if (r.blocked > 0) lines.push(` ${r.blocked} refused below floor — inspect the heat-map, then --force if intended`);
242
+ return lines.join("\n");
243
+ }
244
+ //#endregion
245
+ export { repin_exports as i, DEFAULT_FRAMES as n, repinCommand as r, DEFAULT_FPS as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/cli",
3
- "version": "0.36.0",
3
+ "version": "0.37.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.36.0",
32
- "@glissade/core": "0.36.0",
33
- "@glissade/interact": "0.36.0",
34
- "@glissade/lottie": "0.36.0",
35
- "@glissade/narrate": "0.36.0",
36
- "@glissade/player": "0.36.0",
37
- "@glissade/scene": "0.36.0",
38
- "@glissade/sfx": "0.36.0",
39
- "@glissade/svg": "0.36.0"
31
+ "@glissade/backend-skia": "0.37.0-pre.0",
32
+ "@glissade/core": "0.37.0-pre.0",
33
+ "@glissade/interact": "0.37.0-pre.0",
34
+ "@glissade/lottie": "0.37.0-pre.0",
35
+ "@glissade/narrate": "0.37.0-pre.0",
36
+ "@glissade/player": "0.37.0-pre.0",
37
+ "@glissade/scene": "0.37.0-pre.0",
38
+ "@glissade/sfx": "0.37.0-pre.0",
39
+ "@glissade/svg": "0.37.0-pre.0"
40
40
  },
41
41
  "repository": {
42
42
  "type": "git",