@glissade/cli 0.36.0 → 0.37.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.
- package/README.md +20 -1
- package/dist/captions.js +1 -1
- package/dist/cli.js +41 -1
- package/dist/index.d.ts +57 -1
- package/dist/index.js +2 -1
- package/dist/repin.js +273 -0
- package/package.json +10 -10
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
`gs` — headless rendering and the dev/capture loop. `gs render` evaluates every frame, rasterizes on Skia (no browser anywhere), writes a PNG sequence or muxes mp4/webm via FFmpeg with sample-accurate audio. v2: `gs dev --record` serves a scene with its state machines mounted and writes input-trace sidecars; `gs render --trace/--state` are the deterministic export routes for interactive scenes.
|
|
4
4
|
|
|
5
|
-
The full command set: `render`, `dev`, `import` (Lottie `.json` / `.svg`), `build`, `mcp` (an MCP server over the engine), `describe`, `migrate`, `narrate`, `sfx`, `prepare`, `measure-loudness`, `fonts`, `cache`, `diff`, and `verify-determinism`.
|
|
5
|
+
The full command set: `render`, `dev`, `import` (Lottie `.json` / `.svg`), `build`, `mcp` (an MCP server over the engine), `describe`, `migrate`, `repin` (narration-aware golden reviewer), `narrate`, `sfx`, `prepare`, `measure-loudness`, `fonts`, `cache`, `diff`, and `verify-determinism`. Run `gs` with no arguments for the full usage.
|
|
6
6
|
|
|
7
7
|
```sh
|
|
8
8
|
npm i -D @glissade/cli
|
|
@@ -14,6 +14,25 @@ gs dev scene.ts --record # capture a take
|
|
|
14
14
|
gs render scene.ts --trace scene.button.take1.trace.json --out take.mp4
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
+
## Reviewing goldens (`gs repin`)
|
|
18
|
+
|
|
19
|
+
When a re-narration re-flows a project's beats, every downstream golden goes
|
|
20
|
+
stale at once. `gs repin` renders the current scene vs the committed golden PNGs
|
|
21
|
+
and, per changed frame, reports a perceptual SSIM delta (mean + worst tile) plus a
|
|
22
|
+
one-line **cause** — it diffs the `*.narration.timing.json` sibling against a git
|
|
23
|
+
ref to attribute the edit site and trace the pushed beats back to it. Default is a
|
|
24
|
+
dry run; `--write` re-pins, and `--floor <ssim>` refuses a bigger-than-expected
|
|
25
|
+
drop until `--force` — the confidence gate before you bless a batch re-pin.
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
gs repin scene.ts --golden test/golden # dry-run: what changed & why
|
|
29
|
+
gs repin scene.ts --golden test/golden --write --floor 0.98 # re-pin, guarded
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The SSIM machinery is also exported from `@glissade/backend-skia`
|
|
33
|
+
(`ssim` / `ssimMap` / `heatmapRgba`) for your own golden tooling. Full guide:
|
|
34
|
+
[Reviewing goldens](https://github.com/tyevco/glissade/blob/main/docs/golden-review.md).
|
|
35
|
+
|
|
17
36
|
## Part of glissade
|
|
18
37
|
|
|
19
38
|
*(glide & slide)* — programmatic motion graphics for TypeScript: realtime-first in any web page, deterministic headless video export from the same code, a visual studio over the same document. No generator functions.
|
package/dist/captions.js
CHANGED
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,273 @@
|
|
|
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
|
+
deltaDuration: prev ? s.duration - prev.duration : 0,
|
|
84
|
+
added: !prev,
|
|
85
|
+
removed: false
|
|
86
|
+
});
|
|
87
|
+
old.delete(s.id);
|
|
88
|
+
}
|
|
89
|
+
for (const [id, s] of old) shifts.push({
|
|
90
|
+
id,
|
|
91
|
+
start: s.start,
|
|
92
|
+
deltaStart: 0,
|
|
93
|
+
deltaDuration: 0,
|
|
94
|
+
added: false,
|
|
95
|
+
removed: true
|
|
96
|
+
});
|
|
97
|
+
return shifts.sort((x, y) => x.start - y.start);
|
|
98
|
+
}
|
|
99
|
+
const EPS = 1e-4;
|
|
100
|
+
const sign = (n) => `${n >= 0 ? "+" : ""}${n.toFixed(2)}s`;
|
|
101
|
+
/** Is this shift a re-narration ROOT — a segment whose own content changed
|
|
102
|
+
* (duration moved) or that was newly added, i.e. an EDIT SITE that pushes
|
|
103
|
+
* everything downstream of it? */
|
|
104
|
+
function isRoot(s) {
|
|
105
|
+
return s.added || Math.abs(s.deltaDuration) > EPS;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* One-line cause for a frame at time `t`. A re-narration that re-records one line
|
|
109
|
+
* changes that segment's DURATION (not its start) and pushes every later segment's
|
|
110
|
+
* start by the same amount — so attribution has two jobs:
|
|
111
|
+
* • name the EDIT SITE by its duration change (its start doesn't move), and
|
|
112
|
+
* • trace a purely-shifted downstream beat back to the ROOT that pushed it,
|
|
113
|
+
* rather than letting it claim its own (derived) shift.
|
|
114
|
+
*/
|
|
115
|
+
function causeFor(t, shifts) {
|
|
116
|
+
const before = shifts.filter((s) => s.start <= t + EPS && !s.removed);
|
|
117
|
+
const active = before[before.length - 1];
|
|
118
|
+
if (!active) return void 0;
|
|
119
|
+
if (active.added) return `${active.id}: new segment`;
|
|
120
|
+
if (Math.abs(active.deltaDuration) > EPS) return `${active.id} re-narrated (${sign(active.deltaDuration)} duration): re-narration`;
|
|
121
|
+
const root = before.slice(0, -1).reverse().find(isRoot);
|
|
122
|
+
if (Math.abs(active.deltaStart) > EPS) {
|
|
123
|
+
if (root) return `downstream of ${root.id} (${sign(active.deltaStart)}): re-narration`;
|
|
124
|
+
return `${active.id} moved ${sign(active.deltaStart)}: re-narration`;
|
|
125
|
+
}
|
|
126
|
+
if (root) return `downstream of ${root.id} (${sign(root.deltaDuration)}): re-narration`;
|
|
127
|
+
}
|
|
128
|
+
async function decodePng(buf) {
|
|
129
|
+
const img = await loadImage(buf);
|
|
130
|
+
const w = img.width;
|
|
131
|
+
const h = img.height;
|
|
132
|
+
const ctx = createCanvas(w, h).getContext("2d");
|
|
133
|
+
ctx.drawImage(img, 0, 0);
|
|
134
|
+
return {
|
|
135
|
+
rgba: ctx.getImageData(0, 0, w, h).data,
|
|
136
|
+
w,
|
|
137
|
+
h
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function goldenPathFor(dir, name, frame) {
|
|
141
|
+
return join(dir, `${name}-f${String(frame).padStart(4, "0")}.png`);
|
|
142
|
+
}
|
|
143
|
+
async function repinCommand(opts) {
|
|
144
|
+
const fps = opts.fps ?? 60;
|
|
145
|
+
const frames = opts.frames ?? DEFAULT_FRAMES;
|
|
146
|
+
const name = opts.name ?? basename(opts.modulePath).replace(/\.[jt]sx?$/, "");
|
|
147
|
+
const onlySet = opts.only ? new Set(opts.only) : null;
|
|
148
|
+
const mod = await loadSceneModule(opts.modulePath);
|
|
149
|
+
let shifts = [];
|
|
150
|
+
let causeSource = null;
|
|
151
|
+
const timingPath = timingPathFor(opts.modulePath);
|
|
152
|
+
if (timingPath) {
|
|
153
|
+
const older = gitShow(opts.since ?? "HEAD", resolve(timingPath));
|
|
154
|
+
if (older) try {
|
|
155
|
+
shifts = diffTiming(JSON.parse(older), JSON.parse(readFileSync(timingPath, "utf8")));
|
|
156
|
+
causeSource = timingPath;
|
|
157
|
+
} catch {}
|
|
158
|
+
}
|
|
159
|
+
if (opts.heatmapDir && !existsSync(opts.heatmapDir)) mkdirSync(opts.heatmapDir, { recursive: true });
|
|
160
|
+
const results = [];
|
|
161
|
+
for (const frame of frames) {
|
|
162
|
+
const t = frame / fps;
|
|
163
|
+
const scene = mod.createScene();
|
|
164
|
+
const backend = new SkiaBackend(scene.size.w, scene.size.h);
|
|
165
|
+
scene.setTextMeasurer(backend);
|
|
166
|
+
backend.render(evaluate(scene, mod.timeline, t));
|
|
167
|
+
const curPng = backend.encodePng();
|
|
168
|
+
const curRgba = await backend.readPixels();
|
|
169
|
+
const goldenPath = goldenPathFor(opts.goldenDir, name, frame);
|
|
170
|
+
const canWrite = opts.write === true && (!onlySet || onlySet.has(frame));
|
|
171
|
+
if (!existsSync(goldenPath)) {
|
|
172
|
+
let wrote = false;
|
|
173
|
+
if (canWrite) {
|
|
174
|
+
mkdirSync(dirname(goldenPath), { recursive: true });
|
|
175
|
+
writeFileSync(goldenPath, curPng);
|
|
176
|
+
wrote = true;
|
|
177
|
+
}
|
|
178
|
+
results.push({
|
|
179
|
+
frame,
|
|
180
|
+
t,
|
|
181
|
+
status: "new",
|
|
182
|
+
wrote
|
|
183
|
+
});
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const goldPng = readFileSync(goldenPath);
|
|
187
|
+
if (curPng.equals(goldPng)) {
|
|
188
|
+
results.push({
|
|
189
|
+
frame,
|
|
190
|
+
t,
|
|
191
|
+
status: "identical",
|
|
192
|
+
wrote: false
|
|
193
|
+
});
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const rec = {
|
|
197
|
+
frame,
|
|
198
|
+
t,
|
|
199
|
+
status: "changed",
|
|
200
|
+
wrote: false
|
|
201
|
+
};
|
|
202
|
+
const { rgba: goldRgba, w, h } = await decodePng(goldPng);
|
|
203
|
+
if (w === scene.size.w && h === scene.size.h) {
|
|
204
|
+
const map = ssimMap(goldRgba, curRgba, w, h);
|
|
205
|
+
rec.ssim = map.mean;
|
|
206
|
+
rec.minSsim = map.min;
|
|
207
|
+
if (opts.heatmapDir) {
|
|
208
|
+
const hb = new SkiaBackend(w, h);
|
|
209
|
+
hb.putPixels(heatmapRgba(map, w, h));
|
|
210
|
+
const hp = join(opts.heatmapDir, `${name}-f${String(frame).padStart(4, "0")}.heat.png`);
|
|
211
|
+
writeFileSync(hp, hb.encodePng());
|
|
212
|
+
rec.heatmap = hp;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const cause = causeFor(t, shifts);
|
|
216
|
+
if (cause) rec.cause = cause;
|
|
217
|
+
const belowFloor = opts.floor !== void 0 && rec.ssim !== void 0 && rec.ssim < opts.floor;
|
|
218
|
+
if (canWrite) if (belowFloor && !opts.force) rec.blocked = true;
|
|
219
|
+
else {
|
|
220
|
+
writeFileSync(goldenPath, curPng);
|
|
221
|
+
rec.wrote = true;
|
|
222
|
+
}
|
|
223
|
+
results.push(rec);
|
|
224
|
+
}
|
|
225
|
+
const changed = results.filter((r) => r.status === "changed" || r.status === "new").length;
|
|
226
|
+
const wrote = results.filter((r) => r.wrote).length;
|
|
227
|
+
const blocked = results.filter((r) => r.blocked).length;
|
|
228
|
+
return {
|
|
229
|
+
frames: results,
|
|
230
|
+
changed,
|
|
231
|
+
wrote,
|
|
232
|
+
blocked,
|
|
233
|
+
causeSource,
|
|
234
|
+
report: formatReport(name, opts, {
|
|
235
|
+
frames: results,
|
|
236
|
+
changed,
|
|
237
|
+
wrote,
|
|
238
|
+
blocked,
|
|
239
|
+
causeSource
|
|
240
|
+
})
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
function formatReport(name, opts, r) {
|
|
244
|
+
const lines = [];
|
|
245
|
+
const mode = opts.write ? opts.only ? "write (--only)" : "write" : "dry-run";
|
|
246
|
+
lines.push(`gs repin '${name}' — ${r.frames.length} frames, ${r.changed} changed [${mode}]`);
|
|
247
|
+
if (r.causeSource) lines.push(` cause ← ${r.causeSource} vs ${opts.since ?? "HEAD"}`);
|
|
248
|
+
else lines.push(` (no narration timing sibling diffed — perceptual delta only)`);
|
|
249
|
+
let worstFrame = -1;
|
|
250
|
+
let worstSsim = Infinity;
|
|
251
|
+
for (const f of r.frames) if (f.status === "changed" && f.ssim !== void 0 && f.ssim < worstSsim) {
|
|
252
|
+
worstSsim = f.ssim;
|
|
253
|
+
worstFrame = f.frame;
|
|
254
|
+
}
|
|
255
|
+
const marked = r.changed > 1 && worstFrame >= 0;
|
|
256
|
+
for (const f of r.frames) {
|
|
257
|
+
if (f.status === "identical") continue;
|
|
258
|
+
const fno = `f${String(f.frame).padStart(4, "0")}`;
|
|
259
|
+
if (f.status === "new") {
|
|
260
|
+
lines.push(` ${fno} NEW ${f.wrote ? "→ written" : "(dry-run)"}`);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
const perc = f.ssim !== void 0 ? `ssim ${f.ssim.toFixed(4)} (min ${f.minSsim.toFixed(3)})` : `dimensions changed`;
|
|
264
|
+
const tail = f.blocked ? "⚠ BELOW FLOOR — refused (use --force)" : f.wrote ? "→ re-pinned" : "(dry-run)";
|
|
265
|
+
const editMark = marked && f.frame === worstFrame ? "◀ likely edit-site (lowest SSIM) " : "";
|
|
266
|
+
lines.push(` ${fno} ${perc} ${f.cause ? `— ${f.cause} ` : ""}${editMark}${tail}`);
|
|
267
|
+
}
|
|
268
|
+
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})` : ""}`);
|
|
269
|
+
if (r.blocked > 0) lines.push(` ${r.blocked} refused below floor — inspect the heat-map, then --force if intended`);
|
|
270
|
+
return lines.join("\n");
|
|
271
|
+
}
|
|
272
|
+
//#endregion
|
|
273
|
+
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.
|
|
3
|
+
"version": "0.37.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.
|
|
32
|
-
"@glissade/core": "0.
|
|
33
|
-
"@glissade/interact": "0.
|
|
34
|
-
"@glissade/lottie": "0.
|
|
35
|
-
"@glissade/narrate": "0.
|
|
36
|
-
"@glissade/player": "0.
|
|
37
|
-
"@glissade/scene": "0.
|
|
38
|
-
"@glissade/sfx": "0.
|
|
39
|
-
"@glissade/svg": "0.
|
|
31
|
+
"@glissade/backend-skia": "0.37.0-pre.1",
|
|
32
|
+
"@glissade/core": "0.37.0-pre.1",
|
|
33
|
+
"@glissade/interact": "0.37.0-pre.1",
|
|
34
|
+
"@glissade/lottie": "0.37.0-pre.1",
|
|
35
|
+
"@glissade/narrate": "0.37.0-pre.1",
|
|
36
|
+
"@glissade/player": "0.37.0-pre.1",
|
|
37
|
+
"@glissade/scene": "0.37.0-pre.1",
|
|
38
|
+
"@glissade/sfx": "0.37.0-pre.1",
|
|
39
|
+
"@glissade/svg": "0.37.0-pre.1"
|
|
40
40
|
},
|
|
41
41
|
"repository": {
|
|
42
42
|
"type": "git",
|