@tandryukha/aidemo 0.8.0 → 0.14.1
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/.claude/skills/record-demo/SKILL.md +5 -2
- package/README.md +16 -4
- package/docs/AUTHORING.md +609 -45
- package/package.json +3 -2
- package/src/anchors.ts +128 -0
- package/src/attention.ts +365 -0
- package/src/caption-render.ts +17 -8
- package/src/captions.ts +70 -6
- package/src/capture.ts +113 -0
- package/src/cards.ts +15 -6
- package/src/cli.ts +580 -22
- package/src/compose.ts +1006 -93
- package/src/cursor-overlay.ts +7 -113
- package/src/cursor.ts +9 -3
- package/src/distribute.ts +37 -4
- package/src/expr.ts +130 -0
- package/src/ffmpeg.ts +156 -0
- package/src/frame.ts +205 -0
- package/src/frames.ts +159 -0
- package/src/golden.ts +27 -0
- package/src/guide.ts +102 -0
- package/src/i18n.ts +7 -4
- package/src/import-trace.ts +629 -0
- package/src/inspect.ts +307 -0
- package/src/lint.ts +601 -0
- package/src/login.ts +143 -0
- package/src/mcp/jobs.ts +4 -1
- package/src/mcp/server.ts +403 -26
- package/src/player.ts +975 -32
- package/src/profile.ts +128 -0
- package/src/project.ts +12 -2
- package/src/recorder.ts +252 -15
- package/src/setup.ts +248 -0
- package/src/starter.ts +244 -0
- package/src/stills.ts +15 -4
- package/src/types.ts +643 -5
- package/src/variants.ts +1 -1
- package/src/voice-local.ts +7 -1
- package/src/voice.ts +60 -1
- package/src/walkthrough.ts +250 -0
- package/src/zoom.ts +85 -39
package/src/setup.ts
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-take preparation (issue #44): seed cookies / a Playwright storageState
|
|
3
|
+
* into the recording profile, and run an optional preflight hook — the two
|
|
4
|
+
* things a cookie-gated or fixture-rotating site otherwise forces you to
|
|
5
|
+
* script by hand around the engine.
|
|
6
|
+
*
|
|
7
|
+
* Seeding happens INSIDE the launched context (after Chrome is up, before the
|
|
8
|
+
* first storyboard action), so it composes with `--fresh` (the wipe comes
|
|
9
|
+
* first) and with a logged-in shared profile alike. The preflight hook runs
|
|
10
|
+
* before Chrome launches, with the resolved profile dir in its env, so a
|
|
11
|
+
* script that wants to seed the profile its own way still can.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { promises as fs } from "node:fs";
|
|
16
|
+
import { isAbsolute, resolve } from "node:path";
|
|
17
|
+
import type { BrowserContext, Page } from "playwright";
|
|
18
|
+
import type { SeedCookie, Storyboard } from "./types.js";
|
|
19
|
+
import { log } from "./util.js";
|
|
20
|
+
|
|
21
|
+
/** Playwright's storageState file shape (the subset we consume). */
|
|
22
|
+
interface StorageStateFile {
|
|
23
|
+
cookies?: SeedCookie[];
|
|
24
|
+
origins?: Array<{
|
|
25
|
+
origin: string;
|
|
26
|
+
localStorage?: Array<{ name: string; value: string }>;
|
|
27
|
+
}>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Everything to inject into the context before the first action. */
|
|
31
|
+
export interface Seeds {
|
|
32
|
+
cookies: SeedCookie[];
|
|
33
|
+
origins: Array<{ origin: string; localStorage: Array<{ name: string; value: string }> }>;
|
|
34
|
+
/** Where each seed came from, for the log line. */
|
|
35
|
+
sources: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parse one `--cookie` flag: `name=value;domain=host;path=/;secure;httpOnly;
|
|
40
|
+
* sameSite=Lax;url=https://host/` — the first `k=v` is the cookie, the rest
|
|
41
|
+
* are attributes (cookie-header style, so it can be pasted from DevTools).
|
|
42
|
+
*/
|
|
43
|
+
export function parseCookieFlag(spec: string): SeedCookie {
|
|
44
|
+
const parts = spec
|
|
45
|
+
.split(";")
|
|
46
|
+
.map((p) => p.trim())
|
|
47
|
+
.filter(Boolean);
|
|
48
|
+
if (parts.length === 0) throw new Error(`--cookie: empty spec`);
|
|
49
|
+
const eq = parts[0].indexOf("=");
|
|
50
|
+
if (eq <= 0) {
|
|
51
|
+
throw new Error(`--cookie: expected name=value first, got "${parts[0]}"`);
|
|
52
|
+
}
|
|
53
|
+
const cookie: SeedCookie = {
|
|
54
|
+
name: parts[0].slice(0, eq).trim(),
|
|
55
|
+
value: parts[0].slice(eq + 1).trim(),
|
|
56
|
+
};
|
|
57
|
+
for (const attr of parts.slice(1)) {
|
|
58
|
+
const i = attr.indexOf("=");
|
|
59
|
+
const key = (i < 0 ? attr : attr.slice(0, i)).trim().toLowerCase();
|
|
60
|
+
const val = i < 0 ? "" : attr.slice(i + 1).trim();
|
|
61
|
+
switch (key) {
|
|
62
|
+
case "domain":
|
|
63
|
+
cookie.domain = val;
|
|
64
|
+
break;
|
|
65
|
+
case "path":
|
|
66
|
+
cookie.path = val;
|
|
67
|
+
break;
|
|
68
|
+
case "url":
|
|
69
|
+
cookie.url = val;
|
|
70
|
+
break;
|
|
71
|
+
case "expires":
|
|
72
|
+
cookie.expires = Number(val);
|
|
73
|
+
break;
|
|
74
|
+
case "secure":
|
|
75
|
+
cookie.secure = val === "" || val === "true";
|
|
76
|
+
break;
|
|
77
|
+
case "httponly":
|
|
78
|
+
cookie.httpOnly = val === "" || val === "true";
|
|
79
|
+
break;
|
|
80
|
+
case "samesite": {
|
|
81
|
+
const v = val.toLowerCase();
|
|
82
|
+
cookie.sameSite = v === "strict" ? "Strict" : v === "none" ? "None" : "Lax";
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
default:
|
|
86
|
+
throw new Error(`--cookie: unknown attribute "${key}" in "${spec}"`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (!cookie.domain && !cookie.url) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`--cookie "${cookie.name}": add domain=<host> (or url=<origin>) so the ` +
|
|
92
|
+
`browser knows which site it belongs to`
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
return cookie;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Normalize a seed cookie into the shape `context.addCookies` accepts. */
|
|
99
|
+
function toPlaywrightCookie(c: SeedCookie) {
|
|
100
|
+
if (c.url) {
|
|
101
|
+
return {
|
|
102
|
+
name: c.name,
|
|
103
|
+
value: c.value,
|
|
104
|
+
url: c.url,
|
|
105
|
+
...(c.expires != null ? { expires: c.expires } : {}),
|
|
106
|
+
...(c.httpOnly != null ? { httpOnly: c.httpOnly } : {}),
|
|
107
|
+
...(c.secure != null ? { secure: c.secure } : {}),
|
|
108
|
+
...(c.sameSite ? { sameSite: c.sameSite } : {}),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
name: c.name,
|
|
113
|
+
value: c.value,
|
|
114
|
+
domain: c.domain!,
|
|
115
|
+
path: c.path ?? "/",
|
|
116
|
+
...(c.expires != null ? { expires: c.expires } : {}),
|
|
117
|
+
...(c.httpOnly != null ? { httpOnly: c.httpOnly } : {}),
|
|
118
|
+
...(c.secure != null ? { secure: c.secure } : {}),
|
|
119
|
+
...(c.sameSite ? { sameSite: c.sameSite } : {}),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface SeedInputs {
|
|
124
|
+
/** CLI/MCP `--storage-state <file>` (in addition to the storyboard's). */
|
|
125
|
+
storageState?: string;
|
|
126
|
+
/** CLI/MCP `--cookie` specs, already parsed. */
|
|
127
|
+
cookies?: SeedCookie[];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Collect every seed the take should inject: the storyboard `setup` block
|
|
132
|
+
* first, then CLI/MCP overrides on top. Relative storageState paths resolve
|
|
133
|
+
* against the demo dir. Returns null when nothing is to be seeded.
|
|
134
|
+
*/
|
|
135
|
+
export async function collectSeeds(
|
|
136
|
+
demoDir: string,
|
|
137
|
+
storyboard: Storyboard,
|
|
138
|
+
inputs: SeedInputs = {}
|
|
139
|
+
): Promise<Seeds | null> {
|
|
140
|
+
const seeds: Seeds = { cookies: [], origins: [], sources: [] };
|
|
141
|
+
const files = [storyboard.setup?.storageState, inputs.storageState].filter(
|
|
142
|
+
(f): f is string => !!f
|
|
143
|
+
);
|
|
144
|
+
for (const f of files) {
|
|
145
|
+
const path = isAbsolute(f) ? f : resolve(demoDir, f);
|
|
146
|
+
let parsed: StorageStateFile;
|
|
147
|
+
try {
|
|
148
|
+
parsed = JSON.parse(await fs.readFile(path, "utf8")) as StorageStateFile;
|
|
149
|
+
} catch (err) {
|
|
150
|
+
throw new Error(`storageState ${path}: ${(err as Error).message}`);
|
|
151
|
+
}
|
|
152
|
+
const cookies = parsed.cookies ?? [];
|
|
153
|
+
const origins = (parsed.origins ?? []).map((o) => ({
|
|
154
|
+
origin: o.origin,
|
|
155
|
+
localStorage: o.localStorage ?? [],
|
|
156
|
+
}));
|
|
157
|
+
seeds.cookies.push(...cookies);
|
|
158
|
+
seeds.origins.push(...origins.filter((o) => o.localStorage.length > 0));
|
|
159
|
+
seeds.sources.push(
|
|
160
|
+
`${path} (${cookies.length} cookie(s), ${origins.length} origin(s))`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
const inline = [...(storyboard.setup?.cookies ?? []), ...(inputs.cookies ?? [])];
|
|
164
|
+
if (inline.length) {
|
|
165
|
+
seeds.cookies.push(...inline);
|
|
166
|
+
seeds.sources.push(`${inline.length} inline cookie(s)`);
|
|
167
|
+
}
|
|
168
|
+
return seeds.cookies.length || seeds.origins.length ? seeds : null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Inject the seeds into a live context. Cookies go straight into the profile's
|
|
173
|
+
* cookie jar; localStorage is per-origin and can only be written from a page
|
|
174
|
+
* ON that origin, so each origin is visited once (about:blank is restored
|
|
175
|
+
* afterwards — the visits land in the recording's trimmed lead-in).
|
|
176
|
+
*/
|
|
177
|
+
export async function applySeeds(
|
|
178
|
+
context: BrowserContext,
|
|
179
|
+
page: Page,
|
|
180
|
+
seeds: Seeds
|
|
181
|
+
): Promise<void> {
|
|
182
|
+
if (seeds.cookies.length) {
|
|
183
|
+
await context.addCookies(seeds.cookies.map(toPlaywrightCookie));
|
|
184
|
+
}
|
|
185
|
+
for (const o of seeds.origins) {
|
|
186
|
+
await page.goto(o.origin, { waitUntil: "domcontentloaded" });
|
|
187
|
+
await page.evaluate((entries) => {
|
|
188
|
+
for (const { name, value } of entries) localStorage.setItem(name, value);
|
|
189
|
+
}, o.localStorage);
|
|
190
|
+
}
|
|
191
|
+
if (seeds.origins.length) await page.goto("about:blank");
|
|
192
|
+
log(
|
|
193
|
+
`seeded profile: ${seeds.cookies.length} cookie(s), ` +
|
|
194
|
+
`${seeds.origins.length} origin(s) of localStorage ← ${seeds.sources.join("; ")}`
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface PreflightEnv {
|
|
199
|
+
demoDir: string;
|
|
200
|
+
storyboardPath: string;
|
|
201
|
+
profileDir: string;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Run the storyboard's `setup.preflight` shell command from the demo dir.
|
|
206
|
+
* Output is streamed into the engine log (prefixed), a non-zero exit aborts
|
|
207
|
+
* the take with the command's stderr tail.
|
|
208
|
+
*/
|
|
209
|
+
export async function runPreflight(command: string, env: PreflightEnv): Promise<void> {
|
|
210
|
+
log(`preflight: ${command}`);
|
|
211
|
+
const started = Date.now();
|
|
212
|
+
await new Promise<void>((resolvePromise, reject) => {
|
|
213
|
+
const proc = spawn(command, {
|
|
214
|
+
cwd: env.demoDir,
|
|
215
|
+
shell: true,
|
|
216
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
217
|
+
env: {
|
|
218
|
+
...process.env,
|
|
219
|
+
AIDEMO_DEMO_DIR: env.demoDir,
|
|
220
|
+
AIDEMO_STORYBOARD: env.storyboardPath,
|
|
221
|
+
AIDEMO_PROFILE: env.profileDir,
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
const tail: string[] = [];
|
|
225
|
+
const relay = (chunk: Buffer) => {
|
|
226
|
+
for (const line of chunk.toString().split(/\r?\n/)) {
|
|
227
|
+
if (!line.trim()) continue;
|
|
228
|
+
log(` │ ${line}`);
|
|
229
|
+
tail.push(line);
|
|
230
|
+
if (tail.length > 20) tail.shift();
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
proc.stdout.on("data", relay);
|
|
234
|
+
proc.stderr.on("data", relay);
|
|
235
|
+
proc.on("error", reject);
|
|
236
|
+
proc.on("close", (code, signal) => {
|
|
237
|
+
if (code === 0) return resolvePromise();
|
|
238
|
+
reject(
|
|
239
|
+
new Error(
|
|
240
|
+
`preflight exited ${code ?? signal} — the take was not started.\n` +
|
|
241
|
+
` command: ${command}\n` +
|
|
242
|
+
(tail.length ? ` last output:\n ${tail.join("\n ")}` : "")
|
|
243
|
+
)
|
|
244
|
+
);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
log(`preflight ok (${Date.now() - started}ms)`);
|
|
248
|
+
}
|
package/src/starter.ts
CHANGED
|
@@ -139,3 +139,247 @@ export const STARTER_STORYBOARD = (name: string): string =>
|
|
|
139
139
|
null,
|
|
140
140
|
2
|
|
141
141
|
) + "\n";
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// `init --from-url`: a draft built from what `inspect` saw on the page — real
|
|
145
|
+
// headings become scenes, real unique selectors become the beats. No LLM: the
|
|
146
|
+
// agent still writes the narration and decides the flow; this only removes
|
|
147
|
+
// the selector archaeology from the first draft.
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
import type { InspectElement, InspectResult } from "./inspect.js";
|
|
151
|
+
|
|
152
|
+
function slug(text: string, max = 24): string {
|
|
153
|
+
const s = text
|
|
154
|
+
.toLowerCase()
|
|
155
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
156
|
+
.replace(/^-+|-+$/g, "")
|
|
157
|
+
.slice(0, max)
|
|
158
|
+
.replace(/-+$/g, "");
|
|
159
|
+
return s || "scene";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function jsonText(text: string): string {
|
|
163
|
+
return text.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A selector that reaches exactly this heading. `:has-text()` is a substring
|
|
168
|
+
* match, so a long heading is trimmed at a word boundary (shorter = less
|
|
169
|
+
* brittle against a line break or a trailing badge); a page that repeats the
|
|
170
|
+
* same heading text gets an explicit `nth=` so probe doesn't hit Playwright's
|
|
171
|
+
* strict-mode "resolved to N elements".
|
|
172
|
+
*/
|
|
173
|
+
function headingSelector(
|
|
174
|
+
h: { level: number; text: string },
|
|
175
|
+
all: Array<{ level: number; text: string }>
|
|
176
|
+
): string {
|
|
177
|
+
let text = h.text;
|
|
178
|
+
if (text.length > 40) {
|
|
179
|
+
const cut = text.slice(0, 40);
|
|
180
|
+
const sp = cut.lastIndexOf(" ");
|
|
181
|
+
text = (sp > 16 ? cut.slice(0, sp) : cut).trim();
|
|
182
|
+
}
|
|
183
|
+
const sel = `h${h.level}:has-text("${jsonText(text)}")`;
|
|
184
|
+
// Count what that selector would also match: same level, containing the text.
|
|
185
|
+
const matches = all.filter((o) => o.level === h.level && o.text.includes(text));
|
|
186
|
+
if (matches.length < 2) return sel;
|
|
187
|
+
const idx = Math.max(0, matches.findIndex((o) => o.text === h.text));
|
|
188
|
+
return `${sel} >> nth=${idx}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Headings worth a scene: h1/h2 (h3 when there are too few), deduped, ≤ 4. */
|
|
192
|
+
function sceneHeadings(res: InspectResult): Array<{ level: number; text: string }> {
|
|
193
|
+
const clean = res.headings
|
|
194
|
+
.map((h) => ({ level: h.level, text: h.text.replace(/\s+/g, " ").trim() }))
|
|
195
|
+
.filter((h) => h.text.length >= 3 && h.text.length <= 80);
|
|
196
|
+
const seen = new Set<string>();
|
|
197
|
+
const pick = (maxLevel: number) =>
|
|
198
|
+
clean.filter((h) => {
|
|
199
|
+
if (h.level > maxLevel || seen.has(h.text.toLowerCase())) return false;
|
|
200
|
+
seen.add(h.text.toLowerCase());
|
|
201
|
+
return true;
|
|
202
|
+
});
|
|
203
|
+
let out = pick(2);
|
|
204
|
+
if (out.length < 2) out = [...out, ...pick(3)];
|
|
205
|
+
return out.slice(0, 4);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The most promising interactive elements: named, unique, in the viewport first. */
|
|
209
|
+
function candidateElements(res: InspectResult, max = 8): InspectElement[] {
|
|
210
|
+
const score = (e: InspectElement): number =>
|
|
211
|
+
(e.selectors.length ? 2 : 0) +
|
|
212
|
+
(e.inViewport ? 2 : 0) +
|
|
213
|
+
(e.role === "button" || e.role === "textbox" || e.role === "searchbox" ? 1 : 0) +
|
|
214
|
+
(e.name ? 1 : 0) +
|
|
215
|
+
(e.testid ? 1 : 0);
|
|
216
|
+
return [...res.elements]
|
|
217
|
+
.filter((e) => e.selectors.length && e.name)
|
|
218
|
+
.sort((a, b) => score(b) - score(a))
|
|
219
|
+
.slice(0, max);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function storyboardFromInspect(name: string, res: InspectResult): string {
|
|
223
|
+
const allHeadings = res.headings.map((h) => ({
|
|
224
|
+
level: h.level,
|
|
225
|
+
text: h.text.replace(/\s+/g, " ").trim(),
|
|
226
|
+
}));
|
|
227
|
+
const headings = sceneHeadings(res);
|
|
228
|
+
const cands = candidateElements(res);
|
|
229
|
+
const search = cands.find((e) => e.role === "textbox" || e.role === "searchbox");
|
|
230
|
+
const cta = cands.find((e) => e.role === "button" || e.role === "link");
|
|
231
|
+
const title = res.title?.trim() || name;
|
|
232
|
+
const scenes: unknown[] = [];
|
|
233
|
+
|
|
234
|
+
scenes.push({
|
|
235
|
+
id: "s1-open",
|
|
236
|
+
narration: `<hook: what ${title} does, in one sentence>`,
|
|
237
|
+
actions: [
|
|
238
|
+
{ op: "goto", url: res.finalUrl || res.url },
|
|
239
|
+
{ op: "pause", ms: 1200 },
|
|
240
|
+
...(search
|
|
241
|
+
? [
|
|
242
|
+
{
|
|
243
|
+
op: "type",
|
|
244
|
+
target: { selector: search.selector },
|
|
245
|
+
humanize: false,
|
|
246
|
+
text: "<a realistic query>",
|
|
247
|
+
comment: `textbox "${search.name}" — delete this beat if the story doesn't start with a search`,
|
|
248
|
+
},
|
|
249
|
+
{ op: "press", key: "Enter" },
|
|
250
|
+
{ op: "pause", ms: 1400 },
|
|
251
|
+
]
|
|
252
|
+
: []),
|
|
253
|
+
],
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
headings.slice(search ? 0 : 1).forEach((h, i) => {
|
|
257
|
+
scenes.push({
|
|
258
|
+
id: `s${scenes.length + 1}-${slug(h.text)}`,
|
|
259
|
+
narration: `<narrate the "${h.text}" section>`,
|
|
260
|
+
actions: [
|
|
261
|
+
{
|
|
262
|
+
op: "scrollTo",
|
|
263
|
+
target: { selector: headingSelector(h, allHeadings) },
|
|
264
|
+
easing: "smooth",
|
|
265
|
+
},
|
|
266
|
+
{ op: "pause", ms: 1400 },
|
|
267
|
+
],
|
|
268
|
+
...(i === 0 ? {} : {}),
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// Heading-less page (a single-page app that paints its own layout): there is
|
|
273
|
+
// nothing to name a section by, so walk the page instead — two viewport-sized
|
|
274
|
+
// scroll beats give the agent a real skeleton to rewrite, rather than a
|
|
275
|
+
// one-scene draft that hides how long the page is.
|
|
276
|
+
if (headings.length === 0) {
|
|
277
|
+
const step = Math.round(res.viewport.height * 0.8);
|
|
278
|
+
for (const [i, label] of ["what's on the page", "further down the page"].entries()) {
|
|
279
|
+
scenes.push({
|
|
280
|
+
id: `s${scenes.length + 1}-scroll-${i + 1}`,
|
|
281
|
+
narration: `<narrate ${label} — no headings on this page, so name the section yourself>`,
|
|
282
|
+
actions: [
|
|
283
|
+
{ op: "scrollBy", dy: step, easing: "smooth" },
|
|
284
|
+
{ op: "pause", ms: 1400 },
|
|
285
|
+
],
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (cta) {
|
|
291
|
+
scenes.push({
|
|
292
|
+
id: `s${scenes.length + 1}-${slug(cta.name)}`,
|
|
293
|
+
narration: `<the payoff: what happens after "${cta.name}">`,
|
|
294
|
+
actions: [
|
|
295
|
+
{
|
|
296
|
+
op: "hover",
|
|
297
|
+
target: { selector: cta.selector },
|
|
298
|
+
comment: `${cta.role} "${cta.name}" — change to click once the flow after it is known, then assert the result`,
|
|
299
|
+
},
|
|
300
|
+
{ op: "pause", ms: 1400 },
|
|
301
|
+
],
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const frames: Record<string, string> = {};
|
|
306
|
+
for (const f of res.iframes.slice(0, 3)) frames[f.name || `frame${Object.keys(frames).length + 1}`] = f.selector;
|
|
307
|
+
|
|
308
|
+
const doc = {
|
|
309
|
+
_README:
|
|
310
|
+
`Draft from \`inspect ${res.url}\` — headings became scenes, unique selectors became beats. ` +
|
|
311
|
+
"Write the narration (one idea per scene, ~2.5 words/s), turn the candidate hover into the real click + an assert, " +
|
|
312
|
+
"drop scenes that don't serve the story, then `aidemo probe`. `_candidates` lists more selectors seen on the page." +
|
|
313
|
+
(headings.length === 0
|
|
314
|
+
? " No headings were found (single-page app?) — the scroll scenes are placeholders; point them at real elements with `scrollTo`."
|
|
315
|
+
: ""),
|
|
316
|
+
title,
|
|
317
|
+
targetLengthSeconds: 45,
|
|
318
|
+
video: { width: res.viewport.width, height: res.viewport.height },
|
|
319
|
+
...(Object.keys(frames).length ? { frames } : {}),
|
|
320
|
+
voice: {
|
|
321
|
+
voiceId: "marin",
|
|
322
|
+
instructions: "Confident, friendly founder. Clear and warm, brisk but not rushed.",
|
|
323
|
+
speed: 1.05,
|
|
324
|
+
},
|
|
325
|
+
zoom: { scale: 1.55, easeMs: 600, holdMs: 1700 },
|
|
326
|
+
intro: { title, subtitle: "<one-line value prop>", durationMs: 2600 },
|
|
327
|
+
outro: { title: "<call to action>", subtitle: "<your-domain.example>", durationMs: 2600 },
|
|
328
|
+
scenes,
|
|
329
|
+
_candidates: cands.map((e) => ({
|
|
330
|
+
role: e.role,
|
|
331
|
+
name: e.name,
|
|
332
|
+
selector: e.selector,
|
|
333
|
+
...(e.frame ? { frame: e.frame } : {}),
|
|
334
|
+
...(e.inViewport ? {} : { belowFold: true }),
|
|
335
|
+
})),
|
|
336
|
+
};
|
|
337
|
+
return JSON.stringify(doc, null, 2) + "\n";
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function briefFromInspect(name: string, res: InspectResult): string {
|
|
341
|
+
const headings = res.headings.map((h) => `${" ".repeat(Math.max(0, h.level - 1))}- h${h.level} ${h.text}`);
|
|
342
|
+
const cands = candidateElements(res, 12).map(
|
|
343
|
+
(e) => `| ${e.role} | ${e.name.replace(/\|/g, "/")} | \`${e.selector}\` | ${e.inViewport ? "" : "below fold"} |`
|
|
344
|
+
);
|
|
345
|
+
return `# Demo Brief — ${name}
|
|
346
|
+
|
|
347
|
+
Drafted from \`aidemo inspect ${res.url}\` (page title: ${res.title || "—"}).
|
|
348
|
+
Fill in the product, audience, tone and CTA; the storyboard next to this file
|
|
349
|
+
already has the page's real selectors.
|
|
350
|
+
|
|
351
|
+
## Product
|
|
352
|
+
<your product>
|
|
353
|
+
|
|
354
|
+
## Demo goal
|
|
355
|
+
<the core flow, end to end>
|
|
356
|
+
|
|
357
|
+
## Audience
|
|
358
|
+
<who is this for>
|
|
359
|
+
|
|
360
|
+
## Tone
|
|
361
|
+
Friendly, practical, founder-style. Brisk.
|
|
362
|
+
|
|
363
|
+
## Length
|
|
364
|
+
~45-60 seconds.
|
|
365
|
+
|
|
366
|
+
## CTA
|
|
367
|
+
<what should the viewer do next>
|
|
368
|
+
|
|
369
|
+
---
|
|
370
|
+
|
|
371
|
+
## What inspect saw
|
|
372
|
+
|
|
373
|
+
Headings:
|
|
374
|
+
${headings.join("\n") || "- (none)"}
|
|
375
|
+
|
|
376
|
+
Interactive elements (unique selectors):
|
|
377
|
+
|
|
378
|
+
| role | name | selector | |
|
|
379
|
+
|---|---|---|---|
|
|
380
|
+
${cands.join("\n") || "| — | — | — | |"}
|
|
381
|
+
${res.iframes.length ? `\niframes: ${res.iframes.map((f) => `${f.name || "?"} → \`${f.selector}\``).join(", ")}\n` : ""}
|
|
382
|
+
Next: write the narration, replace the candidate hover with the real click +
|
|
383
|
+
\`assert\`, then \`aidemo probe\` (or the MCP \`probe\` job).
|
|
384
|
+
`;
|
|
385
|
+
}
|
package/src/stills.ts
CHANGED
|
@@ -14,6 +14,8 @@ interface StillMarker {
|
|
|
14
14
|
sceneId: string;
|
|
15
15
|
name: string;
|
|
16
16
|
tMs: number;
|
|
17
|
+
source?: string;
|
|
18
|
+
leadInMs?: number;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
/** True if any scene declares a `still` action (drives auto-extract in render). */
|
|
@@ -65,7 +67,14 @@ export async function extractStills(
|
|
|
65
67
|
);
|
|
66
68
|
}
|
|
67
69
|
seen.set(ev.name, scene.id);
|
|
68
|
-
markers.push({
|
|
70
|
+
markers.push({
|
|
71
|
+
sceneId: scene.id,
|
|
72
|
+
name: ev.name,
|
|
73
|
+
tMs: ev.tMs,
|
|
74
|
+
// Resumed takes: a reused scene's still lives in its own raw file.
|
|
75
|
+
source: scene.source ? resolve(project.dir, scene.source) : undefined,
|
|
76
|
+
leadInMs: scene.leadInMs,
|
|
77
|
+
});
|
|
69
78
|
}
|
|
70
79
|
}
|
|
71
80
|
|
|
@@ -96,8 +105,10 @@ export async function extractStills(
|
|
|
96
105
|
// The same timeline→video mapping compose uses (rawT = tMs + leadInMs).
|
|
97
106
|
// Clamp just inside the recording so a marker at the very tail still yields
|
|
98
107
|
// a decodable frame rather than an ffmpeg seek past EOF.
|
|
99
|
-
const
|
|
100
|
-
const
|
|
108
|
+
const src = m.source ?? rawVideo;
|
|
109
|
+
const srcMs = m.source ? await probeDurationMs(src) : videoMs;
|
|
110
|
+
const rawMs = m.tMs + (m.leadInMs ?? timeline.leadInMs);
|
|
111
|
+
const seekMs = Math.max(0, Math.min(rawMs, Math.max(0, srcMs - 60)));
|
|
101
112
|
const out = resolve(outDir, `${m.name}.png`);
|
|
102
113
|
// -ss before -i is a fast, accurate seek (accurate_seek is on by default);
|
|
103
114
|
// -frames:v 1 grabs exactly one frame. No overlay/drawtext filters, so this
|
|
@@ -106,7 +117,7 @@ export async function extractStills(
|
|
|
106
117
|
"-ss",
|
|
107
118
|
(seekMs / 1000).toFixed(3),
|
|
108
119
|
"-i",
|
|
109
|
-
|
|
120
|
+
src,
|
|
110
121
|
"-frames:v",
|
|
111
122
|
"1",
|
|
112
123
|
out,
|