@glissade/cli 0.11.0 → 0.12.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/audioMix.js +16 -1
- package/dist/cacheVerify.js +133 -0
- package/dist/cli.js +227 -2
- package/dist/diff.js +66 -0
- package/dist/fonts.js +122 -0
- package/dist/frameCache.js +299 -0
- package/dist/index.d.ts +506 -8
- package/dist/index.js +9 -3
- package/dist/loudness.js +254 -0
- package/dist/music.js +1 -1
- package/dist/narrationLintCommand.js +314 -0
- package/dist/prepare.js +1 -1
- package/dist/render.js +153 -13
- package/dist/rolldown-runtime.js +1 -0
- package/dist/shards.js +1 -1
- package/dist/verifyDeterminism.js +302 -0
- package/dist/version.js +26 -0
- package/package.json +10 -10
package/dist/loudness.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime.js";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
//#region src/loudness.ts
|
|
8
|
+
/**
|
|
9
|
+
* gs measure-loudness (0.12, DESIGN §5.3): loudness-normalized PUBLISH profiles
|
|
10
|
+
* via a deterministic, peak-clamped scalar GAIN — NOT a render-pipeline change.
|
|
11
|
+
*
|
|
12
|
+
* The key insight: YouTube/Shorts re-normalize loudness platform-side, so the
|
|
13
|
+
* publish target is *≤ target-LUFS AND ≤ -1 dBTP*, not exact. So we never need a
|
|
14
|
+
* two-pass limiter on the render hot path. Instead:
|
|
15
|
+
*
|
|
16
|
+
* 1. `gs measure-loudness` runs ffmpeg's `loudnorm` measurement pass (a
|
|
17
|
+
* print-only ebur128 + true-peak gate) at MEASURE-time over the final
|
|
18
|
+
* mixed PCM, reading `inputI` (integrated LUFS), `inputTp` (true peak
|
|
19
|
+
* dBTP), `inputLra`. This is the ONE non-deterministic stage and it is
|
|
20
|
+
* QUARANTINED to commit-time — §5.3 already concedes mix-to-PCM bytes are
|
|
21
|
+
* per-path only.
|
|
22
|
+
* 2. It commits a `<scene>.loudness.json` carrying the measured numbers, the
|
|
23
|
+
* chosen profile, the resulting `gain` (dB), and a `mixHash` binding the
|
|
24
|
+
* measurement to the mix CONTENT (the narration/music/sfx manifests).
|
|
25
|
+
* 3. `gs render` reads that file and applies `gain` as a PURE scalar
|
|
26
|
+
* `volume=<gain>dB` multiply on the FINAL mix node — a single scalar in the
|
|
27
|
+
* existing filter graph, NOT a new ffmpeg pass. That stage is bit-exact and
|
|
28
|
+
* golden-hashable (a multiply at the same float→Int16 boundary the rest of
|
|
29
|
+
* the audio path shares).
|
|
30
|
+
*
|
|
31
|
+
* The gain is peak-clamped: `gain = min(gainForTargetLUFS, (-1 dBTP) - inputTp)`.
|
|
32
|
+
* The clamp uses the MEASURED true-peak, so the published output is guaranteed
|
|
33
|
+
* ≤ -1 dBTP with no render-time oversampling. (For 0.12 the brickwall true-peak
|
|
34
|
+
* LIMITER is DEFERRED — un-normalized profiles still get the gain plus an
|
|
35
|
+
* advisory warning when a peaky source would have needed brickwalling.)
|
|
36
|
+
*/
|
|
37
|
+
var loudness_exports = /* @__PURE__ */ __exportAll({
|
|
38
|
+
DEFAULT_PROFILE_ID: () => DEFAULT_PROFILE_ID,
|
|
39
|
+
LOUDNESS_SCHEMA_VERSION: () => 1,
|
|
40
|
+
LoudnessError: () => LoudnessError,
|
|
41
|
+
PUBLISH_PROFILES: () => PUBLISH_PROFILES,
|
|
42
|
+
computeGainDb: () => computeGainDb,
|
|
43
|
+
computeMixHash: () => computeMixHash,
|
|
44
|
+
loudnessPathFor: () => loudnessPathFor,
|
|
45
|
+
measureFile: () => measureFile,
|
|
46
|
+
measureLoudnessCommand: () => measureLoudnessCommand,
|
|
47
|
+
parseLoudnormJson: () => parseLoudnormJson,
|
|
48
|
+
peakClampBinds: () => peakClampBinds,
|
|
49
|
+
readLoudness: () => readLoudness,
|
|
50
|
+
resolveProfile: () => resolveProfile
|
|
51
|
+
});
|
|
52
|
+
var LoudnessError = class extends Error {
|
|
53
|
+
constructor(detail) {
|
|
54
|
+
super(`loudness: ${detail}`);
|
|
55
|
+
this.name = "LoudnessError";
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const PUBLISH_PROFILES = Object.freeze({
|
|
59
|
+
youtube: {
|
|
60
|
+
id: "youtube",
|
|
61
|
+
targetLufs: -14,
|
|
62
|
+
truePeakDb: -1,
|
|
63
|
+
platformNormalized: true
|
|
64
|
+
},
|
|
65
|
+
shorts: {
|
|
66
|
+
id: "shorts",
|
|
67
|
+
targetLufs: -14,
|
|
68
|
+
truePeakDb: -1,
|
|
69
|
+
platformNormalized: true
|
|
70
|
+
},
|
|
71
|
+
podcast: {
|
|
72
|
+
id: "podcast",
|
|
73
|
+
targetLufs: -16,
|
|
74
|
+
truePeakDb: -1,
|
|
75
|
+
platformNormalized: false
|
|
76
|
+
},
|
|
77
|
+
broadcast: {
|
|
78
|
+
id: "broadcast",
|
|
79
|
+
targetLufs: -23,
|
|
80
|
+
truePeakDb: -1,
|
|
81
|
+
platformNormalized: false
|
|
82
|
+
},
|
|
83
|
+
ebu: {
|
|
84
|
+
id: "ebu",
|
|
85
|
+
targetLufs: -23,
|
|
86
|
+
truePeakDb: -1,
|
|
87
|
+
platformNormalized: false
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
const DEFAULT_PROFILE_ID = "youtube";
|
|
91
|
+
/** Resolve a profile id (case-insensitive) or throw with the valid set. */
|
|
92
|
+
function resolveProfile(id) {
|
|
93
|
+
const p = PUBLISH_PROFILES[id.trim().toLowerCase()];
|
|
94
|
+
if (!p) throw new LoudnessError(`unknown publish profile '${id}' (have: ${Object.keys(PUBLISH_PROFILES).join(", ")})`);
|
|
95
|
+
return p;
|
|
96
|
+
}
|
|
97
|
+
const LOUDNESS_SCHEMA_VERSION = 1;
|
|
98
|
+
/**
|
|
99
|
+
* The peak-clamped publish gain (dB). `gainForTargetLUFS = target - inputI`
|
|
100
|
+
* raises/lowers to the integrated target; the clamp `truePeak - inputTp` caps it
|
|
101
|
+
* so the result never exceeds the true-peak ceiling. We take the min: a source
|
|
102
|
+
* that can't reach the loudness target without clipping is left below it (the
|
|
103
|
+
* platform re-normalizes the rest for `youtube`/`shorts`).
|
|
104
|
+
*/
|
|
105
|
+
function computeGainDb(profile, inputI, inputTp) {
|
|
106
|
+
const gainForTargetLufs = profile.targetLufs - inputI;
|
|
107
|
+
const peakClamp = profile.truePeakDb - inputTp;
|
|
108
|
+
return Math.min(gainForTargetLufs, peakClamp);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* True when the peak clamp BOUND the gain — i.e. the source is too peaky to reach
|
|
112
|
+
* the loudness target without exceeding the true-peak ceiling. For an
|
|
113
|
+
* un-normalized profile this is where a brickwall limiter would normally recover
|
|
114
|
+
* the headroom (deferred in 0.12 → advisory warning).
|
|
115
|
+
*/
|
|
116
|
+
function peakClampBinds(profile, inputI, inputTp) {
|
|
117
|
+
return profile.truePeakDb - inputTp < profile.targetLufs - inputI;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Hash the CONTENT of the mix's input manifests so a re-narrate / re-sfx / music
|
|
121
|
+
* change invalidates a committed measurement. We hash the files' BYTES (not
|
|
122
|
+
* mtime): the narration/music/sfx timing manifests and any wired timeline audio
|
|
123
|
+
* sidecars. Missing siblings are recorded by name with a sentinel so adding one
|
|
124
|
+
* later also changes the hash. The scene module path itself is excluded — the
|
|
125
|
+
* mix is a function of the manifests, and timeline `audio` clips flow through the
|
|
126
|
+
* narration/music/sfx manifests or the explicitly-passed extra inputs.
|
|
127
|
+
*/
|
|
128
|
+
function computeMixHash(modulePath, extraInputs = []) {
|
|
129
|
+
const base = modulePath.replace(/\.[jt]sx?$/, "");
|
|
130
|
+
const siblings = [
|
|
131
|
+
`${base}.narration.timing.json`,
|
|
132
|
+
`${base}.music.timing.json`,
|
|
133
|
+
`${base}.sfx.timing.json`
|
|
134
|
+
];
|
|
135
|
+
const h = createHash("sha256");
|
|
136
|
+
for (const path of [...siblings, ...extraInputs]) {
|
|
137
|
+
h.update(path);
|
|
138
|
+
h.update("\0");
|
|
139
|
+
if (existsSync(path)) h.update(readFileSync(path));
|
|
140
|
+
else h.update("\0ABSENT\0");
|
|
141
|
+
h.update("\0");
|
|
142
|
+
}
|
|
143
|
+
return `sha256:${h.digest("hex")}`;
|
|
144
|
+
}
|
|
145
|
+
/** `<module>.loudness.json` for a scene module. */
|
|
146
|
+
function loudnessPathFor(modulePath) {
|
|
147
|
+
return modulePath.replace(/\.[jt]sx?$/, "") + ".loudness.json";
|
|
148
|
+
}
|
|
149
|
+
/** Read + validate a committed measurement, or null when none is committed. */
|
|
150
|
+
function readLoudness(modulePath) {
|
|
151
|
+
const path = loudnessPathFor(modulePath);
|
|
152
|
+
if (!existsSync(path)) return null;
|
|
153
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
154
|
+
if (raw.loudnessVersion !== 1) throw new LoudnessError(`${path}: unsupported loudnessVersion ${String(raw.loudnessVersion)} (expected 1); re-run gs measure-loudness`);
|
|
155
|
+
if (typeof raw.gain !== "number" || typeof raw.inputI !== "number" || typeof raw.inputTp !== "number" || typeof raw.inputLra !== "number" || typeof raw.mixHash !== "string" || typeof raw.profileId !== "string") throw new LoudnessError(`${path}: malformed measurement; re-run gs measure-loudness`);
|
|
156
|
+
return raw;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Parse the JSON block ffmpeg's `loudnorm=print_format=json` prints to stderr.
|
|
160
|
+
* It carries `input_i` / `input_tp` / `input_lra` (the measured loudness),
|
|
161
|
+
* alongside the would-be normalization params (which we ignore — we apply our
|
|
162
|
+
* own peak-clamped scalar gain instead).
|
|
163
|
+
*/
|
|
164
|
+
function parseLoudnormJson(stderr) {
|
|
165
|
+
const start = stderr.lastIndexOf("{");
|
|
166
|
+
const end = stderr.lastIndexOf("}");
|
|
167
|
+
if (start < 0 || end < 0 || end < start) throw new LoudnessError(`could not find loudnorm JSON in ffmpeg output:\n${stderr.slice(-1e3)}`);
|
|
168
|
+
let parsed;
|
|
169
|
+
try {
|
|
170
|
+
parsed = JSON.parse(stderr.slice(start, end + 1));
|
|
171
|
+
} catch {
|
|
172
|
+
throw new LoudnessError(`could not parse loudnorm JSON:\n${stderr.slice(start, end + 1)}`);
|
|
173
|
+
}
|
|
174
|
+
const num = (k) => {
|
|
175
|
+
const v = Number(parsed[k]);
|
|
176
|
+
if (!Number.isFinite(v)) throw new LoudnessError(`loudnorm JSON missing finite '${k}' (got '${parsed[k]}')`);
|
|
177
|
+
return v;
|
|
178
|
+
};
|
|
179
|
+
return {
|
|
180
|
+
inputI: num("input_i"),
|
|
181
|
+
inputTp: num("input_tp"),
|
|
182
|
+
inputLra: num("input_lra")
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Run ffmpeg's loudnorm measurement pass over a built mix WAV/PCM file and return
|
|
187
|
+
* the measured loudness. This is the quarantined non-deterministic stage — it
|
|
188
|
+
* runs ONLY at measure-time (commit), never during render.
|
|
189
|
+
*/
|
|
190
|
+
function measureFile(audioPath) {
|
|
191
|
+
const result = spawnSync("ffmpeg", [
|
|
192
|
+
"-hide_banner",
|
|
193
|
+
"-nostats",
|
|
194
|
+
"-i",
|
|
195
|
+
audioPath,
|
|
196
|
+
"-af",
|
|
197
|
+
"loudnorm=print_format=json",
|
|
198
|
+
"-f",
|
|
199
|
+
"null",
|
|
200
|
+
"-"
|
|
201
|
+
], { encoding: "utf8" });
|
|
202
|
+
if (result.status !== 0) throw new LoudnessError(`ffmpeg loudnorm measurement failed (exit ${result.status}):\n${(result.stderr ?? "").slice(-1e3)}`);
|
|
203
|
+
return parseLoudnormJson(result.stderr ?? "");
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Build the final mix to a WAV, run the ffmpeg loudnorm measurement over it, and
|
|
207
|
+
* commit a `<scene>.loudness.json` with the peak-clamped publish gain + a mixHash
|
|
208
|
+
* bound to the mix-input manifests. This is the measure step (commit-time); it is
|
|
209
|
+
* the only place the non-deterministic ebur128/mix-to-PCM stages run.
|
|
210
|
+
*/
|
|
211
|
+
async function measureLoudnessCommand(opts) {
|
|
212
|
+
const profile = resolveProfile(opts.profile ?? "youtube");
|
|
213
|
+
const { buildMixWav } = await import("./render.js").then((n) => n.l);
|
|
214
|
+
const tmp = mkdtempSync(join(tmpdir(), "glissade-loudness-"));
|
|
215
|
+
try {
|
|
216
|
+
const wavPath = join(tmp, "mix.wav");
|
|
217
|
+
if (!await buildMixWav({
|
|
218
|
+
modulePath: opts.modulePath,
|
|
219
|
+
narration: opts.narration ?? "auto",
|
|
220
|
+
music: opts.music ?? "auto",
|
|
221
|
+
sfx: opts.sfx ?? "auto"
|
|
222
|
+
}, wavPath)) throw new LoudnessError(`${opts.modulePath} has no audio to measure (no timeline audio and no narration/music/sfx manifests)`);
|
|
223
|
+
const { inputI, inputTp, inputLra } = measureFile(wavPath);
|
|
224
|
+
const gain = computeGainDb(profile, inputI, inputTp);
|
|
225
|
+
const clampBound = peakClampBinds(profile, inputI, inputTp);
|
|
226
|
+
const mixHash = computeMixHash(opts.modulePath);
|
|
227
|
+
const measurement = {
|
|
228
|
+
loudnessVersion: 1,
|
|
229
|
+
profileId: profile.id,
|
|
230
|
+
inputI: round2(inputI),
|
|
231
|
+
inputTp: round2(inputTp),
|
|
232
|
+
inputLra: round2(inputLra),
|
|
233
|
+
gain: round2(gain),
|
|
234
|
+
mixHash
|
|
235
|
+
};
|
|
236
|
+
const loudnessPath = loudnessPathFor(opts.modulePath);
|
|
237
|
+
writeFileSync(loudnessPath, JSON.stringify(measurement, null, 2) + "\n");
|
|
238
|
+
return {
|
|
239
|
+
measurement,
|
|
240
|
+
loudnessPath,
|
|
241
|
+
profile,
|
|
242
|
+
clampBound,
|
|
243
|
+
warning: clampBound && !profile.platformNormalized ? `profile '${profile.id}' targets ${profile.targetLufs} LUFS but the source true-peak (${measurement.inputTp} dBTP) clamps the gain to ${measurement.gain} dB, leaving the mix at ~${round2(inputI + gain)} LUFS (below target). A brickwall true-peak limiter (deferred in 0.12) would recover the headroom; for now this profile under-shoots loudness.` : null
|
|
244
|
+
};
|
|
245
|
+
} finally {
|
|
246
|
+
rmSync(tmp, {
|
|
247
|
+
recursive: true,
|
|
248
|
+
force: true
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const round2 = (v) => Math.round(v * 100) / 100;
|
|
253
|
+
//#endregion
|
|
254
|
+
export { computeGainDb as a, loudness_exports as c, parseLoudnormJson as d, peakClampBinds as f, PUBLISH_PROFILES as i, measureFile as l, resolveProfile as m, LOUDNESS_SCHEMA_VERSION as n, computeMixHash as o, readLoudness as p, LoudnessError as r, loudnessPathFor as s, DEFAULT_PROFILE_ID as t, measureLoudnessCommand as u };
|
package/dist/music.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { s as resolveAssetPath } from "./audioMix.js";
|
|
2
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import { basename } from "node:path";
|
|
4
4
|
import { music, narration, validateMusicTiming } from "@glissade/narrate";
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime.js";
|
|
2
|
+
import { a as loadSceneModule } from "./render.js";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { splitCaption } from "@glissade/narrate";
|
|
5
|
+
//#region src/narrationLint.ts
|
|
6
|
+
/**
|
|
7
|
+
* gs narration-lint (§5 narration): catch slow-re-narrate failures — a segment
|
|
8
|
+
* that overran its beat, a caption too dense to read, a caption that overflows
|
|
9
|
+
* its box — at BUILD, not render-hours-later.
|
|
10
|
+
*
|
|
11
|
+
* PURE over the committed `*.narration.timing.json` + the REAL measured caption
|
|
12
|
+
* geometry: no clock, no RNG, no I/O of its own (the CLI reads the files and
|
|
13
|
+
* builds the probe). The only environmental caveat is WHICH measurer feeds the
|
|
14
|
+
* caption-fit rule — the CLI defaults to the Skia measurer with the render's
|
|
15
|
+
* own fonts and drives the REAL caption node (its width/baseFont/autoFit
|
|
16
|
+
* formula + the real `breakLines`), so a lint that passes can't burn-overflow.
|
|
17
|
+
*
|
|
18
|
+
* Tiers (§narration-lint LOCKED):
|
|
19
|
+
* Tier-1 (HARD, deterministic, CAN fail CI / exit non-zero):
|
|
20
|
+
* reading-speed — chars-per-second over committed cue text vs `maxCps`
|
|
21
|
+
* anchor-budget — a segment/pause that overran its allotted beat
|
|
22
|
+
* caption-fit — a cue that overflows its box / exceeds maxLines, using
|
|
23
|
+
* the REAL measured geometry
|
|
24
|
+
* Tier-2 (WARN-only, NEVER fails CI):
|
|
25
|
+
* beat-drift — caption cue boundary drifts from its word timing
|
|
26
|
+
* silence — implausible silence (a long unscripted gap / a segment
|
|
27
|
+
* that reads suspiciously slow)
|
|
28
|
+
*/
|
|
29
|
+
function cuesOf(timing) {
|
|
30
|
+
const out = [];
|
|
31
|
+
for (const s of timing.segments) splitCaption(s, timing.captionSplit?.maxChars).forEach((c, i) => {
|
|
32
|
+
out.push({
|
|
33
|
+
segId: s.id,
|
|
34
|
+
index: i,
|
|
35
|
+
text: c.text,
|
|
36
|
+
start: c.start,
|
|
37
|
+
end: c.end
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
/** A stable cue id: the segment id, suffixed `#n` when a segment splits. */
|
|
43
|
+
function cueId(c, total) {
|
|
44
|
+
return total > 1 ? `${c.segId}#${c.index}` : c.segId;
|
|
45
|
+
}
|
|
46
|
+
/** Visible characters in a cue (whitespace collapsed) — the reading-load proxy. */
|
|
47
|
+
function readingChars(text) {
|
|
48
|
+
return text.replace(/\s+/g, " ").trim().length;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Lint the committed narration timing. Pure: same inputs (timing + the probe's
|
|
52
|
+
* measurements) → same diagnostics, in any order. Tier-1 diagnostics are
|
|
53
|
+
* errors a caller can exit non-zero on; Tier-2 are warnings that never gate CI.
|
|
54
|
+
*/
|
|
55
|
+
function lintNarration(timing, opts = {}) {
|
|
56
|
+
const maxCps = opts.maxCps ?? 17;
|
|
57
|
+
const warnings = opts.warnings ?? true;
|
|
58
|
+
const out = [];
|
|
59
|
+
const cues = cuesOf(timing);
|
|
60
|
+
const perSeg = /* @__PURE__ */ new Map();
|
|
61
|
+
for (const c of cues) perSeg.set(c.segId, (perSeg.get(c.segId) ?? 0) + 1);
|
|
62
|
+
for (const c of cues) {
|
|
63
|
+
const chars = readingChars(c.text);
|
|
64
|
+
const window = c.end - c.start;
|
|
65
|
+
if (chars === 0 || window <= 0) continue;
|
|
66
|
+
const cps = chars / window;
|
|
67
|
+
if (cps > maxCps + 1e-6) {
|
|
68
|
+
const id = cueId(c, perSeg.get(c.segId) ?? 1);
|
|
69
|
+
out.push({
|
|
70
|
+
rule: "reading-speed",
|
|
71
|
+
tier: 1,
|
|
72
|
+
severity: "error",
|
|
73
|
+
id,
|
|
74
|
+
message: `reads at ${cps.toFixed(1)} cps (${chars} chars in ${window.toFixed(2)}s) — over ${maxCps} cps`,
|
|
75
|
+
detail: {
|
|
76
|
+
cps: round(cps),
|
|
77
|
+
maxCps,
|
|
78
|
+
chars,
|
|
79
|
+
seconds: round(window)
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const budgets = timing.budgets ?? {};
|
|
85
|
+
const checkBudget = (id, duration, maxSec) => {
|
|
86
|
+
if (maxSec === void 0 || !(maxSec > 0)) return;
|
|
87
|
+
if (duration > maxSec + 1e-6) out.push({
|
|
88
|
+
rule: "anchor-budget",
|
|
89
|
+
tier: 1,
|
|
90
|
+
severity: "error",
|
|
91
|
+
id,
|
|
92
|
+
message: `beat ran ${duration.toFixed(2)}s, over its ${maxSec.toFixed(2)}s budget (by ${(duration - maxSec).toFixed(2)}s)`,
|
|
93
|
+
detail: {
|
|
94
|
+
duration: round(duration),
|
|
95
|
+
maxSec,
|
|
96
|
+
overBy: round(duration - maxSec)
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
};
|
|
100
|
+
for (const s of timing.segments) checkBudget(s.id, s.duration, s.maxSec ?? budgets[s.id]);
|
|
101
|
+
for (const p of timing.pauses ?? []) checkBudget(p.id, p.duration, budgets[p.id]);
|
|
102
|
+
if (opts.caption) {
|
|
103
|
+
const probe = opts.caption;
|
|
104
|
+
for (const c of cues) {
|
|
105
|
+
const id = cueId(c, perSeg.get(c.segId) ?? 1);
|
|
106
|
+
const { lines, bottomY } = probe.measure(c.text);
|
|
107
|
+
if (lines > probe.maxLines) out.push({
|
|
108
|
+
rule: "caption-fit",
|
|
109
|
+
tier: 1,
|
|
110
|
+
severity: "error",
|
|
111
|
+
id,
|
|
112
|
+
message: `caption wraps to ${lines} lines, over maxLines ${probe.maxLines}`,
|
|
113
|
+
detail: {
|
|
114
|
+
lines,
|
|
115
|
+
maxLines: probe.maxLines,
|
|
116
|
+
text: c.text
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
else if (bottomY > probe.sceneH + 1e-6) out.push({
|
|
120
|
+
rule: "caption-fit",
|
|
121
|
+
tier: 1,
|
|
122
|
+
severity: "error",
|
|
123
|
+
id,
|
|
124
|
+
message: `caption overflows the frame: its lowest line ends at y=${bottomY.toFixed(0)} of ${probe.sceneH}`,
|
|
125
|
+
detail: {
|
|
126
|
+
bottomY: round(bottomY),
|
|
127
|
+
sceneH: probe.sceneH,
|
|
128
|
+
lines,
|
|
129
|
+
text: c.text
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (!warnings) return out;
|
|
135
|
+
const pauseSpans = (timing.pauses ?? []).map((p) => ({
|
|
136
|
+
start: p.start,
|
|
137
|
+
end: p.start + p.duration
|
|
138
|
+
}));
|
|
139
|
+
const isPauseGap = (start, end) => pauseSpans.some((p) => p.start <= start + 1e-6 && p.end >= end - 1e-6);
|
|
140
|
+
const sorted = [...timing.segments].sort((a, b) => a.start - b.start);
|
|
141
|
+
for (let i = 0; i < sorted.length - 1; i++) {
|
|
142
|
+
const a = sorted[i];
|
|
143
|
+
const b = sorted[i + 1];
|
|
144
|
+
const gap = b.start - (a.start + a.duration);
|
|
145
|
+
if (gap > 2 && !isPauseGap(a.start + a.duration, b.start)) out.push({
|
|
146
|
+
rule: "silence",
|
|
147
|
+
tier: 2,
|
|
148
|
+
severity: "warn",
|
|
149
|
+
id: `${a.id}→${b.id}`,
|
|
150
|
+
message: `${gap.toFixed(2)}s of unscripted silence between '${a.id}' and '${b.id}' (no pause declared)`,
|
|
151
|
+
detail: { gap: round(gap) }
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
for (const s of timing.segments) {
|
|
155
|
+
if (!s.words || s.words.length === 0) continue;
|
|
156
|
+
const split = splitCaption(s, timing.captionSplit?.maxChars);
|
|
157
|
+
if (split.length < 2) continue;
|
|
158
|
+
for (let i = 1; i < split.length; i++) {
|
|
159
|
+
const cueStart = split[i].start;
|
|
160
|
+
const nearest = s.words.reduce((best, w) => Math.abs(w.start - cueStart) < Math.abs(best - cueStart) ? w.start : best, s.words[0].start);
|
|
161
|
+
const drift = Math.abs(nearest - cueStart);
|
|
162
|
+
if (drift > .4) out.push({
|
|
163
|
+
rule: "beat-drift",
|
|
164
|
+
tier: 2,
|
|
165
|
+
severity: "warn",
|
|
166
|
+
id: `${s.id}#${i}`,
|
|
167
|
+
message: `caption cue starts ${drift.toFixed(2)}s off the nearest word boundary`,
|
|
168
|
+
detail: { drift: round(drift) }
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
function round(n) {
|
|
175
|
+
return Math.round(n * 1e3) / 1e3;
|
|
176
|
+
}
|
|
177
|
+
/** True when any Tier-1 diagnostic is present — the CI gate / exit-code signal. */
|
|
178
|
+
function hasErrors(diags) {
|
|
179
|
+
return diags.some((d) => d.tier === 1);
|
|
180
|
+
}
|
|
181
|
+
/** A human-readable table (the default terminal output). */
|
|
182
|
+
function formatTable(diags) {
|
|
183
|
+
if (diags.length === 0) return "narration-lint: clean — no issues\n";
|
|
184
|
+
const rows = diags.map((d) => {
|
|
185
|
+
return ` ${d.tier === 1 ? "ERROR" : "warn "} ${d.rule.padEnd(13)} ${d.id.padEnd(16)} ${d.message}`;
|
|
186
|
+
});
|
|
187
|
+
const errs = diags.filter((d) => d.tier === 1).length;
|
|
188
|
+
const warns = diags.length - errs;
|
|
189
|
+
return `${`narration-lint: ${errs} error${errs === 1 ? "" : "s"}, ${warns} warning${warns === 1 ? "" : "s"}`}\n${rows.join("\n")}\n`;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* A git-apply-able unified diff that bumps the committed budgets (and
|
|
193
|
+
* captionSplit, when a caption is too dense) to the values the manifest already
|
|
194
|
+
* shows — the `--fix` SUGGESTION. NEVER writes anything: it prints a diff the
|
|
195
|
+
* author reviews and applies (re-narrate is the real fix; this just unblocks CI
|
|
196
|
+
* when the over-budget value is genuinely the new intent). Targets the SCRIPT
|
|
197
|
+
* (`*.narration.json`), since budgets are committed there.
|
|
198
|
+
*/
|
|
199
|
+
function fixDiff(diags, scriptPath, script) {
|
|
200
|
+
const bumps = /* @__PURE__ */ new Map();
|
|
201
|
+
for (const d of diags) if (d.rule === "anchor-budget" && typeof d.detail?.["duration"] === "number") bumps.set(d.id, Math.ceil(d.detail["duration"] * 10) / 10);
|
|
202
|
+
if (bumps.size === 0) return "";
|
|
203
|
+
const before = { ...script.budgets ?? {} };
|
|
204
|
+
const after = { ...before };
|
|
205
|
+
for (const [id, sec] of bumps) after[id] = sec;
|
|
206
|
+
const fmt = (b) => JSON.stringify({ budgets: b }, null, 2).split("\n");
|
|
207
|
+
const beforeLines = fmt(before);
|
|
208
|
+
const afterLines = fmt(after);
|
|
209
|
+
return [
|
|
210
|
+
`--- a/${scriptPath}`,
|
|
211
|
+
`+++ b/${scriptPath}`,
|
|
212
|
+
"@@ budgets @@",
|
|
213
|
+
...beforeLines.map((l) => `-${l}`),
|
|
214
|
+
...afterLines.map((l) => `+${l}`)
|
|
215
|
+
].join("\n") + "\n";
|
|
216
|
+
}
|
|
217
|
+
//#endregion
|
|
218
|
+
//#region src/narrationLintCommand.ts
|
|
219
|
+
/**
|
|
220
|
+
* The `gs narration-lint` command wiring: read the committed timing manifest,
|
|
221
|
+
* build the REAL caption-fit probe (Skia measurer + the render's fonts driving
|
|
222
|
+
* the actual caption node), run `lintNarration`, and render JSON / a table /
|
|
223
|
+
* the --fix diff. The pure rules live in narrationLint.ts; this file owns only
|
|
224
|
+
* the I/O + the Skia-measurer plumbing (so the lint's geometry == render's).
|
|
225
|
+
*/
|
|
226
|
+
var narrationLintCommand_exports = /* @__PURE__ */ __exportAll({
|
|
227
|
+
buildCaptionProbe: () => buildCaptionProbe,
|
|
228
|
+
lintTimingPathFor: () => lintTimingPathFor,
|
|
229
|
+
narrationLintCommand: () => narrationLintCommand
|
|
230
|
+
});
|
|
231
|
+
/** `<scene>.narration.timing.json` or the manifest path itself. */
|
|
232
|
+
async function lintTimingPathFor(input) {
|
|
233
|
+
if (input.endsWith(".narration.timing.json")) {
|
|
234
|
+
if (!existsSync(input)) throw new Error(`no narration timing manifest at ${input}`);
|
|
235
|
+
return input;
|
|
236
|
+
}
|
|
237
|
+
const { timingPathFor } = await import("./captions.js").then((n) => n.t);
|
|
238
|
+
const p = timingPathFor(input);
|
|
239
|
+
if (!p) throw new Error(`no narration timing manifest beside ${input} — run \`gs narrate\` first, or pass a *.narration.timing.json path directly`);
|
|
240
|
+
return p;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Build a caption-fit probe by loading the scene, registering its fonts, and
|
|
244
|
+
* driving the REAL `captions` node with the Skia measurer. Returns null when
|
|
245
|
+
* the input is a bare manifest (no scene module) or the scene has no caption
|
|
246
|
+
* node — caption-fit then doesn't run, and the lint is still exact for the
|
|
247
|
+
* other rules. Async because font registration + the scene load are.
|
|
248
|
+
*/
|
|
249
|
+
async function buildCaptionProbe(input, maxLines) {
|
|
250
|
+
if (input.endsWith(".narration.timing.json")) return null;
|
|
251
|
+
let mod;
|
|
252
|
+
try {
|
|
253
|
+
mod = await loadSceneModule(input);
|
|
254
|
+
} catch {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
const scene = mod.createScene();
|
|
258
|
+
const cap = scene.nodes.get("captions");
|
|
259
|
+
if (!cap || typeof cap.lineBoxes !== "function" || typeof cap.text !== "function" || typeof cap.text.set !== "function") return null;
|
|
260
|
+
const { resolveAssetPath } = await import("./audioMix.js").then((n) => n.i);
|
|
261
|
+
const { buildFontRegistry } = await import("@glissade/core");
|
|
262
|
+
const fontRegistry = buildFontRegistry(mod.timeline.assets);
|
|
263
|
+
const fonts = {};
|
|
264
|
+
for (const face of fontRegistry.faces()) fonts[face.family] = resolveAssetPath(face.url, input);
|
|
265
|
+
const { createMeasurer } = await import("@glissade/backend-skia");
|
|
266
|
+
const measurer = createMeasurer({ fonts });
|
|
267
|
+
scene.setTextMeasurer(measurer);
|
|
268
|
+
return {
|
|
269
|
+
sceneH: scene.size.h,
|
|
270
|
+
maxLines,
|
|
271
|
+
measure: (cueText) => {
|
|
272
|
+
cap.text.set(cueText);
|
|
273
|
+
const boxes = cap.lineBoxes();
|
|
274
|
+
const lines = boxes.length;
|
|
275
|
+
const deepest = boxes.reduce((m, b) => Math.max(m, b.y + b.h), 0);
|
|
276
|
+
return {
|
|
277
|
+
lines,
|
|
278
|
+
bottomY: cap.position.y() + deepest
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
/** Run the lint end-to-end and render the output (JSON / table / --fix diff). */
|
|
284
|
+
async function narrationLintCommand(opts) {
|
|
285
|
+
const timingPath = await lintTimingPathFor(opts.input);
|
|
286
|
+
const timing = JSON.parse(readFileSync(timingPath, "utf8"));
|
|
287
|
+
if (timing.timingVersion !== 1) throw new Error(`unsupported timingVersion ${String(timing.timingVersion)} in ${timingPath}`);
|
|
288
|
+
const maxLines = opts.maxLines ?? 2;
|
|
289
|
+
const caption = await buildCaptionProbe(opts.input, maxLines);
|
|
290
|
+
const diagnostics = lintNarration(timing, {
|
|
291
|
+
...opts.maxCps !== void 0 ? { maxCps: opts.maxCps } : {},
|
|
292
|
+
...caption ? { caption } : {},
|
|
293
|
+
...opts.noWarnings ? { warnings: false } : {}
|
|
294
|
+
});
|
|
295
|
+
const errors = hasErrors(diagnostics);
|
|
296
|
+
let output;
|
|
297
|
+
if (opts.fix) {
|
|
298
|
+
const scriptPath = opts.input.endsWith(".narration.timing.json") ? opts.input.replace(/\.narration\.timing\.json$/, ".narration.json") : opts.input.replace(/\.[jt]sx?$/, "") + ".narration.json";
|
|
299
|
+
output = fixDiff(diagnostics, scriptPath, existsSync(scriptPath) ? JSON.parse(readFileSync(scriptPath, "utf8")) : {}) || "narration-lint --fix: nothing to suggest (no budget bumps apply)\n";
|
|
300
|
+
} else if (opts.json) output = JSON.stringify({
|
|
301
|
+
timingPath,
|
|
302
|
+
hasErrors: errors,
|
|
303
|
+
diagnostics
|
|
304
|
+
}, null, 2) + "\n";
|
|
305
|
+
else output = formatTable(diagnostics);
|
|
306
|
+
return {
|
|
307
|
+
diagnostics,
|
|
308
|
+
hasErrors: errors,
|
|
309
|
+
output,
|
|
310
|
+
timingPath
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
//#endregion
|
|
314
|
+
export { fixDiff as a, lintNarration as c, narrationLintCommand_exports as i, lintTimingPathFor as n, formatTable as o, narrationLintCommand as r, hasErrors as s, buildCaptionProbe as t };
|
package/dist/prepare.js
CHANGED