@tandryukha/aidemo 0.8.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/starter.ts ADDED
@@ -0,0 +1,141 @@
1
+ /** Templates written by `aidemo init`. Kept in code so init has no file deps.
2
+ *
3
+ * The storyboard scaffolds a common shape — search → results → act → confirm —
4
+ * with placeholder selectors you MUST replace with your app's real hooks
5
+ * (confirm them by driving the real page once; `aidemo probe` dry-runs the
6
+ * actions without narration). For ChatGPT Apps SDK widgets and other
7
+ * iframe-embedded apps, see the record-demo skill for the frames pattern.
8
+ */
9
+
10
+ export const STARTER_BRIEF = (name: string): string => `# Demo Brief — ${name}
11
+
12
+ ## Product
13
+ <your product> (a web app; for ChatGPT apps / Apps SDK widgets see the record-demo skill)
14
+
15
+ ## Demo goal
16
+ Show a user completing <the core flow> end-to-end — e.g. search for an item,
17
+ compare results, add to basket, and check out.
18
+
19
+ ## Audience
20
+ <who is this demo for>
21
+
22
+ ## Tone
23
+ Friendly, practical, founder-style. Brisk.
24
+
25
+ ## Length
26
+ ~45-60 seconds.
27
+
28
+ ## CTA
29
+ <what should the viewer do next>
30
+
31
+ ---
32
+
33
+ Authoring notes (see the record-demo skill for the full schema + principles):
34
+ - 4-6 scenes, ONE idea each; ~2.5 words/second of narration per scene.
35
+ - Use waitForWidget (with label "thinking") for every async/loading wait so the
36
+ dead time gets trimmed out of the final cut.
37
+ - Set humanize:false on typing when the narration (not the typing) should set
38
+ the scene's pace.
39
+ - Placeholder selectors in the scaffold storyboard will NOT match your app —
40
+ replace them with real hooks and confirm with \`aidemo probe\`.
41
+ - If your app renders inside an iframe (e.g. a ChatGPT Apps SDK widget), declare
42
+ it under "frames" and target elements with { "frame": "<name>", ... } — the
43
+ skill documents the nested-iframe pattern and real-world gotchas.
44
+ `;
45
+
46
+ export const STARTER_STORYBOARD = (name: string): string =>
47
+ JSON.stringify(
48
+ {
49
+ _README:
50
+ "Scaffold with PLACEHOLDER selectors — replace every #search / [data-demo=...] with your app's real hooks, then verify with `aidemo probe`. Keep scenes to one idea each; use waitForWidget for async waits so they get trimmed. For iframe-embedded apps (ChatGPT widgets), add a 'frames' entry and target with {frame:'<name>'} — see the record-demo skill.",
51
+ title: name,
52
+ targetLengthSeconds: 55,
53
+ video: { width: 1280, height: 800 },
54
+ // For iframe-embedded apps, name the iframe here and target elements with
55
+ // { "frame": "widget", "selector": "..." }. Example (ChatGPT Apps SDK —
56
+ // the 'oaiusercontent' substring makes the engine descend into the nested
57
+ // widget iframe automatically):
58
+ // frames: { widget: 'iframe[src*="web-sandbox.oaiusercontent.com"]' }
59
+ frames: {},
60
+ voice: {
61
+ voiceId: "marin",
62
+ instructions: "Confident, friendly founder. Clear and warm, brisk but not rushed.",
63
+ speed: 1.05,
64
+ },
65
+ // Screen-Studio-style auto-zoom on every click/typed prompt. Delete this
66
+ // key to disable, or set "zoom": false on a busy scene.
67
+ zoom: { scale: 1.55, easeMs: 600, holdMs: 1700 },
68
+ intro: {
69
+ title: name,
70
+ subtitle: "<one-line value prop>",
71
+ durationMs: 2600,
72
+ },
73
+ outro: {
74
+ title: "<call to action>",
75
+ subtitle: "<your-domain.example>",
76
+ durationMs: 2600,
77
+ },
78
+ // Optional: drop a track at assets/music.mp3 for a music bed. It ducks
79
+ // under narration automatically (sidechain) and swells over the cards.
80
+ // music: { track: "assets/music.mp3", gainDb: -14 },
81
+ scenes: [
82
+ {
83
+ id: "s1-search",
84
+ narration:
85
+ "Meet <product> — describe what you need in plain language, and it searches the live catalog for you.",
86
+ actions: [
87
+ { op: "goto", url: "https://your-app.example/" },
88
+ { op: "pause", ms: 1200 },
89
+ {
90
+ op: "type",
91
+ target: { selector: "#search" },
92
+ humanize: false,
93
+ text: "<a realistic search query>",
94
+ },
95
+ { op: "press", key: "Enter" },
96
+ {
97
+ op: "waitForWidget",
98
+ target: { selector: "[data-demo=result-card]" },
99
+ label: "thinking",
100
+ timeoutMs: 30000,
101
+ },
102
+ { op: "pause", ms: 1600 },
103
+ ],
104
+ },
105
+ {
106
+ id: "s2-results",
107
+ narration:
108
+ "Results come back with the details that matter — so the best option jumps right out.",
109
+ actions: [
110
+ { op: "scrollTo", target: { selector: "[data-demo=result-card]" }, easing: "smooth" },
111
+ { op: "pause", ms: 1400 },
112
+ ],
113
+ },
114
+ {
115
+ id: "s3-act",
116
+ narration: "Pick one, and it goes straight into the basket — no forms, no friction.",
117
+ actions: [
118
+ { op: "click", target: { selector: "[data-demo=add-to-cart]" } },
119
+ { op: "pause", ms: 1400 },
120
+ ],
121
+ },
122
+ {
123
+ id: "s4-confirm",
124
+ narration:
125
+ "Check out in a single click, and you're done. That's <product> — <value prop, restated>.",
126
+ actions: [
127
+ { op: "click", target: { selector: "[data-demo=checkout]" } },
128
+ {
129
+ op: "waitForWidget",
130
+ target: { selector: "[data-demo=order-confirmed]" },
131
+ label: "thinking",
132
+ timeoutMs: 25000,
133
+ },
134
+ { op: "pause", ms: 2200 },
135
+ ],
136
+ },
137
+ ],
138
+ },
139
+ null,
140
+ 2
141
+ ) + "\n";
package/src/stills.ts ADDED
@@ -0,0 +1,121 @@
1
+ import { resolve, isAbsolute } from "node:path";
2
+ import { Project } from "./project.js";
3
+ import { TimelineSchema, type Storyboard } from "./types.js";
4
+ import { runFfmpeg, probeDurationMs, probeVideoDims } from "./ffmpeg.js";
5
+ import { ensureDir, exists, readJson, log, ok, step } from "./util.js";
6
+
7
+ export interface StillsOptions {
8
+ /** Output directory. Default <dir>/output/stills. */
9
+ outDir?: string;
10
+ }
11
+
12
+ /** A still marker with its scene id, in timeline order. */
13
+ interface StillMarker {
14
+ sceneId: string;
15
+ name: string;
16
+ tMs: number;
17
+ }
18
+
19
+ /** True if any scene declares a `still` action (drives auto-extract in render). */
20
+ export function storyboardHasStills(storyboard: Storyboard): boolean {
21
+ return storyboard.scenes.some((s) => s.actions.some((a) => a.op === "still"));
22
+ }
23
+
24
+ /** Reject names that aren't safe, single-segment file stems. */
25
+ function assertSafeName(name: string): void {
26
+ if (!/^[A-Za-z0-9._-]+$/.test(name) || name === "." || name === "..") {
27
+ throw new Error(
28
+ `still name ${JSON.stringify(name)} is not a valid file slug — use only ` +
29
+ `letters, digits, dot, dash, underscore (it becomes <name>.png).`
30
+ );
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Screenshot mode: extract each named `still` marker recorded in timeline.json
36
+ * as a PNG, pulled from the CLEAN take (the raw recording — no captions, zoom,
37
+ * or title cards burned in). Timeline time → take-video time is `tMs +
38
+ * leadInMs`, the same mapping compose uses to align focus/scene offsets. Needs
39
+ * only the recording + timeline (no API key, no storyboard) so it runs
40
+ * standalone against any existing take, exactly like re-composing. Duplicate
41
+ * still names are a hard error. Returns the written PNG paths.
42
+ */
43
+ export async function extractStills(
44
+ project: Project,
45
+ opts: StillsOptions = {}
46
+ ): Promise<string[]> {
47
+ if (!(await exists(project.timelinePath))) {
48
+ throw new Error(
49
+ `No timeline at ${project.timelinePath} — run \`aidemo record ${project.dir}\` first.`
50
+ );
51
+ }
52
+ const timeline = TimelineSchema.parse(await readJson(project.timelinePath));
53
+
54
+ // Collect markers in timeline order; reject duplicate names up-front so the
55
+ // extraction never silently overwrites one still with another.
56
+ const markers: StillMarker[] = [];
57
+ const seen = new Map<string, string>(); // name -> the scene that declared it
58
+ for (const scene of timeline.scenes) {
59
+ for (const ev of scene.stillEvents ?? []) {
60
+ assertSafeName(ev.name);
61
+ if (seen.has(ev.name)) {
62
+ throw new Error(
63
+ `duplicate still name ${JSON.stringify(ev.name)} (scenes ` +
64
+ `${seen.get(ev.name)} and ${scene.id}) — still names must be unique.`
65
+ );
66
+ }
67
+ seen.set(ev.name, scene.id);
68
+ markers.push({ sceneId: scene.id, name: ev.name, tMs: ev.tMs });
69
+ }
70
+ }
71
+
72
+ if (markers.length === 0) {
73
+ log("no still markers in timeline.json — nothing to extract");
74
+ return [];
75
+ }
76
+
77
+ const rawVideo = await project.resolveRawVideo();
78
+ if (!(await exists(rawVideo))) {
79
+ throw new Error(
80
+ `No raw recording at ${rawVideo} — run \`aidemo record ${project.dir}\` first.`
81
+ );
82
+ }
83
+ const { width, height } = await probeVideoDims(rawVideo);
84
+ const videoMs = await probeDurationMs(rawVideo);
85
+
86
+ const outDir = opts.outDir
87
+ ? isAbsolute(opts.outDir)
88
+ ? opts.outDir
89
+ : resolve(process.cwd(), opts.outDir)
90
+ : project.stillsDir;
91
+ await ensureDir(outDir);
92
+
93
+ step(`Extracting ${markers.length} still(s) (${width}x${height}, clean take)`);
94
+ const written: string[] = [];
95
+ for (const m of markers) {
96
+ // The same timeline→video mapping compose uses (rawT = tMs + leadInMs).
97
+ // Clamp just inside the recording so a marker at the very tail still yields
98
+ // a decodable frame rather than an ffmpeg seek past EOF.
99
+ const rawMs = m.tMs + timeline.leadInMs;
100
+ const seekMs = Math.max(0, Math.min(rawMs, Math.max(0, videoMs - 60)));
101
+ const out = resolve(outDir, `${m.name}.png`);
102
+ // -ss before -i is a fast, accurate seek (accurate_seek is on by default);
103
+ // -frames:v 1 grabs exactly one frame. No overlay/drawtext filters, so this
104
+ // stays portable across ffmpeg builds.
105
+ await runFfmpeg([
106
+ "-ss",
107
+ (seekMs / 1000).toFixed(3),
108
+ "-i",
109
+ rawVideo,
110
+ "-frames:v",
111
+ "1",
112
+ out,
113
+ ]);
114
+ written.push(out);
115
+ log(
116
+ `still "${m.name}" (scene ${m.sceneId}) @ ${Math.round(seekMs)}ms → ${out}`
117
+ );
118
+ }
119
+ ok(`stills → ${outDir} (${written.length} PNG${written.length === 1 ? "" : "s"})`);
120
+ return written;
121
+ }