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,331 @@
1
+ /**
2
+ * Prompt-driven demo planner.
3
+ *
4
+ * Instead of starting from a git diff, this planner takes a natural language
5
+ * prompt ("show the Magic Inbox, filter by positive replies, open a thread")
6
+ * and generates a DemoPlan by:
7
+ *
8
+ * 1. Launching Playwright to explore the target site
9
+ * 2. Taking screenshots + collecting interactive elements from key pages
10
+ * 3. Sending the screenshots + element inventory + user prompt to an LLM
11
+ * 4. Parsing the LLM response into a validated DemoPlan
12
+ *
13
+ * This gives users a zero-config, one-shot way to create demos.
14
+ */
15
+
16
+ import { mkdir } from "node:fs/promises";
17
+ import { join } from "node:path";
18
+ import { chromium, type Page } from "playwright";
19
+ import {
20
+ getContextOptionsWithSession,
21
+ resolveSessionConfig,
22
+ } from "../browser/session.js";
23
+ import type { ProjectConfig } from "../config/project.js";
24
+ import { summarizeProjectHints } from "../config/project.js";
25
+ import { requestAiJson } from "../ai/provider.js";
26
+ import type { DemoPlan } from "../types.js";
27
+ import { demoPlanSchema } from "./schema.js";
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Page exploration
31
+ // ---------------------------------------------------------------------------
32
+
33
+ interface PageSnapshot {
34
+ url: string;
35
+ title: string;
36
+ headings: string[];
37
+ textPreview: string;
38
+ interactiveElements: Array<{
39
+ tag: string;
40
+ role: string;
41
+ name: string;
42
+ text?: string;
43
+ label?: string;
44
+ placeholder?: string;
45
+ href?: string;
46
+ testId?: string;
47
+ }>;
48
+ navLinks: Array<{ text: string; href: string }>;
49
+ }
50
+
51
+ const EXPLORE_SCRIPT = `(() => {
52
+ function norm(v) { return (v||"").replace(/\\s+/g," ").trim().slice(0,140); }
53
+ function vis(el) {
54
+ const r = el.getBoundingClientRect(), s = getComputedStyle(el);
55
+ return r.width>0 && r.height>0 && s.visibility!=="hidden" && s.display!=="none" && s.opacity!=="0";
56
+ }
57
+ function role(el) {
58
+ const r = el.getAttribute("role"); if(r) return r;
59
+ const t = el.tagName.toLowerCase();
60
+ if(t==="a") return "link"; if(t==="button") return "button";
61
+ if(t==="select") return "combobox"; if(t==="textarea") return "textbox";
62
+ if(t==="input") { const tp=(el.type||"text").toLowerCase(); return ["submit","button"].includes(tp)?"button":"textbox"; }
63
+ return t;
64
+ }
65
+ function name(el) {
66
+ return norm(el.getAttribute("aria-label")) || norm(el.placeholder) || norm(el.textContent) || role(el);
67
+ }
68
+ const sels = "button,a[href],input,select,textarea,[role=button],[role=link],[role=tab],[role=menuitem],[data-testid]";
69
+ const elems = Array.from(document.querySelectorAll(sels)).filter(vis).slice(0,50).map(el => ({
70
+ tag: el.tagName.toLowerCase(), role: role(el), name: name(el),
71
+ text: norm(el.textContent)||undefined, label: undefined,
72
+ placeholder: norm(el.placeholder)||undefined,
73
+ href: el.href||undefined, testId: el.getAttribute("data-testid")||undefined,
74
+ }));
75
+ const headings = Array.from(document.querySelectorAll("h1,h2,h3")).map(h=>norm(h.textContent)).filter(Boolean).slice(0,10);
76
+ const text = norm(document.body?.innerText).slice(0,800);
77
+ const navLinks = Array.from(document.querySelectorAll("nav a[href], aside a[href], [role=navigation] a[href]"))
78
+ .filter(vis).slice(0,20).map(a => ({ text: norm(a.textContent), href: a.href }));
79
+ return { headings, textPreview: text, interactiveElements: elems, navLinks };
80
+ })()`;
81
+
82
+ const collectPageSnapshot = async (page: Page): Promise<PageSnapshot> => {
83
+ const data = await page.evaluate(EXPLORE_SCRIPT) as Omit<PageSnapshot, "url" | "title">;
84
+ return {
85
+ url: page.url(),
86
+ title: await page.title().catch(() => ""),
87
+ ...data,
88
+ };
89
+ };
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Site exploration: visit landing + follow key nav links
93
+ // ---------------------------------------------------------------------------
94
+
95
+ interface SiteExploration {
96
+ pages: PageSnapshot[];
97
+ screenshotPaths: string[];
98
+ }
99
+
100
+ const exploreSite = async (
101
+ baseUrl: string,
102
+ outputDir: string,
103
+ projectConfig?: ProjectConfig,
104
+ ): Promise<SiteExploration> => {
105
+ const screenshotDir = join(outputDir, "exploration");
106
+ await mkdir(screenshotDir, { recursive: true });
107
+
108
+ const session = resolveSessionConfig(outputDir);
109
+ const browser = await chromium.launch({ headless: true });
110
+ const context = await browser.newContext(
111
+ await getContextOptionsWithSession(
112
+ { viewport: { width: 1440, height: 900 } },
113
+ session,
114
+ ),
115
+ );
116
+ const page = await context.newPage();
117
+
118
+ const pages: PageSnapshot[] = [];
119
+ const screenshotPaths: string[] = [];
120
+ const visited = new Set<string>();
121
+
122
+ // Determine which URLs to explore
123
+ const startUrls = ["/"];
124
+ if (projectConfig?.preferredRoutes) {
125
+ startUrls.push(...projectConfig.preferredRoutes);
126
+ }
127
+
128
+ try {
129
+ for (const route of startUrls) {
130
+ const fullUrl = new URL(route, baseUrl).toString();
131
+ const normalized = new URL(fullUrl).pathname;
132
+ if (visited.has(normalized)) continue;
133
+ visited.add(normalized);
134
+
135
+ try {
136
+ await page.goto(fullUrl, { waitUntil: "networkidle", timeout: 20000 });
137
+ } catch {
138
+ try {
139
+ await page.goto(fullUrl, { waitUntil: "domcontentloaded", timeout: 20000 });
140
+ await page.waitForTimeout(1500);
141
+ } catch {
142
+ continue;
143
+ }
144
+ }
145
+
146
+ const snapshot = await collectPageSnapshot(page);
147
+ pages.push(snapshot);
148
+
149
+ const ssPath = join(screenshotDir, `page-${pages.length}.png`);
150
+ await page.screenshot({ path: ssPath });
151
+ screenshotPaths.push(ssPath);
152
+
153
+ // Follow nav links we haven't visited (up to 4 total pages)
154
+ if (pages.length >= 4) break;
155
+
156
+ for (const link of snapshot.navLinks) {
157
+ if (pages.length >= 4) break;
158
+ try {
159
+ const linkPath = new URL(link.href).pathname;
160
+ if (visited.has(linkPath)) continue;
161
+ if (!link.href.startsWith(baseUrl)) continue;
162
+ visited.add(linkPath);
163
+
164
+ await page.goto(link.href, { waitUntil: "networkidle", timeout: 15000 });
165
+ const navSnapshot = await collectPageSnapshot(page);
166
+ pages.push(navSnapshot);
167
+
168
+ const navSsPath = join(screenshotDir, `page-${pages.length}.png`);
169
+ await page.screenshot({ path: navSsPath });
170
+ screenshotPaths.push(navSsPath);
171
+ } catch {
172
+ continue;
173
+ }
174
+ }
175
+ }
176
+ } finally {
177
+ await context.close();
178
+ await browser.close();
179
+ }
180
+
181
+ return { pages, screenshotPaths };
182
+ };
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // LLM prompt construction
186
+ // ---------------------------------------------------------------------------
187
+
188
+ const buildPromptPlannerPrompt = (
189
+ userPrompt: string,
190
+ exploration: SiteExploration,
191
+ projectConfig?: ProjectConfig,
192
+ ): string => {
193
+ const pageDescriptions = exploration.pages.map((p, i) => {
194
+ const elements = p.interactiveElements
195
+ .map((el) => {
196
+ const parts = [`${el.role}: "${el.name}"`];
197
+ if (el.href) parts.push(`→ ${el.href}`);
198
+ if (el.placeholder) parts.push(`(placeholder: "${el.placeholder}")`);
199
+ if (el.testId) parts.push(`[data-testid="${el.testId}"]`);
200
+ return ` ${parts.join(" ")}`;
201
+ })
202
+ .join("\n");
203
+
204
+ return [
205
+ `Page ${i + 1}: ${p.title}`,
206
+ ` URL: ${p.url}`,
207
+ ` Headings: ${p.headings.join(" | ")}`,
208
+ ` Content preview: ${p.textPreview.slice(0, 300)}`,
209
+ ` Interactive elements (${p.interactiveElements.length}):`,
210
+ elements,
211
+ ].join("\n");
212
+ });
213
+
214
+ return [
215
+ "You are a product demo director.",
216
+ "A user wants to create a demo video of their web app with a single prompt.",
217
+ "",
218
+ "USER PROMPT:",
219
+ `"${userPrompt}"`,
220
+ "",
221
+ "SITE EXPLORATION:",
222
+ "I visited the app and found these pages with their interactive elements:",
223
+ "",
224
+ ...pageDescriptions,
225
+ "",
226
+ "INSTRUCTIONS:",
227
+ "1. Create 3-6 scenes that tell the story the user described.",
228
+ "2. Each scene should have specific, executable actions (navigate, click, hover, fill, scroll, wait, press).",
229
+ "3. Use REAL element selectors from the exploration above. Prefer strategy: 'text' or 'role' with the exact name/text shown.",
230
+ "4. Write narration that sounds like a human giving a product tour — conversational, not robotic.",
231
+ "5. All URLs must be relative paths (e.g., /inbox not https://app.example.com/inbox).",
232
+ "6. Keep durationMs between 4000 and 9000 per scene.",
233
+ "7. Start with a navigate action in the first scene.",
234
+ "8. Add wait actions (300-800ms) between interactions for pacing.",
235
+ "9. Use scroll actions to reveal below-the-fold content when relevant.",
236
+ "",
237
+ "ACTION TARGET STRATEGIES (use these exact formats):",
238
+ ' { "strategy": "text", "value": "Button Text", "exact": false } — match by visible text',
239
+ ' { "strategy": "role", "role": "button", "name": "Submit" } — match by ARIA role + name',
240
+ ' { "strategy": "css", "value": ".my-class" } — CSS selector',
241
+ ' { "strategy": "placeholder", "value": "Search..." } — match by placeholder',
242
+ ' { "strategy": "testId", "value": "my-test-id" } — match by data-testid',
243
+ "",
244
+ "Project hints:",
245
+ JSON.stringify(summarizeProjectHints(projectConfig ?? {}), null, 2),
246
+ "",
247
+ "EXACT JSON SCHEMA (follow this structure precisely):",
248
+ JSON.stringify({
249
+ title: "Demo Title",
250
+ summary: "One sentence summary",
251
+ branch: "prompt",
252
+ generatedAt: "2026-01-01T00:00:00.000Z",
253
+ scenes: [{
254
+ id: "scene-1",
255
+ title: "Scene Title",
256
+ goal: "What this scene demonstrates",
257
+ url: "/relative-path",
258
+ viewport: { width: 1440, height: 900 },
259
+ actions: [
260
+ { type: "navigate", url: "/relative-path" },
261
+ { type: "wait", ms: 500 },
262
+ { type: "click", target: { strategy: "text", value: "Button Text", exact: false } },
263
+ { type: "hover", target: { strategy: "role", role: "button", name: "Name" } },
264
+ { type: "scroll", y: 200 },
265
+ { type: "fill", target: { strategy: "placeholder", value: "Search..." }, value: "typed text" },
266
+ ],
267
+ narration: "Conversational narration for this scene.",
268
+ caption: "Short caption",
269
+ durationMs: 6000,
270
+ evidenceHints: [],
271
+ }],
272
+ }, null, 2),
273
+ "",
274
+ "Output a strict JSON DemoPlan following the schema above. No markdown, no explanation.",
275
+ ].join("\n");
276
+ };
277
+
278
+ // ---------------------------------------------------------------------------
279
+ // Public API
280
+ // ---------------------------------------------------------------------------
281
+
282
+ export const buildPromptPlan = async (options: {
283
+ prompt: string;
284
+ baseUrl: string;
285
+ outputDir: string;
286
+ projectConfig?: ProjectConfig;
287
+ }): Promise<DemoPlan> => {
288
+ // Step 1: Explore the site
289
+ const exploration = await exploreSite(
290
+ options.baseUrl,
291
+ options.outputDir,
292
+ options.projectConfig,
293
+ );
294
+
295
+ if (exploration.pages.length === 0) {
296
+ throw new Error("Could not load any pages from " + options.baseUrl);
297
+ }
298
+
299
+ // Step 2: Ask LLM to generate a plan
300
+ const prompt = buildPromptPlannerPrompt(
301
+ options.prompt,
302
+ exploration,
303
+ options.projectConfig,
304
+ );
305
+
306
+ const plan = await requestAiJson({
307
+ system:
308
+ "You are a product demo director. You create demo plans from natural language prompts. " +
309
+ "You have been given a site exploration with real page content and interactive elements. " +
310
+ "Use ONLY elements that actually exist on the pages. Output strict JSON only.",
311
+ prompt,
312
+ schema: demoPlanSchema,
313
+ temperature: 0.3,
314
+ });
315
+
316
+ if (!plan) {
317
+ throw new Error(
318
+ "AI provider could not generate a plan. Set DEMO_OPENAI_API_KEY or use a local AI provider.",
319
+ );
320
+ }
321
+
322
+ return {
323
+ ...plan,
324
+ branch: "prompt",
325
+ generatedAt: new Date().toISOString(),
326
+ scenes: plan.scenes.map((scene) => ({
327
+ ...scene,
328
+ evidenceHints: scene.evidenceHints ?? [],
329
+ })),
330
+ };
331
+ };
@@ -0,0 +1,155 @@
1
+ import type { DemoPlan, DemoScene, PageProbe, ProbeSnapshot, SceneAction } from "../types.js";
2
+ import { requestAiJson } from "../ai/provider.js";
3
+ import { demoPlanSchema } from "./schema.js";
4
+
5
+ const ACTIONABLE_NAME_RE =
6
+ /get started|start|continue|next|learn more|try demo|demo|explore|open|view|create|new|save|submit|search/i;
7
+ const NEGATIVE_NAME_RE = /delete|remove|logout|log out|sign out|cancel/i;
8
+
9
+ const snapshotFor = (probe: PageProbe): ProbeSnapshot => (probe.followUp && !probe.followUp.error ? probe.followUp : probe.initial);
10
+
11
+ const normalizeTargetAction = (scene: DemoScene, probe: PageProbe): SceneAction[] => {
12
+ const initial = probe.initial;
13
+ const active = snapshotFor(probe);
14
+ const actions: SceneAction[] = [{ type: "navigate", url: scene.url }];
15
+
16
+ if (initial.headings[0]) {
17
+ actions.push({ type: "waitForText", value: initial.headings[0], timeoutMs: 10000 });
18
+ } else {
19
+ actions.push({ type: "wait", ms: 1200 });
20
+ }
21
+
22
+ const searchField = initial.interactiveElements.find(
23
+ (element) =>
24
+ element.role === "textbox" &&
25
+ /search/i.test([element.name, element.label, element.placeholder].filter(Boolean).join(" ")),
26
+ );
27
+
28
+ if (searchField?.placeholder) {
29
+ actions.push({
30
+ type: "fill",
31
+ target: { strategy: "placeholder", value: searchField.placeholder },
32
+ value: "demo",
33
+ });
34
+ actions.push({ type: "press", key: "Enter" });
35
+ actions.push({ type: "wait", ms: 1200 });
36
+ if (active !== initial && active.headings[0] && active.headings[0] !== initial.headings[0]) {
37
+ actions.push({ type: "waitForText", value: active.headings[0], timeoutMs: 10000 });
38
+ }
39
+ return actions;
40
+ }
41
+
42
+ const primaryAction = initial.interactiveElements.find(
43
+ (element) =>
44
+ ["button", "link", "tab"].includes(element.role) &&
45
+ ACTIONABLE_NAME_RE.test(element.name) &&
46
+ !NEGATIVE_NAME_RE.test(element.name),
47
+ );
48
+
49
+ if (primaryAction) {
50
+ actions.push({
51
+ type: "click",
52
+ target: primaryAction.testId
53
+ ? { strategy: "testId", value: primaryAction.testId }
54
+ : { strategy: "role", role: primaryAction.role, name: primaryAction.name, exact: false },
55
+ });
56
+
57
+ if (probe.followUp?.resolvedUrl && probe.followUp.resolvedUrl !== initial.resolvedUrl) {
58
+ actions.push({ type: "waitForUrl", value: probe.followUp.resolvedUrl, timeoutMs: 10000 });
59
+ } else {
60
+ actions.push({ type: "wait", ms: 1200 });
61
+ }
62
+
63
+ if (probe.followUp?.headings[0] && probe.followUp.headings[0] !== initial.headings[0]) {
64
+ actions.push({ type: "waitForText", value: probe.followUp.headings[0], timeoutMs: 10000 });
65
+ }
66
+
67
+ return actions;
68
+ }
69
+
70
+ actions.push({ type: "scroll", y: 360 });
71
+ actions.push({ type: "wait", ms: 400 });
72
+ return actions;
73
+ };
74
+
75
+ const allowHeuristicFallback = process.env.DEMO_AI_MANDATORY === "false";
76
+
77
+ const buildRefinementPrompt = (context: {
78
+ initialPlan: DemoPlan;
79
+ probes: PageProbe[];
80
+ }) => {
81
+ return [
82
+ "You are a product demo director. You already have an initial scene plan and real browser probe output.",
83
+ "Task: revise the initial plan into a more executable browser plan.",
84
+ "Rules:",
85
+ "1. Only use headings, buttons, links, inputs, and placeholders that actually appear in the probes.",
86
+ "2. If a followUp probe exists, prefer turning it into a multi-step flow such as click -> waitForUrl -> waitForText.",
87
+ "3. Do not invent labels, buttons, or text that were not observed.",
88
+ "4. If a probe failed, keep the scene as a page-level presentation.",
89
+ "5. Prefer stable flows: navigate -> waitForText -> click/fill -> waitForUrl/waitForText.",
90
+ "6. If no reliable element exists, keep the scene simple instead of forcing a complex form flow.",
91
+ "7. Return full JSON matching the original plan shape.",
92
+ "",
93
+ "Initial plan:",
94
+ JSON.stringify(context.initialPlan, null, 2),
95
+ "",
96
+ "Probe results:",
97
+ JSON.stringify(context.probes, null, 2),
98
+ ].join("\n");
99
+ };
100
+
101
+ export const refineDemoPlan = async (options: {
102
+ initialPlan: DemoPlan;
103
+ probes: PageProbe[];
104
+ }): Promise<DemoPlan> => {
105
+ try {
106
+ const refined = await requestAiJson({
107
+ system:
108
+ "You refine browser demo plans using only observed page probes. Prefer concise multi-step flows when a follow-up page state was observed. Keep outputs executable and JSON-only.",
109
+ prompt: buildRefinementPrompt(options),
110
+ schema: demoPlanSchema,
111
+ temperature: 0.2,
112
+ });
113
+
114
+ if (refined) {
115
+ return {
116
+ ...refined,
117
+ branch: options.initialPlan.branch,
118
+ generatedAt: new Date().toISOString(),
119
+ scenes: refined.scenes.map((scene) => ({
120
+ ...scene,
121
+ evidenceHints: scene.evidenceHints ?? [],
122
+ })),
123
+ };
124
+ }
125
+ } catch (error) {
126
+ if (!allowHeuristicFallback) {
127
+ throw error;
128
+ }
129
+ console.warn("LLM refinement failed, fallback to heuristic refinement", error);
130
+ }
131
+
132
+ if (!allowHeuristicFallback) {
133
+ throw new Error("AI provider did not return a refined plan. Set DEMO_AI_MANDATORY=false to allow heuristic fallback.");
134
+ }
135
+
136
+ const probeBySceneId = new Map(options.probes.map((probe) => [probe.sceneId, probe]));
137
+
138
+ return {
139
+ ...options.initialPlan,
140
+ summary: `${options.initialPlan.summary} Refined using real page probes.`,
141
+ generatedAt: new Date().toISOString(),
142
+ scenes: options.initialPlan.scenes.map((scene) => {
143
+ const probe = probeBySceneId.get(scene.id);
144
+ if (!probe || probe.initial.error) return scene;
145
+
146
+ const active = snapshotFor(probe);
147
+
148
+ return {
149
+ ...scene,
150
+ caption: active.pageTitle ? `${scene.title} · ${active.pageTitle}` : scene.caption,
151
+ actions: normalizeTargetAction(scene, probe),
152
+ };
153
+ }),
154
+ };
155
+ };
@@ -0,0 +1,62 @@
1
+ import { z } from "zod";
2
+
3
+ const actionTargetSchema = z.discriminatedUnion("strategy", [
4
+ z.object({ strategy: z.literal("label"), value: z.string(), exact: z.boolean().optional() }),
5
+ z.object({ strategy: z.literal("text"), value: z.string(), exact: z.boolean().optional() }),
6
+ z.object({ strategy: z.literal("placeholder"), value: z.string(), exact: z.boolean().optional() }),
7
+ z.object({ strategy: z.literal("testId"), value: z.string() }),
8
+ z.object({ strategy: z.literal("css"), value: z.string() }),
9
+ z.object({ strategy: z.literal("role"), role: z.string(), name: z.string().optional(), exact: z.boolean().optional() }),
10
+ ]);
11
+
12
+ const focusRegionSchema = z.object({
13
+ x: z.number(),
14
+ y: z.number(),
15
+ width: z.number(),
16
+ height: z.number(),
17
+ label: z.string().optional(),
18
+ });
19
+
20
+ const sceneDirectionSchema = z.object({
21
+ shot: z.enum(["hero", "detail", "workflow"]),
22
+ focusRegion: focusRegionSchema.optional(),
23
+ accentColor: z.string().optional(),
24
+ cameraMove: z.enum(["push-in", "pan-left", "pan-right"]).optional(),
25
+ });
26
+
27
+ export const sceneActionSchema = z.discriminatedUnion("type", [
28
+ z.object({ type: z.literal("navigate"), url: z.string() }),
29
+ z.object({ type: z.literal("wait"), ms: z.number().int().min(0).max(20000) }),
30
+ z.object({ type: z.literal("scroll"), y: z.number().int().min(-5000).max(5000) }),
31
+ z.object({ type: z.literal("click"), target: actionTargetSchema }),
32
+ z.object({ type: z.literal("hover"), target: actionTargetSchema }),
33
+ z.object({ type: z.literal("fill"), target: actionTargetSchema, value: z.string() }),
34
+ z.object({ type: z.literal("press"), key: z.string() }),
35
+ z.object({ type: z.literal("select"), target: actionTargetSchema, value: z.string() }),
36
+ z.object({ type: z.literal("waitForText"), value: z.string(), exact: z.boolean().optional(), timeoutMs: z.number().int().min(0).max(20000).optional() }),
37
+ z.object({ type: z.literal("waitForUrl"), value: z.string(), timeoutMs: z.number().int().min(0).max(20000).optional() }),
38
+ ]);
39
+
40
+ export const demoSceneSchema = z.object({
41
+ id: z.string(),
42
+ title: z.string(),
43
+ goal: z.string(),
44
+ url: z.string(),
45
+ viewport: z.object({ width: z.number().int().min(320).max(2400), height: z.number().int().min(320).max(2400) }),
46
+ actions: z.array(sceneActionSchema).min(1),
47
+ narration: z.string(),
48
+ caption: z.string(),
49
+ durationMs: z.number().int().min(1000).max(30000),
50
+ evidenceHints: z.array(z.string()).default([]),
51
+ direction: sceneDirectionSchema.optional(),
52
+ });
53
+
54
+ export const demoPlanSchema = z.object({
55
+ title: z.string(),
56
+ summary: z.string(),
57
+ branch: z.string(),
58
+ generatedAt: z.string(),
59
+ scenes: z.array(demoSceneSchema).min(1).max(6),
60
+ });
61
+
62
+ export type DemoPlanInput = z.infer<typeof demoPlanSchema>;
@@ -0,0 +1,84 @@
1
+ import type { DemoPlan } from "../types.js";
2
+ import { requestAiJson } from "../ai/provider.js";
3
+ import { z } from "zod";
4
+
5
+ const polishedPresentationSchema = z.object({
6
+ title: z.string(),
7
+ summary: z.string(),
8
+ scenes: z.array(
9
+ z.object({
10
+ id: z.string(),
11
+ title: z.string(),
12
+ caption: z.string(),
13
+ narration: z.string(),
14
+ }),
15
+ ),
16
+ });
17
+
18
+ const getPresentationConfig = () => ({
19
+ language: process.env.DEMO_SCRIPT_LANGUAGE ?? "en",
20
+ style: process.env.DEMO_PRESENTATION_STYLE ?? "launch",
21
+ });
22
+
23
+ export const polishPresentationCopy = async (plan: DemoPlan): Promise<DemoPlan> => {
24
+ const config = getPresentationConfig();
25
+
26
+ try {
27
+ const polished = await requestAiJson({
28
+ system:
29
+ "You are a world-class product launch copy director. Rewrite demo video copy to sound premium, concise, cinematic, and confident. Output strict JSON only.",
30
+ prompt: [
31
+ `Rewrite the following demo plan copy in ${config.language}.`,
32
+ `Style: ${config.style}. Think product launch / premium SaaS demo, not QA output.`,
33
+ "Rules:",
34
+ "1. Keep the same number of scenes and preserve every scene id exactly.",
35
+ "2. Rewrite only title, caption, narration, plus top-level title and summary.",
36
+ "3. Narration should sound like polished spoken demo copy.",
37
+ "4. Caption should be short, punchy, and on-screen friendly.",
38
+ "5. Title should be product-marketing quality, not generic.",
39
+ "6. Do not mention git diff, tests, heuristics, JSON, or implementation details unless the product truly shows them.",
40
+ "7. English should be natural and launch-video ready.",
41
+ JSON.stringify(
42
+ {
43
+ title: plan.title,
44
+ summary: plan.summary,
45
+ scenes: plan.scenes.map((scene) => ({
46
+ id: scene.id,
47
+ title: scene.title,
48
+ caption: scene.caption,
49
+ narration: scene.narration,
50
+ goal: scene.goal,
51
+ })),
52
+ },
53
+ null,
54
+ 2,
55
+ ),
56
+ ].join("\n"),
57
+ schema: polishedPresentationSchema,
58
+ temperature: 0.7,
59
+ });
60
+
61
+ if (!polished) return plan;
62
+
63
+ const copyBySceneId = new Map(polished.scenes.map((scene) => [scene.id, scene]));
64
+
65
+ return {
66
+ ...plan,
67
+ title: polished.title,
68
+ summary: polished.summary,
69
+ scenes: plan.scenes.map((scene) => {
70
+ const copy = copyBySceneId.get(scene.id);
71
+ if (!copy) return scene;
72
+ return {
73
+ ...scene,
74
+ title: copy.title,
75
+ caption: copy.caption,
76
+ narration: copy.narration,
77
+ };
78
+ }),
79
+ };
80
+ } catch (error) {
81
+ console.warn("presentation polish failed, using original copy", error);
82
+ return plan;
83
+ }
84
+ };