demo-dev 0.0.1-alpha.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.
Files changed (41) hide show
  1. package/README.md +174 -0
  2. package/bin/demo-cli.js +26 -0
  3. package/bin/demo-dev.js +26 -0
  4. package/demo.dev.config.example.json +20 -0
  5. package/dist/index.d.ts +392 -0
  6. package/dist/index.js +2116 -0
  7. package/package.json +76 -0
  8. package/skills/demo-dev/SKILL.md +153 -0
  9. package/skills/demo-dev/references/configuration.md +102 -0
  10. package/skills/demo-dev/references/recipes.md +83 -0
  11. package/src/ai/provider.ts +254 -0
  12. package/src/auth/bootstrap.ts +72 -0
  13. package/src/browser/session.ts +43 -0
  14. package/src/capture/continuous-capture.ts +739 -0
  15. package/src/cli.ts +337 -0
  16. package/src/config/project.ts +183 -0
  17. package/src/github/comment.ts +134 -0
  18. package/src/index.ts +10 -0
  19. package/src/lib/data-uri.ts +21 -0
  20. package/src/lib/fs.ts +7 -0
  21. package/src/lib/git.ts +59 -0
  22. package/src/lib/media.ts +23 -0
  23. package/src/orchestrate.ts +166 -0
  24. package/src/planner/heuristic.ts +180 -0
  25. package/src/planner/index.ts +26 -0
  26. package/src/planner/llm.ts +85 -0
  27. package/src/planner/openai.ts +77 -0
  28. package/src/planner/prompt.ts +331 -0
  29. package/src/planner/refine.ts +155 -0
  30. package/src/planner/schema.ts +62 -0
  31. package/src/presentation/polish.ts +84 -0
  32. package/src/probe/page-probe.ts +225 -0
  33. package/src/render/browser-frame.ts +176 -0
  34. package/src/render/ffmpeg-compose.ts +779 -0
  35. package/src/render/visual-plan.ts +422 -0
  36. package/src/setup/doctor.ts +158 -0
  37. package/src/setup/init.ts +90 -0
  38. package/src/types.ts +105 -0
  39. package/src/voice/script.ts +42 -0
  40. package/src/voice/tts.ts +286 -0
  41. package/tsconfig.json +16 -0
