@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.
@@ -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 { o as resolveAssetPath } from "./audioMix.js";
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
@@ -1,4 +1,4 @@
1
- import { r as loadSceneModule } from "./render.js";
1
+ import { a as loadSceneModule } from "./render.js";
2
2
  import { narrateCommand } from "./narrate.js";
3
3
  import { prepareSfx, sfxScriptPathFor } from "./sfx.js";
4
4
  import { existsSync } from "node:fs";