@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/README.md +22 -1
- package/docs/AUTHORING.md +37 -7
- package/package.json +5 -2
- package/src/attention.ts +5 -4
- package/src/browser.ts +108 -0
- package/src/caption-render.ts +2 -2
- package/src/captions.ts +80 -2
- package/src/cards.ts +2 -2
- package/src/cli.ts +6 -1
- package/src/compose.ts +13 -6
- package/src/config.ts +120 -1
- package/src/cursor.ts +2 -2
- package/src/distribute.ts +79 -4
- package/src/ffmpeg.ts +167 -114
- package/src/frame.ts +2 -2
- package/src/inspect.ts +25 -4
- package/src/login.ts +6 -2
- package/src/mcp/server.ts +32 -6
- package/src/qa.ts +30 -1
- package/src/recorder.ts +11 -3
- package/src/safe-regex.ts +7 -2
- package/src/types.ts +4 -2
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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):
|
|
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
|
-
|
|
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
|
|
846
|
-
* span is kept.
|
|
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. */
|