@tandryukha/aidemo 0.8.0 → 0.15.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/src/profile.ts ADDED
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Recording-profile hygiene.
3
+ *
4
+ * Takes replay against a persistent Chrome profile, which is what makes a
5
+ * logged-in demo possible at all — and also the single most dangerous piece of
6
+ * hidden state in the pipeline. A profile that already holds cookies or
7
+ * localStorage for the app skips first-run gates, onboarding, empty states and
8
+ * one-shot flows, and the take then *silently demonstrates the opposite of what
9
+ * the narration says* (issue #39). The reviewer cannot tell; nothing failed.
10
+ *
11
+ * Two remedies live here: `--fresh`, which runs the take against a throwaway
12
+ * profile, and a warning that names the trap when the shared profile already
13
+ * has state for the storyboard's origin.
14
+ */
15
+
16
+ import { promises as fs } from "node:fs";
17
+ import { join, resolve } from "node:path";
18
+ import { chromeProfileDir } from "./config.js";
19
+ import type { Storyboard } from "./types.js";
20
+ import { exists } from "./util.js";
21
+
22
+ /** Where a `--fresh` run's throwaway profile lives (wiped before each take). */
23
+ export function freshProfileDir(demoDir: string): string {
24
+ return resolve(demoDir, ".chrome-profile-fresh");
25
+ }
26
+
27
+ /** Wipe and re-create the throwaway profile for a `--fresh` run. */
28
+ export async function resetProfile(dir: string): Promise<void> {
29
+ await fs.rm(dir, { recursive: true, force: true });
30
+ await fs.mkdir(dir, { recursive: true });
31
+ }
32
+
33
+ /** The origin of the storyboard's first `goto`, or null if it has none. */
34
+ export function firstGotoOrigin(storyboard: Storyboard): string | null {
35
+ for (const scene of storyboard.scenes) {
36
+ for (const action of scene.actions) {
37
+ if (action.op === "goto") {
38
+ try {
39
+ return new URL(action.url).origin;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+ }
45
+ }
46
+ return null;
47
+ }
48
+
49
+ /**
50
+ * Does the profile already hold state for `origin`? Checks Chrome's cookie and
51
+ * Local Storage stores as opaque bytes — both record the origin as a plain
52
+ * string, so a substring scan answers the question without launching a browser
53
+ * or navigating anywhere. Best-effort: any read error means "don't warn".
54
+ */
55
+ export async function profileHasStateFor(
56
+ profileDir: string,
57
+ origin: string
58
+ ): Promise<boolean> {
59
+ let host: string;
60
+ try {
61
+ host = new URL(origin).hostname;
62
+ } catch {
63
+ return false;
64
+ }
65
+ const needles = [host, `_${origin}`, origin];
66
+ const roots = [
67
+ join(profileDir, "Default", "Local Storage", "leveldb"),
68
+ join(profileDir, "Default", "Session Storage"),
69
+ ];
70
+ const files: string[] = [join(profileDir, "Default", "Cookies")];
71
+ for (const root of roots) {
72
+ for (const f of await fs.readdir(root).catch(() => [] as string[])) {
73
+ files.push(join(root, f));
74
+ }
75
+ }
76
+ for (const file of files) {
77
+ if (!(await exists(file))) continue;
78
+ let buf: Buffer;
79
+ try {
80
+ buf = await fs.readFile(file);
81
+ } catch {
82
+ continue;
83
+ }
84
+ // Chrome writes leveldb keys as UTF-16 for values and ASCII for keys;
85
+ // "latin1" catches the ASCII origin either way.
86
+ const text = buf.toString("latin1");
87
+ if (needles.some((n) => text.includes(n))) return true;
88
+ }
89
+ return false;
90
+ }
91
+
92
+ export interface ResolveProfileOptions {
93
+ /** Explicit --profile dir, if any. */
94
+ profileDir?: string;
95
+ /** --fresh: run against a wiped throwaway profile in the demo dir. */
96
+ fresh?: boolean;
97
+ }
98
+
99
+ /**
100
+ * Pick the profile a take should run against, preparing it if needed, and
101
+ * return any warning the caller should log. `--fresh` wins; otherwise the
102
+ * shared profile is used and checked for carried-over state.
103
+ */
104
+ export async function resolveProfile(
105
+ demoDir: string,
106
+ storyboard: Storyboard,
107
+ opts: ResolveProfileOptions
108
+ ): Promise<{ dir: string; warning?: string }> {
109
+ if (opts.fresh) {
110
+ const dir = opts.profileDir ?? freshProfileDir(demoDir);
111
+ await resetProfile(dir);
112
+ return { dir };
113
+ }
114
+ const dir = opts.profileDir ?? chromeProfileDir();
115
+ const origin = firstGotoOrigin(storyboard);
116
+ if (origin && (await profileHasStateFor(dir, origin))) {
117
+ return {
118
+ dir,
119
+ warning:
120
+ `profile already holds cookies/localStorage for ${origin} — a first-run ` +
121
+ `gate, onboarding step or one-shot flow may be skipped, and the take ` +
122
+ `would look fine while showing the wrong story. Re-run with --fresh ` +
123
+ `for a clean identity — or, if the profile is seeded on purpose, ` +
124
+ `set setup.expectState: true (CLI --profile-seeded) to silence this.`,
125
+ };
126
+ }
127
+ return { dir };
128
+ }
package/src/project.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { resolve, isAbsolute } from "node:path";
2
2
  import { StoryboardSchema, type Storyboard } from "./types.js";
3
3
  import { applyParams } from "./params.js";
4
+ import { applyAnchors } from "./anchors.js";
4
5
  import { ensureDir, exists, readJson, writeJson, log } from "./util.js";
5
6
 
6
7
  /**
@@ -119,6 +120,14 @@ export class Project {
119
120
  get gifPath() {
120
121
  return this.p("output", this.lang ? `final-demo.${this.lang}.gif` : "final-demo.gif");
121
122
  }
123
+ /** Structured compose report (per-scene retime facts + warnings). */
124
+ get posterPath() {
125
+ return resolve(this.dir, "output", this.lang ? `poster.${this.lang}.png` : "poster.png");
126
+ }
127
+
128
+ get reportPath() {
129
+ return this.p("output", this.lang ? `report.${this.lang}.json` : "report.json");
130
+ }
122
131
  /** Directory for screenshot-mode stills (one PNG per named `still` marker). */
123
132
  get stillsDir() {
124
133
  return this.p("output", "stills");
@@ -265,11 +274,12 @@ export function parseStoryboard(
265
274
  issues: [{ path: "params", message: applied.message, code: "params" }],
266
275
  };
267
276
  }
277
+ const storyboard = applyAnchors(applied.storyboard);
268
278
  return {
269
279
  ok: true,
270
- storyboard: applied.storyboard,
280
+ storyboard,
271
281
  resolved: applied.resolved,
272
- warnings: storyboardWarnings(applied.storyboard),
282
+ warnings: storyboardWarnings(storyboard),
273
283
  };
274
284
  }
275
285
 
package/src/qa.ts ADDED
@@ -0,0 +1,295 @@
1
+ /**
2
+ * `aidemo qa <dir>` — post-render checks on the FINISHED MP4 (issue #52).
3
+ *
4
+ * `lint` predicts problems before the take and `output/report.json` records
5
+ * what compose did, but neither sees the file a viewer opens: a white opening
6
+ * frame, a blank poster, a master that is too quiet, captions that flash by,
7
+ * an address pill naming the wrong host. Those were caught locally with a
8
+ * ~150-line ffmpeg + python script; this is that script, in the engine, so
9
+ * every agent-driven render is self-checking.
10
+ *
11
+ * Pure measurement over existing artifacts: ffmpeg/ffprobe only, no browser,
12
+ * no network, nothing written.
13
+ */
14
+
15
+ import { spawn } from "node:child_process";
16
+ import { relative } from "node:path";
17
+ import type { Project } from "./project.js";
18
+ import {
19
+ probeDurationMs,
20
+ probeFlatLeadMs,
21
+ } from "./ffmpeg.js";
22
+ import { ComposeReportSchema, type Storyboard } from "./types.js";
23
+ import { exists, log, ok, readJson } from "./util.js";
24
+
25
+ export type QaStatus = "pass" | "warn" | "note";
26
+
27
+ export interface QaCheck {
28
+ /** Stable id: length | blank-open | poster | loudness | bed | hold | captions | hygiene. */
29
+ id: string;
30
+ status: QaStatus;
31
+ message: string;
32
+ }
33
+
34
+ export interface QaResult {
35
+ video: string;
36
+ durationMs: number;
37
+ checks: QaCheck[];
38
+ warnings: number;
39
+ notes: number;
40
+ }
41
+
42
+ /** Integrated loudness / true peak / the momentary p10 (the bed under speech). */
43
+ interface LoudnessStats {
44
+ integrated: number;
45
+ truePeak: number;
46
+ /** 10th percentile of the momentary loudness — what plays in the gaps. */
47
+ momentaryP10: number;
48
+ }
49
+
50
+ function ebur128(file: string): Promise<LoudnessStats | null> {
51
+ return new Promise((resolvePromise) => {
52
+ const proc = spawn(
53
+ "ffmpeg",
54
+ ["-hide_banner", "-nostats", "-i", file, "-af", "ebur128=peak=true", "-f", "null", "-"],
55
+ { stdio: ["ignore", "ignore", "pipe"] }
56
+ );
57
+ let err = "";
58
+ proc.stderr.on("data", (d) => (err += d.toString()));
59
+ proc.on("error", () => resolvePromise(null));
60
+ proc.on("close", () => {
61
+ const moments: number[] = [];
62
+ for (const m of err.matchAll(/M:\s*(-?[\d.]+|-inf)/g)) {
63
+ const v = m[1] === "-inf" ? -70 : parseFloat(m[1]);
64
+ if (Number.isFinite(v) && v > -70) moments.push(v);
65
+ }
66
+ const summary = err.slice(err.lastIndexOf("Integrated loudness"));
67
+ const I = /I:\s*(-?[\d.]+)\s*LUFS/.exec(summary);
68
+ const TP = /Peak:\s*(-?[\d.]+)\s*dBFS/.exec(summary);
69
+ if (!I) return resolvePromise(null);
70
+ moments.sort((a, b) => a - b);
71
+ resolvePromise({
72
+ integrated: parseFloat(I[1]),
73
+ truePeak: TP ? parseFloat(TP[1]) : 0,
74
+ momentaryP10: moments.length ? moments[Math.floor(moments.length * 0.1)] : -70,
75
+ });
76
+ });
77
+ });
78
+ }
79
+
80
+ /** Mean luma of a still image (a blank poster is ~white or ~black). */
81
+ function imageYavg(file: string): Promise<number | null> {
82
+ return new Promise((resolvePromise) => {
83
+ const proc = spawn(
84
+ "ffmpeg",
85
+ [
86
+ "-hide_banner",
87
+ "-nostats",
88
+ "-i",
89
+ file,
90
+ "-vf",
91
+ "signalstats,metadata=print:file=-",
92
+ "-f",
93
+ "null",
94
+ "-",
95
+ ],
96
+ { stdio: ["ignore", "pipe", "ignore"] }
97
+ );
98
+ let out = "";
99
+ proc.stdout.on("data", (d) => (out += d.toString()));
100
+ proc.on("error", () => resolvePromise(null));
101
+ proc.on("close", () => {
102
+ const m = /YAVG=\s*([\d.]+)/.exec(out);
103
+ resolvePromise(m ? parseFloat(m[1]) : null);
104
+ });
105
+ });
106
+ }
107
+
108
+ /** Host of a URL-ish string, or null. */
109
+ function hostOf(url: string): string | null {
110
+ try {
111
+ return new URL(url.includes("://") ? url : `https://${url}`).host;
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+
117
+ export async function runQa(project: Project, storyboard: Storyboard): Promise<QaResult> {
118
+ const video = project.outputPath;
119
+ if (!(await exists(video))) {
120
+ throw new Error(`no video at ${video} — run 'aidemo render <dir>' (or compose) first`);
121
+ }
122
+ const checks: QaCheck[] = [];
123
+ const add = (id: string, status: QaStatus, message: string): void => {
124
+ checks.push({ id, status, message });
125
+ };
126
+ const durationMs = await probeDurationMs(video);
127
+
128
+ // 1. Length against the storyboard's own target.
129
+ const target = storyboard.targetLengthSeconds;
130
+ const secs = Math.round(durationMs / 1000);
131
+ if (target == null) {
132
+ add("length", "note", `${secs}s (no targetLengthSeconds declared)`);
133
+ } else if (Math.abs(secs - target) > Math.max(10, target * 0.2)) {
134
+ add("length", "warn", `${secs}s vs targetLengthSeconds ${target}`);
135
+ } else {
136
+ add("length", "pass", `${secs}s (target ${target}s)`);
137
+ }
138
+
139
+ // 2. The opening frames: a demo that starts on the browser's white pre-paint.
140
+ const introMs = storyboard.intro?.durationMs ?? 0;
141
+ const blankMs = await probeFlatLeadMs(video, 4000, introMs).catch(() => 0);
142
+ if (blankMs > 250) {
143
+ add(
144
+ "blank-open",
145
+ "warn",
146
+ `opens on ${(blankMs / 1000).toFixed(2)}s of blank page — set output.trimLeadingBlank`
147
+ );
148
+ } else {
149
+ add("blank-open", "pass", `content from ${(blankMs / 1000).toFixed(2)}s`);
150
+ }
151
+
152
+ // 3. Poster: a white rectangle is worse than no poster at all.
153
+ if (await exists(project.posterPath)) {
154
+ const yavg = await imageYavg(project.posterPath);
155
+ if (yavg != null && (yavg > 180 || yavg < 25)) {
156
+ add(
157
+ "poster",
158
+ "warn",
159
+ `${relative(project.dir, project.posterPath)} looks blank (YAVG=${yavg.toFixed(1)}) — ` +
160
+ `set output.poster to an explicit ms`
161
+ );
162
+ } else {
163
+ add("poster", "pass", `${relative(project.dir, project.posterPath)} has content`);
164
+ }
165
+ }
166
+
167
+ // 4. Master loudness, and the music bed's level in the gaps.
168
+ const loud = await ebur128(video);
169
+ if (loud) {
170
+ // The loudness pass only runs with music, or when `output.loudness` is an
171
+ // explicit object — a narration-only render is deliberately untouched, so
172
+ // measuring it against -16 LUFS would warn on every such demo.
173
+ const normalized =
174
+ storyboard.output?.loudness !== false &&
175
+ (storyboard.music != null || typeof storyboard.output?.loudness === "object");
176
+ const wantI = normalized ? -16 : null;
177
+ if (wantI != null && Math.abs(loud.integrated - wantI) > 2) {
178
+ add(
179
+ "loudness",
180
+ "warn",
181
+ `I=${loud.integrated.toFixed(1)} LUFS (target ${wantI}), TP=${loud.truePeak.toFixed(1)} dBTP`
182
+ );
183
+ } else {
184
+ add(
185
+ "loudness",
186
+ wantI == null ? "note" : "pass",
187
+ `I=${loud.integrated.toFixed(1)} LUFS, TP=${loud.truePeak.toFixed(1)} dBTP` +
188
+ (wantI == null ? " (no loudness pass — narration only)" : "")
189
+ );
190
+ }
191
+ if (storyboard.music) {
192
+ const bed = loud.momentaryP10;
193
+ if (bed > -18) {
194
+ add("bed", "warn", `music bed ≈ ${bed.toFixed(1)} LUFS in the gaps — it will fight the voice`);
195
+ } else if (bed < -40) {
196
+ add("bed", "note", `music bed ≈ ${bed.toFixed(1)} LUFS — effectively inaudible`);
197
+ } else {
198
+ add("bed", "pass", `music bed ≈ ${bed.toFixed(1)} LUFS (p10 of the master)`);
199
+ }
200
+ }
201
+ }
202
+
203
+ // 5. Hold shares + whatever compose already warned about.
204
+ if (await exists(project.reportPath)) {
205
+ const report = ComposeReportSchema.safeParse(await readJson(project.reportPath));
206
+ if (report.success) {
207
+ for (const s of report.data.scenes) {
208
+ if (s.holdPct > 0.25) {
209
+ add("hold", "warn", `${s.id} held ${Math.round(s.holdPct * 100)}%`);
210
+ }
211
+ }
212
+ for (const w of report.data.warnings) {
213
+ add("hold", "warn", `engine: ${w.code} — ${w.message}`);
214
+ }
215
+ if (!report.data.warnings.length) add("hold", "pass", "no compose warnings");
216
+ }
217
+ }
218
+
219
+ // 6. Caption readability (the cue stats the captions step logs).
220
+ if (await exists(project.captionsCuesPath)) {
221
+ const cues = (await readJson(project.captionsCuesPath)) as Array<{
222
+ startMs: number;
223
+ endMs: number;
224
+ text: string;
225
+ }>;
226
+ const short = cues.filter((c) => c.endMs - c.startMs < 1000).length;
227
+ let midClause = 0;
228
+ cues.forEach((c, i) => {
229
+ const next = cues[i + 1];
230
+ if (!next) return;
231
+ if (!/[.!?,;:—–]["'”’)]?$/.test(c.text.trim()) && /^\p{Ll}/u.test(next.text.trim())) {
232
+ midClause++;
233
+ }
234
+ });
235
+ const pct = Math.round((midClause / Math.max(1, cues.length - 1)) * 100);
236
+ if (short > 2 || pct > 25) {
237
+ add("captions", "warn", `${cues.length} cues: ${short} under 1s, ${pct}% mid-clause breaks`);
238
+ } else {
239
+ add("captions", "pass", `${cues.length} cues, ${pct}% mid-clause breaks`);
240
+ }
241
+ }
242
+
243
+ // 7. Hygiene the eye catches and the schema can't: an address pill that
244
+ // names one host while the demo visits several, ASCII-only chapter
245
+ // titles in a non-English demo.
246
+ const frameUrl = storyboard.frame?.url;
247
+ if (frameUrl && frameUrl !== "auto") {
248
+ const hosts = new Set<string>();
249
+ for (const scene of storyboard.scenes) {
250
+ for (const a of scene.actions) {
251
+ if (a.op === "goto") {
252
+ const h = hostOf(a.url);
253
+ if (h) hosts.add(h);
254
+ }
255
+ }
256
+ }
257
+ const pill = hostOf(frameUrl);
258
+ if (hosts.size > 1 || (pill && hosts.size === 1 && !hosts.has(pill))) {
259
+ add(
260
+ "hygiene",
261
+ "warn",
262
+ `frame.url is the fixed "${frameUrl}" but the demo visits ${[...hosts].join(", ")} — ` +
263
+ `set frame.url: "auto" to track the page`
264
+ );
265
+ }
266
+ }
267
+ const lang = (storyboard.language ?? project.lang ?? "en").toLowerCase().split(/[-_]/)[0];
268
+ if (lang !== "en" && storyboard.output?.chapters) {
269
+ const ascii = storyboard.scenes.filter(
270
+ (s) => (s.title ?? s.id) && !/[^\x20-\x7e]/.test(s.title ?? s.id)
271
+ ).length;
272
+ if (ascii === storyboard.scenes.length) {
273
+ add(
274
+ "hygiene",
275
+ "note",
276
+ `every chapter title is ASCII in a "${lang}" demo — they are probably scene ids, not titles`
277
+ );
278
+ }
279
+ }
280
+
281
+ const warnings = checks.filter((c) => c.status === "warn").length;
282
+ const notes = checks.filter((c) => c.status === "note").length;
283
+ return { video, durationMs, checks, warnings, notes };
284
+ }
285
+
286
+ /** Print a QA result the way the rest of the CLI prints stages. */
287
+ export function logQa(res: QaResult): void {
288
+ for (const c of res.checks) {
289
+ const glyph = c.status === "pass" ? "✓" : c.status === "warn" ? "W" : "·";
290
+ log(` ${glyph} ${c.id.padEnd(11)}${c.message}`);
291
+ }
292
+ const line = `${res.warnings} warning(s), ${res.notes} note(s)`;
293
+ if (res.warnings) log(`── ${line}`);
294
+ else ok(line);
295
+ }