@tandryukha/aidemo 0.15.1 → 0.16.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/mcp/server.ts CHANGED
@@ -4,7 +4,13 @@ import { z } from "zod";
4
4
  import { zodToJsonSchema } from "zod-to-json-schema";
5
5
  import { readFile } from "node:fs/promises";
6
6
  import { resolve } from "node:path";
7
- import { ENGINE_ROOT, engineVersion, captionsAutoOffline, chromeProfileDir } from "../config.js";
7
+ import {
8
+ ENGINE_ROOT,
9
+ engineVersion,
10
+ captionsAutoOffline,
11
+ chromeProfileDir,
12
+ applyProjectEnv,
13
+ } from "../config.js";
8
14
  import { Project, parseStoryboard } from "../project.js";
9
15
  import { StoryboardSchema, SeedCookieSchema, type SeedCookie, type Storyboard } from "../types.js";
10
16
  import { generateVoice } from "../voice.js";
@@ -498,6 +504,8 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
498
504
  },
499
505
  },
500
506
  async (args) => {
507
+ // fromUrl launches Chrome: honour the target repo's .env (#77).
508
+ if (args.dir && !jobs.running()) await applyProjectEnv(args.dir);
501
509
  const demoDir = await scaffoldDemo(
502
510
  args.dir ? resolve(args.dir) : process.cwd(),
503
511
  args.name,
@@ -523,7 +531,8 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
523
531
  {
524
532
  title: "Environment preflight",
525
533
  description:
526
- "Check prereqs: node, ffmpeg, Chrome, TTS/STT endpoint (flags " +
534
+ "Check prereqs: node, ffmpeg, Chrome (or the AIDEMO_CHROME_CHANNEL / " +
535
+ "AIDEMO_CHROME_EXECUTABLE browser in use), TTS/STT endpoint (flags " +
527
536
  "LLM-only servers like Ollama), API key, playwright, installed skill.",
528
537
  inputSchema: {
529
538
  dir: z.string().optional().describe("repo to check for an installed skill"),
@@ -533,6 +542,15 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
533
542
  node: z.string(),
534
543
  ffmpeg: z.string().nullable(),
535
544
  chrome: z.string().nullable(),
545
+ browser: z.object({
546
+ source: z.enum(["default", "channel", "executable"]),
547
+ channel: z.string().optional(),
548
+ executablePath: z.string().optional(),
549
+ description: z.string(),
550
+ found: z.boolean(),
551
+ version: z.string().optional(),
552
+ error: z.string().optional(),
553
+ }),
536
554
  gh: z.string().nullable(),
537
555
  endpoint: z.object({
538
556
  url: z.string(),
@@ -558,10 +576,13 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
558
576
  },
559
577
  annotations: { readOnlyHint: true },
560
578
  },
561
- async (args) =>
562
- jsonResult(
579
+ async (args) => {
580
+ // Report the env the pipeline would actually run with for this repo (#77).
581
+ if (args.dir && !jobs.running()) await applyProjectEnv(args.dir);
582
+ return jsonResult(
563
583
  (await doctorReport(args.dir)) as unknown as Record<string, unknown>
564
- )
584
+ );
585
+ }
565
586
  );
566
587
 
567
588
  server.registerTool(
@@ -788,6 +809,9 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
788
809
  (async (args: z.infer<z.ZodObject<Shape>>) => {
789
810
  const project = new Project((args as { dir: string }).dir);
790
811
  await project.ensureDirs();
812
+ // The demo's own .env, as the CLI run from its repo would see it
813
+ // (#77). Never swapped under a running job — startJob rejects anyway.
814
+ if (!jobs.running()) await applyProjectEnv(project.dir);
791
815
  try {
792
816
  const job = jobs.startJob(kind, project, makeRunner(project, args));
793
817
  return jsonResult({
@@ -1131,6 +1155,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
1131
1155
  .boolean()
1132
1156
  .optional()
1133
1157
  .describe("approximate captions from the script — no network/STT"),
1158
+ alignScript: z.boolean().optional().describe("use script spelling and punctuation with STT word timing"),
1134
1159
  params: PARAMS_INPUT,
1135
1160
  lang: LANG_INPUT,
1136
1161
  },
@@ -1142,7 +1167,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
1142
1167
  if (args.offline) {
1143
1168
  await generateCaptionsOffline(lp, sb, sceneProgress(job));
1144
1169
  } else {
1145
- await generateCaptions(lp, sb, sceneProgress(job));
1170
+ await generateCaptions(lp, sb, { ...sceneProgress(job), alignScript: args.alignScript });
1146
1171
  }
1147
1172
  return {
1148
1173
  srt: lp.captionsSrtPath,
@@ -1375,6 +1400,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
1375
1400
  });
1376
1401
  const file = project.p("logs", `inspect-${stamp}.json`);
1377
1402
  await writeJson(file, res);
1403
+ if (res.target.status === "blocked") log(`inspect blocked: ${res.target.reason}`);
1378
1404
  log(`inspect: ${res.elements.length} element(s), ${res.headings.length} heading(s) → ${file}`);
1379
1405
  return { ...res, file };
1380
1406
  })
package/src/qa.ts CHANGED
@@ -14,10 +14,12 @@
14
14
 
15
15
  import { spawn } from "node:child_process";
16
16
  import { relative } from "node:path";
17
+ import { stat } from "node:fs/promises";
17
18
  import type { Project } from "./project.js";
18
19
  import {
19
20
  probeDurationMs,
20
21
  probeFlatLeadMs,
22
+ probeVideoDims,
21
23
  } from "./ffmpeg.js";
22
24
  import { ComposeReportSchema, type Storyboard } from "./types.js";
23
25
  import { exists, log, ok, readJson } from "./util.js";
@@ -105,6 +107,24 @@ function imageYavg(file: string): Promise<number | null> {
105
107
  });
106
108
  }
107
109
 
110
+ /**
111
+ * `blankOpenMs` from the compose report that belongs to `video` (written at or
112
+ * after it), or null when there is no such report.
113
+ */
114
+ async function reportedBlankOpenMs(project: Project, video: string): Promise<number | null> {
115
+ if (!(await exists(project.reportPath))) return null;
116
+ try {
117
+ const [rs, vs] = await Promise.all([stat(project.reportPath), stat(video)]);
118
+ if (rs.mtimeMs < vs.mtimeMs) return null;
119
+ const report = ComposeReportSchema.safeParse(await readJson(project.reportPath));
120
+ return report.success && typeof report.data.blankOpenMs === "number"
121
+ ? report.data.blankOpenMs
122
+ : null;
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
127
+
108
128
  /** Host of a URL-ish string, or null. */
109
129
  function hostOf(url: string): string | null {
110
130
  try {
@@ -137,8 +157,17 @@ export async function runQa(project: Project, storyboard: Storyboard): Promise<Q
137
157
  }
138
158
 
139
159
  // 2. The opening frames: a demo that starts on the browser's white pre-paint.
160
+ // Prefer compose's own measurement (report.json `blankOpenMs`): it is taken
161
+ // on the page footage before captions are burned in, and the finished MP4
162
+ // can't tell a caption pill over a blank page from sparse real content
163
+ // (issue #78). Fall back to probing the MP4 when there's no report for it.
140
164
  const introMs = storyboard.intro?.durationMs ?? 0;
141
- const blankMs = await probeFlatLeadMs(video, 4000, introMs).catch(() => 0);
165
+ let blankMs = await reportedBlankOpenMs(project, video);
166
+ if (blankMs == null) {
167
+ const dims = await probeVideoDims(video).catch(() => null);
168
+ const pxScale = dims ? dims.width / storyboard.video.width : 1;
169
+ blankMs = await probeFlatLeadMs(video, 4000, introMs, pxScale).catch(() => 0);
170
+ }
142
171
  if (blankMs > 250) {
143
172
  add(
144
173
  "blank-open",
package/src/recorder.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { chromium } from "playwright";
1
+ import { describeChromeTarget, launchChromePersistent } from "./browser.js";
2
2
  import { promises as fs } from "node:fs";
3
3
  import {
4
4
  TimelineSchema,
@@ -18,6 +18,8 @@ import {
18
18
  captureDevice,
19
19
  obsUrl,
20
20
  obsPassword,
21
+ BrowserSetupError,
22
+ chromeLaunchOptions,
21
23
  } from "./config.js";
22
24
  import {
23
25
  type CaptureMode,
@@ -169,6 +171,11 @@ export async function record(
169
171
  // error); clean up a stale lock from a crashed Chrome.
170
172
  await ensureProfileUnlocked(profileDir);
171
173
  log(`profile: ${profileDir}${options.fresh ? " (fresh)" : ""}`);
174
+ // Non-default browser (issue #80): resolve it now — a bad
175
+ // AIDEMO_CHROME_CHANNEL fails here, before any preflight hook runs.
176
+ if (process.env.AIDEMO_CHROME_EXECUTABLE?.trim() || process.env.AIDEMO_CHROME_CHANNEL?.trim()) {
177
+ log(`browser: ${describeChromeTarget(chromeLaunchOptions())}`);
178
+ }
172
179
 
173
180
  // Preflight hook (issue #44): runs before Chrome launches, with the resolved
174
181
  // profile in its env. It may rewrite the storyboard (a rotating fixture), so
@@ -311,8 +318,7 @@ export async function record(
311
318
 
312
319
  let context;
313
320
  try {
314
- context = await chromium.launchPersistentContext(profileDir, {
315
- channel: "chrome",
321
+ context = await launchChromePersistent(profileDir, {
316
322
  headless: options.headed === false,
317
323
  // Hide the automation fingerprint (navigator.webdriver=false) so
318
324
  // Cloudflare on chatgpt.com doesn't throw a "Verify you are human"
@@ -345,6 +351,8 @@ export async function record(
345
351
  }),
346
352
  });
347
353
  } catch (err) {
354
+ // Wrong/missing browser (issue #80) is already actionable on its own.
355
+ if (err instanceof BrowserSetupError) throw err;
348
356
  throw new Error(
349
357
  `Failed to launch Chrome with profile ${profileDir}.\n` +
350
358
  `If Chrome is already open on this profile, quit it first (Playwright ` +
package/src/safe-regex.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { RE2JS } from "re2js";
2
+
1
3
  /**
2
4
  * Storyboard-supplied regex patterns (`assert.textMatches`, `assert.url`, and
3
5
  * the `textMatches` option the player threads through) are compiled at replay
@@ -29,13 +31,16 @@ export function isUnsafeRegexSource(pattern: string): string | null {
29
31
  * Compile a storyboard-supplied pattern, or throw with `where` naming the
30
32
  * field so the author can see which matcher to fix.
31
33
  */
32
- export function compileUserRegex(pattern: string, flags: string, where: string): RegExp {
34
+ export function compileUserRegex(pattern: string, flags: string, where: string): RE2JS {
33
35
  if (typeof pattern !== "string" || pattern === "")
34
36
  throw new Error(`${where}: regex must be a non-empty string`);
35
37
  const unsafe = isUnsafeRegexSource(pattern);
36
38
  if (unsafe) throw new Error(`${where}: unsafe regex — ${unsafe}`);
37
39
  try {
38
- return new RegExp(pattern, flags);
40
+ // RE2JS guarantees linear-time matching for storyboard supplied patterns.
41
+ // The replay path only uses `test`, so its API is interchangeable here.
42
+ if (flags && flags !== "i") throw new Error(`unsupported regex flags ${flags}`);
43
+ return RE2JS.compile(pattern, flags === "i" ? RE2JS.CASE_INSENSITIVE : 0);
39
44
  } catch (e) {
40
45
  // A raw SyntaxError names neither the field nor the storyboard, which is
41
46
  // the only thing the author can act on.
package/src/types.ts CHANGED
@@ -842,8 +842,10 @@ export const OutputSchema = z
842
842
  /**
843
843
  * Drop the browser's blank pre-paint at the very start of the video, so
844
844
  * the demo opens on the product instead of on ~0.5 s of white under the
845
- * first caption (issue #47). At most `blankOpenCapMs` of the leading flat
846
- * span is kept. Default false (unchanged opening).
845
+ * first caption (issue #47). At most `blankOpenCapMs` of the leading blank
846
+ * span is kept. Blank = empty (one colour, no text/outlines), not merely
847
+ * static: a motionless terminal or page is never trimmed (issue #78).
848
+ * Default false (unchanged opening).
847
849
  */
848
850
  trimLeadingBlank: z.boolean().optional(),
849
851
  /** With `trimLeadingBlank`: leading blank kept, ms. Default 100. */