@@ -0,0 +1,225 @@
1
+ import { chromium, type Page } from "playwright";
2
+ import { getContextOptionsWithSession, resolveSessionConfig, persistSessionState } from "../browser/session.js";
3
+ import type { ActionTarget, DemoPlan, PageProbe, ProbeSnapshot, SceneAction } from "../types.js";
4
+
5
+ const PROBE_SCRIPT = String.raw`(() => {
6
+ function normalizeText(value) {
7
+ return (value || "").replace(/\s+/g, " ").trim().slice(0, 140);
8
+ }
9
+
10
+ function isVisible(element) {
11
+ const rect = element.getBoundingClientRect();
12
+ const style = window.getComputedStyle(element);
13
+ return (
14
+ rect.width > 0 &&
15
+ rect.height > 0 &&
16
+ style.visibility !== "hidden" &&
17
+ style.display !== "none" &&
18
+ style.opacity !== "0"
19
+ );
20
+ }
21
+
22
+ function deriveRole(element) {
23
+ const explicitRole = element.getAttribute("role");
24
+ if (explicitRole) return explicitRole;
25
+ const tag = element.tagName.toLowerCase();
26
+ if (tag === "a") return "link";
27
+ if (tag === "button") return "button";
28
+ if (tag === "select") return "combobox";
29
+ if (tag === "textarea") return "textbox";
30
+ if (tag === "input") {
31
+ const type = (element.getAttribute("type") || "text").toLowerCase();
32
+ if (["submit", "button"].includes(type)) return "button";
33
+ if (type === "checkbox") return "checkbox";
34
+ if (type === "radio") return "radio";
35
+ return "textbox";
36
+ }
37
+ return tag;
38
+ }
39
+
40
+ function deriveName(element) {
41
+ const labelledBy = element.getAttribute("aria-labelledby");
42
+ if (labelledBy) {
43
+ const labelNode = document.getElementById(labelledBy);
44
+ const labelText = normalizeText(labelNode && labelNode.textContent);
45
+ if (labelText) return labelText;
46
+ }
47
+
48
+ const ariaLabel = normalizeText(element.getAttribute("aria-label"));
49
+ if (ariaLabel) return ariaLabel;
50
+
51
+ const placeholder = normalizeText(element.placeholder);
52
+ if (placeholder) return placeholder;
53
+
54
+ const text = normalizeText(element.textContent);
55
+ if (text) return text;
56
+
57
+ const value = normalizeText(element.value);
58
+ if (value) return value;
59
+
60
+ return deriveRole(element);
61
+ }
62
+
63
+ function getLabel(element) {
64
+ const id = element.getAttribute("id");
65
+ if (!id) return undefined;
66
+ const label = document.querySelector('label[for="' + id + '"]');
67
+ return normalizeText(label && label.textContent) || undefined;
68
+ }
69
+
70
+ const selectors = [
71
+ "button",
72
+ "a[href]",
73
+ "input",
74
+ "select",
75
+ "textarea",
76
+ "[role=button]",
77
+ "[role=link]",
78
+ "[role=textbox]",
79
+ "[role=tab]",
80
+ "[data-testid]"
81
+ ].join(",");
82
+
83
+ const interactiveElements = Array.from(document.querySelectorAll(selectors))
84
+ .filter(isVisible)
85
+ .slice(0, 30)
86
+ .map((element) => ({
87
+ tag: element.tagName.toLowerCase(),
88
+ role: deriveRole(element),
89
+ name: deriveName(element),
90
+ text: normalizeText(element.textContent) || undefined,
91
+ label: getLabel(element),
92
+ placeholder: normalizeText(element.placeholder) || undefined,
93
+ type: element.type || undefined,
94
+ href: element.href || undefined,
95
+ testId: element.getAttribute("data-testid") || undefined,
96
+ }));
97
+
98
+ const headings = Array.from(document.querySelectorAll("h1,h2,h3"))
99
+ .map((heading) => normalizeText(heading.textContent))
100
+ .filter(Boolean)
101
+ .slice(0, 10);
102
+
103
+ const textPreview = normalizeText(document.body && document.body.innerText).slice(0, 500);
104
+
105
+ return { interactiveElements, headings, textPreview };
106
+ })()`;
107
+
108
+ const ACTIONABLE_NAME_RE = /get started|start|continue|next|learn more|try demo|demo|explore|open|view|create|new|save|submit|search/i;
109
+ const NEGATIVE_NAME_RE = /delete|remove|logout|log out|sign out|cancel/i;
110
+
111
+ const collectSnapshot = async (page: Page): Promise<ProbeSnapshot> => {
112
+ const collected = (await page.evaluate(PROBE_SCRIPT)) as {
113
+ headings: string[];
114
+ textPreview: string;
115
+ interactiveElements: ProbeSnapshot["interactiveElements"];
116
+ };
117
+
118
+ return {
119
+ resolvedUrl: page.url(),
120
+ pageTitle: await page.title().catch(() => undefined),
121
+ headings: collected.headings,
122
+ textPreview: collected.textPreview,
123
+ interactiveElements: collected.interactiveElements,
124
+ };
125
+ };
126
+
127
+ const deriveFollowUpAction = (snapshot: ProbeSnapshot): SceneAction | undefined => {
128
+ const candidate = snapshot.interactiveElements.find(
129
+ (element) =>
130
+ ["button", "link", "tab"].includes(element.role) &&
131
+ ACTIONABLE_NAME_RE.test(element.name) &&
132
+ !NEGATIVE_NAME_RE.test(element.name),
133
+ );
134
+
135
+ if (!candidate) return undefined;
136
+
137
+ const target: ActionTarget = candidate.testId
138
+ ? { strategy: "testId", value: candidate.testId }
139
+ : { strategy: "role", role: candidate.role, name: candidate.name, exact: false };
140
+
141
+ return { type: "click", target };
142
+ };
143
+
144
+ export const probePlanScenes = async (
145
+ plan: DemoPlan,
146
+ options: { baseUrl: string; outputDir?: string },
147
+ ): Promise<PageProbe[]> => {
148
+ const browser = await chromium.launch({ headless: true });
149
+ const session = resolveSessionConfig(options.outputDir ?? "artifacts");
150
+
151
+ try {
152
+ const probes: PageProbe[] = [];
153
+
154
+ for (const scene of plan.scenes) {
155
+ const context = await browser.newContext(
156
+ await getContextOptionsWithSession({ viewport: scene.viewport }, session),
157
+ );
158
+ const page = await context.newPage();
159
+ const requestedUrl = new URL(scene.url, options.baseUrl).toString();
160
+
161
+ try {
162
+ await page.goto(requestedUrl, { waitUntil: "networkidle" });
163
+ const initial = await collectSnapshot(page);
164
+ const followUpAction = deriveFollowUpAction(initial);
165
+
166
+ let followUp: ProbeSnapshot | undefined;
167
+ if (followUpAction?.type === "click") {
168
+ try {
169
+ let locator;
170
+ if (followUpAction.target.strategy === "testId") {
171
+ locator = page.getByTestId(followUpAction.target.value);
172
+ } else if (followUpAction.target.strategy === "role") {
173
+ locator = page.getByRole(followUpAction.target.role as never, {
174
+ name: followUpAction.target.name,
175
+ exact: followUpAction.target.exact,
176
+ });
177
+ } else {
178
+ locator = page.locator("body");
179
+ }
180
+
181
+ await locator.first().click();
182
+ await page.waitForLoadState("networkidle").catch(() => undefined);
183
+ followUp = await collectSnapshot(page);
184
+ } catch (error) {
185
+ followUp = {
186
+ headings: [],
187
+ textPreview: "",
188
+ interactiveElements: [],
189
+ error: error instanceof Error ? error.message : String(error),
190
+ };
191
+ }
192
+ }
193
+
194
+ await persistSessionState(page, session);
195
+
196
+ probes.push({
197
+ sceneId: scene.id,
198
+ sceneTitle: scene.title,
199
+ requestedUrl,
200
+ initial,
201
+ followUpAction,
202
+ followUp,
203
+ });
204
+ } catch (error) {
205
+ probes.push({
206
+ sceneId: scene.id,
207
+ sceneTitle: scene.title,
208
+ requestedUrl,
209
+ initial: {
210
+ headings: [],
211
+ textPreview: "",
212
+ interactiveElements: [],
213
+ error: error instanceof Error ? error.message : String(error),
214
+ },
215
+ });
216
+ }
217
+
218
+ await context.close();
219
+ }
220
+
221
+ return probes;
222
+ } finally {
223
+ await browser.close();
224
+ }
225
+ };
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Screen Studio–style browser frame compositing.
3
+ *
4
+ * Wraps raw screen recordings in a macOS browser window shell with:
5
+ * - Gradient background (configurable colors)
6
+ * - macOS-style browser chrome (traffic lights, URL bar)
7
+ * - Rounded corners + drop shadow
8
+ * - Padding/margin around the window
9
+ *
10
+ * Implemented as an FFmpeg filter chain that composites a pre-rendered
11
+ * browser chrome PNG on top of the recording, positioned within a
12
+ * gradient background canvas.
13
+ */
14
+
15
+ import { execFile } from "node:child_process";
16
+ import { writeFile, mkdir } from "node:fs/promises";
17
+ import { join, dirname } from "node:path";
18
+ import { promisify } from "node:util";
19
+ import { chromium } from "playwright";
20
+
21
+ const execFileAsync = promisify(execFile);
22
+
23
+ export interface BrowserFrameOptions {
24
+ /** Gradient start color (top-left). Default: "#f97316" (orange) */
25
+ gradientFrom?: string;
26
+ /** Gradient end color (bottom-right). Default: "#a855f7" (purple) */
27
+ gradientTo?: string;
28
+ /** Padding around the browser window in pixels. Default: 48 */
29
+ padding?: number;
30
+ /** URL to display in the address bar. Default: inferred from capture */
31
+ displayUrl?: string;
32
+ }
33
+
34
+ const DEFAULT_GRADIENT_FROM = "#f97316";
35
+ const DEFAULT_GRADIENT_TO = "#a855f7";
36
+ const DEFAULT_PADDING = 48;
37
+ const CHROME_HEIGHT = 52;
38
+
39
+ /**
40
+ * Render a browser chrome bar + gradient background as a PNG using Playwright.
41
+ * This gives us pixel-perfect rendering of the macOS-style window chrome.
42
+ */
43
+ const renderFrameTemplate = async (
44
+ outputPath: string,
45
+ contentWidth: number,
46
+ contentHeight: number,
47
+ options: BrowserFrameOptions,
48
+ ): Promise<{ canvasWidth: number; canvasHeight: number; contentOffsetX: number; contentOffsetY: number }> => {
49
+ const padding = options.padding ?? DEFAULT_PADDING;
50
+ const gradFrom = options.gradientFrom ?? DEFAULT_GRADIENT_FROM;
51
+ const gradTo = options.gradientTo ?? DEFAULT_GRADIENT_TO;
52
+ const displayUrl = options.displayUrl ?? "app.example.com";
53
+
54
+ const windowWidth = contentWidth;
55
+ const windowHeight = contentHeight + CHROME_HEIGHT;
56
+ const canvasWidth = windowWidth + padding * 2;
57
+ const canvasHeight = windowHeight + padding * 2;
58
+ const contentOffsetX = padding;
59
+ const contentOffsetY = padding + CHROME_HEIGHT;
60
+
61
+ const html = `<!DOCTYPE html>
62
+ <html><head><style>
63
+ * { margin: 0; padding: 0; box-sizing: border-box; }
64
+ body {
65
+ width: ${canvasWidth}px; height: ${canvasHeight}px;
66
+ background: linear-gradient(135deg, ${gradFrom} 0%, ${gradTo} 100%);
67
+ display: flex; align-items: center; justify-content: center;
68
+ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif;
69
+ }
70
+ .window {
71
+ width: ${windowWidth}px; height: ${windowHeight}px;
72
+ border-radius: 12px;
73
+ overflow: hidden;
74
+ box-shadow: 0 25px 60px rgba(0,0,0,0.35), 0 8px 20px rgba(0,0,0,0.2);
75
+ }
76
+ .chrome {
77
+ height: ${CHROME_HEIGHT}px;
78
+ background: linear-gradient(180deg, #e8e6e3 0%, #d5d3d0 100%);
79
+ display: flex; align-items: center; padding: 0 16px;
80
+ border-bottom: 1px solid #c4c2bf;
81
+ }
82
+ .traffic-lights { display: flex; gap: 8px; margin-right: 16px; }
83
+ .dot { width: 12px; height: 12px; border-radius: 50%; }
84
+ .dot-red { background: #ff5f57; border: 1px solid #e0443e; }
85
+ .dot-yellow { background: #febc2e; border: 1px solid #d4a020; }
86
+ .dot-green { background: #28c840; border: 1px solid #1ea633; }
87
+ .nav-buttons { display: flex; gap: 6px; margin-right: 12px; }
88
+ .nav-btn { width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; color: #888; font-size: 16px; }
89
+ .url-bar {
90
+ flex: 1; height: 30px; border-radius: 6px;
91
+ background: rgba(255,255,255,0.75); border: 1px solid rgba(0,0,0,0.1);
92
+ display: flex; align-items: center; justify-content: center;
93
+ padding: 0 12px; font-size: 13px; color: #555;
94
+ }
95
+ .lock { margin-right: 5px; font-size: 11px; color: #888; }
96
+ .content { width: ${contentWidth}px; height: ${contentHeight}px; background: #fff; }
97
+ </style></head><body>
98
+ <div class="window">
99
+ <div class="chrome">
100
+ <div class="traffic-lights">
101
+ <div class="dot dot-red"></div>
102
+ <div class="dot dot-yellow"></div>
103
+ <div class="dot dot-green"></div>
104
+ </div>
105
+ <div class="nav-buttons">
106
+ <div class="nav-btn">&larr;</div>
107
+ <div class="nav-btn">&rarr;</div>
108
+ </div>
109
+ <div class="url-bar">
110
+ <span class="lock">&#x1f512;</span>
111
+ ${escapeHtml(displayUrl)}
112
+ </div>
113
+ </div>
114
+ <div class="content"></div>
115
+ </div>
116
+ </body></html>`;
117
+
118
+ await mkdir(dirname(outputPath), { recursive: true });
119
+
120
+ const browser = await chromium.launch({ headless: true });
121
+ const page = await browser.newPage({ viewport: { width: canvasWidth, height: canvasHeight } });
122
+ await page.setContent(html, { waitUntil: "networkidle" });
123
+ await page.screenshot({ path: outputPath, omitBackground: false });
124
+ await browser.close();
125
+
126
+ return { canvasWidth, canvasHeight, contentOffsetX, contentOffsetY };
127
+ };
128
+
129
+ const escapeHtml = (text: string) =>
130
+ text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
131
+
132
+ /**
133
+ * Apply browser frame compositing to a video.
134
+ * Renders the frame template once as PNG, then composites every frame of
135
+ * the input video on top using FFmpeg overlay.
136
+ */
137
+ export const applyBrowserFrame = async (
138
+ inputPath: string,
139
+ outputPath: string,
140
+ contentWidth: number,
141
+ contentHeight: number,
142
+ options: BrowserFrameOptions,
143
+ crf: number,
144
+ preset: string,
145
+ ): Promise<{ outputWidth: number; outputHeight: number }> => {
146
+ const tempDir = join(dirname(outputPath), ".frame-temp");
147
+ await mkdir(tempDir, { recursive: true });
148
+
149
+ const framePngPath = join(tempDir, "browser-frame.png");
150
+ const { canvasWidth, canvasHeight, contentOffsetX, contentOffsetY } = await renderFrameTemplate(
151
+ framePngPath,
152
+ contentWidth,
153
+ contentHeight,
154
+ options,
155
+ );
156
+
157
+ // FFmpeg: overlay video on top of the frame PNG at the content position
158
+ await execFileAsync("ffmpeg", [
159
+ "-i", inputPath,
160
+ "-i", framePngPath,
161
+ "-filter_complex",
162
+ `[0:v]scale=${contentWidth}:${contentHeight}:flags=lanczos[vid];` +
163
+ `[1:v]loop=loop=-1:size=1:start=0,setpts=N/FRAME_RATE/TB[frame];` +
164
+ `[frame][vid]overlay=${contentOffsetX}:${contentOffsetY}:shortest=1[out]`,
165
+ "-map", "[out]",
166
+ "-c:v", "libx264",
167
+ "-preset", preset,
168
+ "-crf", String(crf),
169
+ "-pix_fmt", "yuv420p",
170
+ "-an",
171
+ "-y",
172
+ outputPath,
173
+ ]);
174
+
175
+ return { outputWidth: canvasWidth, outputHeight: canvasHeight };
176
+ };