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
package/dist/index.js ADDED
@@ -0,0 +1,2116 @@
1
+ // src/capture/continuous-capture.ts
2
+ import { mkdir as mkdir2 } from "fs/promises";
3
+ import { join } from "path";
4
+ import { chromium } from "playwright";
5
+ import { createCursor } from "ghost-cursor-playwright";
6
+
7
+ // src/browser/session.ts
8
+ import { access, mkdir } from "fs/promises";
9
+ import { dirname } from "path";
10
+ var fileExists = async (path) => {
11
+ try {
12
+ await access(path);
13
+ return true;
14
+ } catch {
15
+ return false;
16
+ }
17
+ };
18
+ var resolveSessionConfig = (outputDir) => {
19
+ const storageStatePath = process.env.DEMO_STORAGE_STATE;
20
+ const saveStorageStatePath = process.env.DEMO_SAVE_STORAGE_STATE ?? `${outputDir}/storage-state.json`;
21
+ return { storageStatePath, saveStorageStatePath };
22
+ };
23
+ var getContextOptionsWithSession = async (contextOptions, session) => {
24
+ if (session.storageStatePath && await fileExists(session.storageStatePath)) {
25
+ return {
26
+ ...contextOptions,
27
+ storageState: session.storageStatePath
28
+ };
29
+ }
30
+ return contextOptions;
31
+ };
32
+ var persistSessionState = async (page, session) => {
33
+ if (!session.saveStorageStatePath) return;
34
+ await mkdir(dirname(session.saveStorageStatePath), { recursive: true }).catch(() => void 0);
35
+ await page.context().storageState({ path: session.saveStorageStatePath }).catch(() => void 0);
36
+ };
37
+
38
+ // src/capture/continuous-capture.ts
39
+ var stripBadge = (text) => text.replace(/\d+\s*$/, "").trim();
40
+ var resolveLocatorExact = (page, target) => {
41
+ switch (target.strategy) {
42
+ case "label":
43
+ return page.getByLabel(target.value, { exact: target.exact });
44
+ case "text":
45
+ return page.getByText(target.value, { exact: target.exact });
46
+ case "placeholder":
47
+ return page.getByPlaceholder(target.value, { exact: target.exact });
48
+ case "testId":
49
+ return page.getByTestId(target.value);
50
+ case "css":
51
+ return page.locator(target.value);
52
+ case "role":
53
+ return page.getByRole(target.role, {
54
+ name: target.name,
55
+ exact: target.exact
56
+ });
57
+ }
58
+ };
59
+ var resolveLocator = (page, target) => {
60
+ const exact = resolveLocatorExact(page, target);
61
+ let result = exact;
62
+ if (target.strategy === "text" || target.strategy === "label") {
63
+ const stripped = stripBadge(target.value);
64
+ if (stripped !== target.value && stripped.length > 0) {
65
+ result = result.or(
66
+ target.strategy === "text" ? page.getByText(stripped, { exact: false }) : page.getByLabel(stripped, { exact: false })
67
+ );
68
+ }
69
+ if (target.exact) {
70
+ result = result.or(
71
+ target.strategy === "text" ? page.getByText(target.value, { exact: false }) : page.getByLabel(target.value, { exact: false })
72
+ );
73
+ }
74
+ }
75
+ if (target.strategy === "role" && target.name) {
76
+ const stripped = stripBadge(target.name);
77
+ if (stripped !== target.name && stripped.length > 0) {
78
+ result = result.or(
79
+ page.getByRole(target.role, { name: stripped, exact: false })
80
+ );
81
+ }
82
+ result = result.or(page.getByText(target.name, { exact: false }));
83
+ if (stripped !== target.name) {
84
+ result = result.or(page.getByText(stripped, { exact: false }));
85
+ }
86
+ }
87
+ return result;
88
+ };
89
+ var humanDelay = (baseMs, jitter = 0.4) => {
90
+ const factor = 1 + (Math.random() * 2 - 1) * jitter;
91
+ return Math.max(50, Math.round(baseMs * factor));
92
+ };
93
+ var boxForAction = async (page, action) => {
94
+ const getBox = async (locator) => {
95
+ const box = await locator.first().boundingBox().catch(() => null);
96
+ if (!box) return void 0;
97
+ return {
98
+ x: box.x + box.width / 2,
99
+ y: box.y + box.height / 2,
100
+ width: box.width,
101
+ height: box.height
102
+ };
103
+ };
104
+ switch (action.type) {
105
+ case "click":
106
+ case "hover":
107
+ case "fill":
108
+ case "select":
109
+ case "dragSelect":
110
+ return getBox(resolveLocator(page, action.target));
111
+ case "scrollIntoView":
112
+ return getBox(resolveLocator(page, action.target));
113
+ case "waitForText":
114
+ return getBox(page.getByText(action.value, { exact: action.exact }));
115
+ default:
116
+ return void 0;
117
+ }
118
+ };
119
+ var CURSOR_TRACKER_SCRIPT = `
120
+ (() => {
121
+ if (window.__cursorTrackerInstalled) return;
122
+ window.__cursorTrackerInstalled = true;
123
+ window.__cursorSamples = [];
124
+ window.__cursorTrackingStart = Date.now();
125
+ let lastX = 0, lastY = 0;
126
+
127
+ document.addEventListener('mousemove', (e) => {
128
+ lastX = e.clientX;
129
+ lastY = e.clientY;
130
+ }, { passive: true });
131
+
132
+ const tick = () => {
133
+ window.__cursorSamples.push({
134
+ atMs: Date.now() - window.__cursorTrackingStart,
135
+ x: lastX,
136
+ y: lastY,
137
+ });
138
+ requestAnimationFrame(tick);
139
+ };
140
+ requestAnimationFrame(tick);
141
+ })();
142
+ `;
143
+ var ZOOM_INJECT_SCRIPT = `
144
+ (() => {
145
+ if (window.__zoomInstalled) return;
146
+ window.__zoomInstalled = true;
147
+ const html = document.documentElement;
148
+ html.style.transition = 'transform 0.8s cubic-bezier(0.22, 0.61, 0.36, 1)';
149
+ html.style.transformOrigin = '0 0';
150
+ window.__zoomTo = (x, y, scale) => {
151
+ const vw = window.innerWidth;
152
+ const vh = window.innerHeight;
153
+ const tx = -(x - vw / scale / 2) * (scale - 1);
154
+ const ty = -(y - vh / scale / 2) * (scale - 1);
155
+ const clampedTx = Math.min(0, Math.max(tx, -(vw * (scale - 1))));
156
+ const clampedTy = Math.min(0, Math.max(ty, -(vh * (scale - 1))));
157
+ html.style.transform = 'scale(' + scale + ') translate(' + clampedTx / scale + 'px, ' + clampedTy / scale + 'px)';
158
+ };
159
+ window.__zoomReset = () => {
160
+ html.style.transform = 'scale(1) translate(0px, 0px)';
161
+ };
162
+ })();
163
+ `;
164
+ var zoomToPoint = async (page, x, y, scale = 1.5) => {
165
+ await page.evaluate(ZOOM_INJECT_SCRIPT).catch(() => void 0);
166
+ await page.evaluate(
167
+ ({ x: x2, y: y2, scale: scale2 }) => window.__zoomTo?.(x2, y2, scale2),
168
+ { x, y, scale }
169
+ ).catch(() => void 0);
170
+ await page.waitForTimeout(850);
171
+ };
172
+ var zoomReset = async (page) => {
173
+ await page.evaluate(() => window.__zoomReset?.()).catch(() => void 0);
174
+ await page.waitForTimeout(850);
175
+ };
176
+ var drainCursorSamples = async (page, log, offsetMs) => {
177
+ try {
178
+ const samples = await page.evaluate(() => {
179
+ const s = window.__cursorSamples ?? [];
180
+ window.__cursorSamples = [];
181
+ return s;
182
+ });
183
+ for (const s of samples) {
184
+ log.push({ atMs: s.atMs + offsetMs, x: s.x, y: s.y });
185
+ }
186
+ } catch {
187
+ }
188
+ };
189
+ var CURSOR_OVERLAY_SCRIPT = `
190
+ (() => {
191
+ if (document.getElementById('__ghost-cursor')) return;
192
+
193
+ /* \u2500\u2500 macOS-style pointer cursor \u2500\u2500 */
194
+ const cursor = document.createElement('div');
195
+ cursor.id = '__ghost-cursor';
196
+ Object.assign(cursor.style, {
197
+ position: 'fixed', zIndex: '999999', pointerEvents: 'none',
198
+ left: '0px', top: '0px', width: '22px', height: '32px',
199
+ transition: 'left 0.04s cubic-bezier(.2,.8,.3,1), top 0.04s cubic-bezier(.2,.8,.3,1)',
200
+ filter: 'drop-shadow(0 2px 3px rgba(0,0,0,0.4))',
201
+ });
202
+ /* Standard macOS cursor: white fill, black outline, clean geometry */
203
+ cursor.innerHTML = '<svg width="22" height="32" viewBox="0 0 22 32" xmlns="http://www.w3.org/2000/svg">' +
204
+ '<path d="M1.5 0.5L1.5 24.5L7 18.5L11.5 28.5L14.5 27L10 17.5L18 17.5Z" fill="white" stroke="black" stroke-width="1.2" stroke-linejoin="round"/>' +
205
+ '</svg>';
206
+ document.body.appendChild(cursor);
207
+
208
+ /* \u2500\u2500 Click ripple \u2500\u2500 */
209
+ const ripple = document.createElement('div');
210
+ ripple.id = '__ghost-ripple';
211
+ Object.assign(ripple.style, {
212
+ position: 'fixed', zIndex: '999998', pointerEvents: 'none',
213
+ width: '40px', height: '40px', borderRadius: '50%',
214
+ border: '2px solid rgba(59,130,246,0.6)',
215
+ background: 'rgba(59,130,246,0.12)',
216
+ transform: 'translate(-50%,-50%) scale(0)',
217
+ opacity: '0', left: '0px', top: '0px',
218
+ transition: 'transform 0.35s cubic-bezier(.2,.8,.3,1), opacity 0.35s ease-out',
219
+ });
220
+ document.body.appendChild(ripple);
221
+
222
+ document.addEventListener('mousemove', (e) => {
223
+ cursor.style.left = e.clientX + 'px';
224
+ cursor.style.top = e.clientY + 'px';
225
+ }, { passive: true });
226
+
227
+ document.addEventListener('mousedown', (e) => {
228
+ /* Pointer press animation */
229
+ cursor.style.transform = 'scale(0.82)';
230
+ setTimeout(() => { cursor.style.transform = 'scale(1)'; }, 150);
231
+
232
+ /* Ripple at click position */
233
+ ripple.style.left = e.clientX + 'px';
234
+ ripple.style.top = e.clientY + 'px';
235
+ ripple.style.transform = 'translate(-50%,-50%) scale(0)';
236
+ ripple.style.opacity = '1';
237
+ /* Force reflow so transition restarts */
238
+ void ripple.offsetWidth;
239
+ ripple.style.transform = 'translate(-50%,-50%) scale(1)';
240
+ ripple.style.opacity = '0';
241
+ });
242
+ })();
243
+ `;
244
+ var humanTypeInto = async (page, locator, value) => {
245
+ await locator.first().click();
246
+ await locator.first().fill("");
247
+ for (const char of value) {
248
+ await page.keyboard.type(char, { delay: humanDelay(65, 0.5) });
249
+ }
250
+ };
251
+ var runActionWithCursor = async (page, action, baseUrl, cursor) => {
252
+ switch (action.type) {
253
+ case "navigate": {
254
+ const url = new URL(action.url, baseUrl).toString();
255
+ try {
256
+ await page.goto(url, { waitUntil: "networkidle", timeout: 3e4 });
257
+ } catch (error) {
258
+ const message = error instanceof Error ? error.message : String(error);
259
+ if (!message.includes("page.goto: Timeout")) throw error;
260
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 });
261
+ await page.waitForTimeout(1500);
262
+ }
263
+ await page.evaluate(CURSOR_OVERLAY_SCRIPT).catch(() => void 0);
264
+ await page.evaluate(CURSOR_TRACKER_SCRIPT).catch(() => void 0);
265
+ await page.evaluate(ZOOM_INJECT_SCRIPT).catch(() => void 0);
266
+ await page.waitForTimeout(humanDelay(400));
267
+ break;
268
+ }
269
+ case "wait": {
270
+ await page.waitForTimeout(action.ms);
271
+ break;
272
+ }
273
+ case "scroll": {
274
+ await page.evaluate(
275
+ (y) => window.scrollBy({ top: y, behavior: "smooth" }),
276
+ action.y
277
+ );
278
+ await page.waitForTimeout(humanDelay(500));
279
+ break;
280
+ }
281
+ case "scrollIntoView": {
282
+ const locator = resolveLocator(page, action.target);
283
+ await locator.first().scrollIntoViewIfNeeded();
284
+ await page.waitForTimeout(humanDelay(300));
285
+ break;
286
+ }
287
+ case "click": {
288
+ const selector = buildCssSelector(action.target);
289
+ if (selector) {
290
+ await cursor.actions.click({
291
+ target: selector,
292
+ waitBeforeClick: [100, 300]
293
+ });
294
+ } else {
295
+ const box = await resolveLocator(page, action.target).first().boundingBox().catch(() => null);
296
+ if (box) {
297
+ await cursor.actions.move({
298
+ x: box.x + box.width / 2,
299
+ y: box.y + box.height / 2
300
+ });
301
+ await page.waitForTimeout(humanDelay(120));
302
+ await page.mouse.click(
303
+ box.x + box.width / 2,
304
+ box.y + box.height / 2
305
+ );
306
+ } else {
307
+ await resolveLocator(page, action.target).first().click();
308
+ }
309
+ }
310
+ const clickBox = await resolveLocator(page, action.target).first().boundingBox().catch(() => null);
311
+ if (clickBox) {
312
+ await zoomToPoint(page, clickBox.x + clickBox.width / 2, clickBox.y + clickBox.height / 2, 1.4);
313
+ await page.waitForTimeout(humanDelay(1200));
314
+ await zoomReset(page);
315
+ } else {
316
+ await page.waitForTimeout(humanDelay(200));
317
+ }
318
+ break;
319
+ }
320
+ case "hover": {
321
+ const selector = buildCssSelector(action.target);
322
+ if (selector) {
323
+ await cursor.actions.move(selector);
324
+ } else {
325
+ const box = await resolveLocator(page, action.target).first().boundingBox().catch(() => null);
326
+ if (box) {
327
+ await cursor.actions.move({
328
+ x: box.x + box.width / 2,
329
+ y: box.y + box.height / 2
330
+ });
331
+ } else {
332
+ await resolveLocator(page, action.target).first().hover();
333
+ }
334
+ }
335
+ const hoverBox = await resolveLocator(page, action.target).first().boundingBox().catch(() => null);
336
+ if (hoverBox) {
337
+ await zoomToPoint(page, hoverBox.x + hoverBox.width / 2, hoverBox.y + hoverBox.height / 2, 1.3);
338
+ await page.waitForTimeout(humanDelay(1e3));
339
+ await zoomReset(page);
340
+ } else {
341
+ await page.waitForTimeout(humanDelay(300));
342
+ }
343
+ break;
344
+ }
345
+ case "fill": {
346
+ const locator = resolveLocator(page, action.target);
347
+ const selector = buildCssSelector(action.target);
348
+ if (selector) {
349
+ await cursor.actions.click({
350
+ target: selector,
351
+ waitBeforeClick: [80, 200]
352
+ });
353
+ } else {
354
+ const box = await locator.first().boundingBox().catch(() => null);
355
+ if (box) {
356
+ await cursor.actions.move({
357
+ x: box.x + box.width / 2,
358
+ y: box.y + box.height / 2
359
+ });
360
+ await page.waitForTimeout(humanDelay(100));
361
+ await page.mouse.click(
362
+ box.x + box.width / 2,
363
+ box.y + box.height / 2
364
+ );
365
+ }
366
+ }
367
+ await page.waitForTimeout(humanDelay(150));
368
+ await humanTypeInto(page, locator, action.value);
369
+ await page.waitForTimeout(humanDelay(200));
370
+ break;
371
+ }
372
+ case "press": {
373
+ await page.keyboard.press(action.key);
374
+ await page.waitForTimeout(humanDelay(150));
375
+ break;
376
+ }
377
+ case "select": {
378
+ const locator = resolveLocator(page, action.target);
379
+ await locator.first().selectOption(action.value);
380
+ await page.waitForTimeout(humanDelay(200));
381
+ break;
382
+ }
383
+ case "dragSelect": {
384
+ const box = await resolveLocator(page, action.target).first().boundingBox();
385
+ if (!box) throw new Error("dragSelect: no bounding box");
386
+ const startX = box.x + box.width * (action.startX ?? 0.08);
387
+ const startY = box.y + box.height * (action.startY ?? 0.12);
388
+ const endX = box.x + box.width * (action.endX ?? 0.7);
389
+ const endY = box.y + box.height * (action.endY ?? action.startY ?? 0.12);
390
+ await cursor.actions.move({ x: startX, y: startY });
391
+ await page.waitForTimeout(humanDelay(100));
392
+ await page.mouse.down();
393
+ await page.mouse.move(endX, endY, { steps: 24 });
394
+ await page.mouse.up();
395
+ await page.waitForTimeout(humanDelay(200));
396
+ break;
397
+ }
398
+ case "waitForText": {
399
+ await page.getByText(action.value, { exact: action.exact }).first().waitFor({ timeout: action.timeoutMs ?? 1e4 }).catch(() => console.warn(`[capture] waitForText timed out: "${action.value}"`));
400
+ break;
401
+ }
402
+ case "waitForUrl": {
403
+ const urlMatcher = action.value.startsWith("http://") || action.value.startsWith("https://") || action.value.includes("*") ? action.value : new URL(action.value, baseUrl).toString();
404
+ await page.waitForURL(urlMatcher, { timeout: action.timeoutMs ?? 1e4 }).catch(() => console.warn(`[capture] waitForUrl timed out: "${action.value}"`));
405
+ break;
406
+ }
407
+ }
408
+ };
409
+ var buildCssSelector = (target) => {
410
+ switch (target.strategy) {
411
+ case "css": {
412
+ if (/:has-text|:text|:visible|>>/.test(target.value)) return void 0;
413
+ return target.value;
414
+ }
415
+ case "testId":
416
+ return `[data-testid="${target.value}"]`;
417
+ default:
418
+ return void 0;
419
+ }
420
+ };
421
+ var DEFAULT_VIEWPORT = { width: 1600, height: 900 };
422
+ var capturePlanContinuous = async (plan, options) => {
423
+ const viewport = options.viewport ?? plan.scenes[0]?.viewport ?? DEFAULT_VIEWPORT;
424
+ const captureDir = join(options.outputDir, "continuous");
425
+ await mkdir2(captureDir, { recursive: true });
426
+ const videoPath = join(captureDir, "recording.webm");
427
+ const session = resolveSessionConfig(options.outputDir);
428
+ const browser = await chromium.launch({ headless: true });
429
+ const context = await browser.newContext(
430
+ await getContextOptionsWithSession({ viewport }, session)
431
+ );
432
+ const page = await context.newPage();
433
+ page.setDefaultTimeout(8e3);
434
+ const cursorLog = [];
435
+ const interactions = [];
436
+ const sceneMarkers = [];
437
+ const captureStartedAt = Date.now();
438
+ const elapsed = () => Date.now() - captureStartedAt;
439
+ await page.screencast.start({ path: videoPath });
440
+ const cursor = await createCursor(page, {
441
+ overshootSpread: 2,
442
+ overshootRadius: 8
443
+ });
444
+ let cursorTrackingOffset = 0;
445
+ const drainInterval = setInterval(async () => {
446
+ await drainCursorSamples(page, cursorLog, cursorTrackingOffset);
447
+ }, 200);
448
+ try {
449
+ for (const scene of plan.scenes) {
450
+ const sceneStart = elapsed();
451
+ interactions.push({
452
+ type: "scene-start",
453
+ sceneId: scene.id,
454
+ atMs: sceneStart
455
+ });
456
+ await page.screencast.showChapter(scene.title).catch(() => void 0);
457
+ await page.waitForTimeout(humanDelay(600));
458
+ for (const action of scene.actions) {
459
+ const boxBefore = await boxForAction(page, action).catch(() => void 0);
460
+ try {
461
+ await runActionWithCursor(page, action, options.baseUrl, cursor);
462
+ } catch (error) {
463
+ const msg = error instanceof Error ? error.message : String(error);
464
+ console.warn(`[capture] action ${action.type} failed (skipping): ${msg.split("\n")[0]}`);
465
+ }
466
+ if (action.type === "navigate") {
467
+ cursorTrackingOffset = elapsed();
468
+ await page.evaluate(CURSOR_TRACKER_SCRIPT).catch(() => void 0);
469
+ await page.evaluate(CURSOR_OVERLAY_SCRIPT).catch(() => void 0);
470
+ }
471
+ const boxAfter = await boxForAction(page, action);
472
+ const box = boxAfter ?? boxBefore;
473
+ interactions.push({
474
+ type: action.type,
475
+ sceneId: scene.id,
476
+ atMs: elapsed(),
477
+ x: box?.x,
478
+ y: box?.y,
479
+ width: box?.width,
480
+ height: box?.height,
481
+ fillValue: action.type === "fill" ? action.value : void 0
482
+ });
483
+ }
484
+ await page.waitForLoadState("networkidle").catch(() => void 0);
485
+ await page.waitForTimeout(humanDelay(500));
486
+ await persistSessionState(page, session);
487
+ const sceneEnd = elapsed();
488
+ interactions.push({
489
+ type: "scene-end",
490
+ sceneId: scene.id,
491
+ atMs: sceneEnd
492
+ });
493
+ sceneMarkers.push({
494
+ sceneId: scene.id,
495
+ sceneTitle: scene.title,
496
+ startMs: sceneStart,
497
+ endMs: sceneEnd,
498
+ url: page.url()
499
+ });
500
+ await page.waitForTimeout(humanDelay(400));
501
+ }
502
+ await drainCursorSamples(page, cursorLog, cursorTrackingOffset);
503
+ const totalDurationMs = elapsed();
504
+ await page.screencast.stop();
505
+ await context.close();
506
+ await browser.close();
507
+ return {
508
+ videoPath,
509
+ cursorLog,
510
+ interactions,
511
+ sceneMarkers,
512
+ totalDurationMs,
513
+ viewport
514
+ };
515
+ } finally {
516
+ clearInterval(drainInterval);
517
+ await browser.close().catch(() => void 0);
518
+ }
519
+ };
520
+
521
+ // src/planner/prompt.ts
522
+ import { mkdir as mkdir3 } from "fs/promises";
523
+ import { join as join3 } from "path";
524
+ import { chromium as chromium2 } from "playwright";
525
+
526
+ // src/config/project.ts
527
+ import { access as access2, readFile } from "fs/promises";
528
+ import { resolve } from "path";
529
+ var DEFAULT_CONFIG_PATHS = ["demo.dev.config.json", ".demo-dev.json"];
530
+ var fileExists2 = async (path) => {
531
+ try {
532
+ await access2(path);
533
+ return true;
534
+ } catch {
535
+ return false;
536
+ }
537
+ };
538
+ var isRecord = (value) => {
539
+ return typeof value === "object" && value !== null;
540
+ };
541
+ var readStringArray = (value) => {
542
+ if (!Array.isArray(value)) return void 0;
543
+ const items = value.filter((item) => typeof item === "string");
544
+ return items.length > 0 ? items : void 0;
545
+ };
546
+ var readTarget = (value) => {
547
+ if (!isRecord(value) || typeof value.strategy !== "string") return void 0;
548
+ switch (value.strategy) {
549
+ case "label":
550
+ case "text":
551
+ case "placeholder":
552
+ if (typeof value.value === "string") {
553
+ return {
554
+ strategy: value.strategy,
555
+ value: value.value,
556
+ exact: typeof value.exact === "boolean" ? value.exact : void 0
557
+ };
558
+ }
559
+ return void 0;
560
+ case "testId":
561
+ case "css":
562
+ if (typeof value.value === "string") {
563
+ return { strategy: value.strategy, value: value.value };
564
+ }
565
+ return void 0;
566
+ case "role":
567
+ if (typeof value.role === "string") {
568
+ return {
569
+ strategy: "role",
570
+ role: value.role,
571
+ name: typeof value.name === "string" ? value.name : void 0,
572
+ exact: typeof value.exact === "boolean" ? value.exact : void 0
573
+ };
574
+ }
575
+ return void 0;
576
+ default:
577
+ return void 0;
578
+ }
579
+ };
580
+ var normalizeAuthConfig = (value) => {
581
+ if (!isRecord(value)) return void 0;
582
+ return {
583
+ loginPath: typeof value.loginPath === "string" ? value.loginPath : void 0,
584
+ emailTarget: readTarget(value.emailTarget),
585
+ passwordTarget: readTarget(value.passwordTarget),
586
+ submitTarget: readTarget(value.submitTarget),
587
+ successUrlPattern: typeof value.successUrlPattern === "string" ? value.successUrlPattern : void 0,
588
+ postSubmitWaitMs: typeof value.postSubmitWaitMs === "number" ? value.postSubmitWaitMs : void 0
589
+ };
590
+ };
591
+ var normalizeProjectConfig = (value) => {
592
+ if (!isRecord(value)) return {};
593
+ return {
594
+ projectName: typeof value.projectName === "string" ? value.projectName : void 0,
595
+ baseUrl: typeof value.baseUrl === "string" ? value.baseUrl : void 0,
596
+ readyUrl: typeof value.readyUrl === "string" ? value.readyUrl : void 0,
597
+ devCommand: typeof value.devCommand === "string" ? value.devCommand : void 0,
598
+ baseRef: typeof value.baseRef === "string" ? value.baseRef : void 0,
599
+ outputDir: typeof value.outputDir === "string" ? value.outputDir : void 0,
600
+ storageStatePath: typeof value.storageStatePath === "string" ? value.storageStatePath : void 0,
601
+ saveStorageStatePath: typeof value.saveStorageStatePath === "string" ? value.saveStorageStatePath : void 0,
602
+ preferredRoutes: readStringArray(value.preferredRoutes),
603
+ featureHints: readStringArray(value.featureHints),
604
+ authRequiredRoutes: readStringArray(value.authRequiredRoutes),
605
+ auth: normalizeAuthConfig(value.auth)
606
+ };
607
+ };
608
+ var loadProjectConfig = async (configPath) => {
609
+ const explicitPath = configPath ?? process.env.DEMO_CONFIG;
610
+ const candidatePaths = explicitPath ? [explicitPath] : DEFAULT_CONFIG_PATHS;
611
+ for (const candidate of candidatePaths) {
612
+ const absolutePath = resolve(candidate);
613
+ if (!await fileExists2(absolutePath)) continue;
614
+ const parsed = JSON.parse(await readFile(absolutePath, "utf8"));
615
+ return {
616
+ path: absolutePath,
617
+ config: normalizeProjectConfig(parsed)
618
+ };
619
+ }
620
+ return { config: {} };
621
+ };
622
+ var applyProjectEnvironment = (config) => {
623
+ if (!process.env.DEMO_STORAGE_STATE && config.storageStatePath) {
624
+ process.env.DEMO_STORAGE_STATE = config.storageStatePath;
625
+ }
626
+ if (!process.env.DEMO_SAVE_STORAGE_STATE && config.saveStorageStatePath) {
627
+ process.env.DEMO_SAVE_STORAGE_STATE = config.saveStorageStatePath;
628
+ }
629
+ };
630
+ var summarizeProjectHints = (config) => {
631
+ return {
632
+ preferredRoutes: config.preferredRoutes ?? [],
633
+ featureHints: config.featureHints ?? [],
634
+ authRequiredRoutes: config.authRequiredRoutes ?? []
635
+ };
636
+ };
637
+
638
+ // src/ai/provider.ts
639
+ import { execFile } from "child_process";
640
+ import { mkdtemp, readFile as readFile2, rm } from "fs/promises";
641
+ import { tmpdir } from "os";
642
+ import { join as join2 } from "path";
643
+ import { promisify } from "util";
644
+ import { z } from "zod";
645
+ var execFileAsync = promisify(execFile);
646
+ var extractJson = (text) => {
647
+ const fenced = text.match(/```json\s*([\s\S]*?)```/i)?.[1];
648
+ if (fenced) return fenced.trim();
649
+ const firstBrace = text.indexOf("{");
650
+ const lastBrace = text.lastIndexOf("}");
651
+ if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
652
+ return text.slice(firstBrace, lastBrace + 1);
653
+ }
654
+ return text.trim();
655
+ };
656
+ var runCommand = async (command, args, stdin) => {
657
+ if (stdin) {
658
+ const { spawn } = await import("child_process");
659
+ return new Promise((resolve3, reject) => {
660
+ const child = spawn(command, args, { cwd: process.cwd(), stdio: ["pipe", "pipe", "pipe"] });
661
+ let stdout2 = "";
662
+ let stderr2 = "";
663
+ child.stdout.on("data", (d) => {
664
+ stdout2 += d.toString();
665
+ });
666
+ child.stderr.on("data", (d) => {
667
+ stderr2 += d.toString();
668
+ });
669
+ child.on("error", reject);
670
+ child.on("close", (code) => {
671
+ if (code !== 0) reject(new Error(`${command} exited with ${code}: ${stderr2}`));
672
+ else resolve3({ stdout: stdout2, stderr: stderr2 });
673
+ });
674
+ child.stdin.write(stdin);
675
+ child.stdin.end();
676
+ });
677
+ }
678
+ const { stdout, stderr } = await execFileAsync(command, args, {
679
+ cwd: process.cwd(),
680
+ maxBuffer: 1024 * 1024 * 20
681
+ });
682
+ return { stdout, stderr };
683
+ };
684
+ var commandExists = async (command) => {
685
+ try {
686
+ await execFileAsync("which", [command]);
687
+ return true;
688
+ } catch {
689
+ return false;
690
+ }
691
+ };
692
+ var getConfig = () => ({
693
+ provider: process.env.DEMO_AI_PROVIDER ?? "auto",
694
+ model: process.env.DEMO_AI_MODEL,
695
+ mandatory: process.env.DEMO_AI_MANDATORY !== "false"
696
+ });
697
+ var getOpenAiConfig = () => {
698
+ const apiKey = process.env.DEMO_OPENAI_API_KEY;
699
+ const baseUrl = process.env.DEMO_OPENAI_BASE_URL ?? "https://api.openai.com/v1";
700
+ const model = process.env.DEMO_OPENAI_MODEL ?? process.env.DEMO_AI_MODEL ?? "gpt-4.1-mini";
701
+ return { apiKey, baseUrl, model };
702
+ };
703
+ var requestFromOpenAi = async (options) => {
704
+ const config = getOpenAiConfig();
705
+ if (!config.apiKey) throw new Error("OpenAI API key not configured. Set DEMO_OPENAI_API_KEY.");
706
+ const response = await fetch(`${config.baseUrl}/chat/completions`, {
707
+ method: "POST",
708
+ headers: {
709
+ "content-type": "application/json",
710
+ authorization: `Bearer ${config.apiKey}`
711
+ },
712
+ body: JSON.stringify({
713
+ model: config.model,
714
+ temperature: 0.2,
715
+ response_format: { type: "json_object" },
716
+ messages: [
717
+ { role: "system", content: options.system },
718
+ { role: "user", content: options.prompt }
719
+ ]
720
+ })
721
+ });
722
+ if (!response.ok) {
723
+ throw new Error(`OpenAI request failed: ${response.status} ${await response.text()}`);
724
+ }
725
+ const payload = z.object({
726
+ choices: z.array(z.object({ message: z.object({ content: z.string().nullable().optional() }) }))
727
+ }).parse(await response.json());
728
+ const content = payload.choices[0]?.message.content;
729
+ if (!content) throw new Error("OpenAI returned empty content");
730
+ return options.schema.parse(JSON.parse(extractJson(content)));
731
+ };
732
+ var requestFromCursor = async (options) => {
733
+ if (!await commandExists("cursor-agent")) throw new Error("cursor-agent not found");
734
+ const stdinContent = `${options.system}
735
+
736
+ ${options.prompt}`;
737
+ const args = ["--print", "--output-format", "json", "--trust", "--mode", "plan"];
738
+ if (options.model) args.push("--model", options.model);
739
+ args.push("-");
740
+ const { stdout } = await runCommand("cursor-agent", args, stdinContent);
741
+ const payload = JSON.parse(stdout);
742
+ if (payload.is_error) throw new Error(payload.result ?? "Cursor provider returned error");
743
+ if (!payload.result) throw new Error("Cursor provider returned empty result");
744
+ return options.schema.parse(JSON.parse(extractJson(payload.result)));
745
+ };
746
+ var requestFromClaude = async (options) => {
747
+ if (!await commandExists("claude")) throw new Error("claude not found");
748
+ const stdinContent = `${options.prompt}
749
+
750
+ Return strict JSON only.`;
751
+ const args = [
752
+ "-p",
753
+ "--output-format",
754
+ "json",
755
+ "--system-prompt",
756
+ options.system,
757
+ "--allowedTools",
758
+ ""
759
+ ];
760
+ if (options.model) args.push("--model", options.model);
761
+ args.push("-");
762
+ const { stdout } = await runCommand("claude", args, stdinContent);
763
+ const payload = JSON.parse(stdout);
764
+ if (payload.is_error) throw new Error(payload.result ?? "Claude provider returned error");
765
+ if (!payload.result) throw new Error("Claude provider returned empty result");
766
+ return options.schema.parse(JSON.parse(extractJson(payload.result)));
767
+ };
768
+ var requestFromCodex = async (options) => {
769
+ if (!await commandExists("codex")) throw new Error("codex not found");
770
+ const tempDir = await mkdtemp(join2(tmpdir(), "demo-dev-codex-"));
771
+ const outputPath = join2(tempDir, "last-message.json");
772
+ try {
773
+ const fullPrompt = `${options.system}
774
+
775
+ ${options.prompt}
776
+
777
+ Return strict JSON only.`;
778
+ const args = [
779
+ "exec",
780
+ "--skip-git-repo-check",
781
+ "--sandbox",
782
+ "read-only",
783
+ "--output-last-message",
784
+ outputPath
785
+ ];
786
+ if (options.model) args.push("--model", options.model);
787
+ args.push(fullPrompt);
788
+ await runCommand("codex", args);
789
+ const content = await readFile2(outputPath, "utf8");
790
+ return options.schema.parse(JSON.parse(extractJson(content)));
791
+ } finally {
792
+ await rm(tempDir, { recursive: true, force: true }).catch(() => void 0);
793
+ }
794
+ };
795
+ var PROVIDERS = ["cursor", "claude", "codex", "openai"];
796
+ var requestAiJson = async (options) => {
797
+ const config = getConfig();
798
+ const providers = config.provider === "auto" ? PROVIDERS : [config.provider];
799
+ const errors = [];
800
+ for (const provider of providers) {
801
+ let prompt = options.prompt;
802
+ for (let attempt = 0; attempt < 2; attempt += 1) {
803
+ try {
804
+ switch (provider) {
805
+ case "cursor":
806
+ return await requestFromCursor({ ...options, prompt, model: config.model });
807
+ case "claude":
808
+ return await requestFromClaude({ ...options, prompt, model: config.model });
809
+ case "codex":
810
+ return await requestFromCodex({ ...options, prompt, model: config.model });
811
+ case "openai":
812
+ return await requestFromOpenAi({ ...options, prompt });
813
+ }
814
+ } catch (error) {
815
+ const message = error instanceof Error ? error.message : String(error);
816
+ if (attempt === 0) {
817
+ prompt = `${options.prompt}
818
+
819
+ Your previous response failed validation with this error:
820
+ ${message}
821
+
822
+ Return corrected strict JSON only.`;
823
+ continue;
824
+ }
825
+ errors.push(`${provider}: ${message}`);
826
+ break;
827
+ }
828
+ }
829
+ if (config.provider !== "auto") break;
830
+ }
831
+ if (!config.mandatory) return null;
832
+ throw new Error(`No AI provider succeeded. ${errors.join(" | ")}`);
833
+ };
834
+
835
+ // src/planner/schema.ts
836
+ import { z as z2 } from "zod";
837
+ var actionTargetSchema = z2.discriminatedUnion("strategy", [
838
+ z2.object({ strategy: z2.literal("label"), value: z2.string(), exact: z2.boolean().optional() }),
839
+ z2.object({ strategy: z2.literal("text"), value: z2.string(), exact: z2.boolean().optional() }),
840
+ z2.object({ strategy: z2.literal("placeholder"), value: z2.string(), exact: z2.boolean().optional() }),
841
+ z2.object({ strategy: z2.literal("testId"), value: z2.string() }),
842
+ z2.object({ strategy: z2.literal("css"), value: z2.string() }),
843
+ z2.object({ strategy: z2.literal("role"), role: z2.string(), name: z2.string().optional(), exact: z2.boolean().optional() })
844
+ ]);
845
+ var focusRegionSchema = z2.object({
846
+ x: z2.number(),
847
+ y: z2.number(),
848
+ width: z2.number(),
849
+ height: z2.number(),
850
+ label: z2.string().optional()
851
+ });
852
+ var sceneDirectionSchema = z2.object({
853
+ shot: z2.enum(["hero", "detail", "workflow"]),
854
+ focusRegion: focusRegionSchema.optional(),
855
+ accentColor: z2.string().optional(),
856
+ cameraMove: z2.enum(["push-in", "pan-left", "pan-right"]).optional()
857
+ });
858
+ var sceneActionSchema = z2.discriminatedUnion("type", [
859
+ z2.object({ type: z2.literal("navigate"), url: z2.string() }),
860
+ z2.object({ type: z2.literal("wait"), ms: z2.number().int().min(0).max(2e4) }),
861
+ z2.object({ type: z2.literal("scroll"), y: z2.number().int().min(-5e3).max(5e3) }),
862
+ z2.object({ type: z2.literal("click"), target: actionTargetSchema }),
863
+ z2.object({ type: z2.literal("hover"), target: actionTargetSchema }),
864
+ z2.object({ type: z2.literal("fill"), target: actionTargetSchema, value: z2.string() }),
865
+ z2.object({ type: z2.literal("press"), key: z2.string() }),
866
+ z2.object({ type: z2.literal("select"), target: actionTargetSchema, value: z2.string() }),
867
+ z2.object({ type: z2.literal("waitForText"), value: z2.string(), exact: z2.boolean().optional(), timeoutMs: z2.number().int().min(0).max(2e4).optional() }),
868
+ z2.object({ type: z2.literal("waitForUrl"), value: z2.string(), timeoutMs: z2.number().int().min(0).max(2e4).optional() })
869
+ ]);
870
+ var demoSceneSchema = z2.object({
871
+ id: z2.string(),
872
+ title: z2.string(),
873
+ goal: z2.string(),
874
+ url: z2.string(),
875
+ viewport: z2.object({ width: z2.number().int().min(320).max(2400), height: z2.number().int().min(320).max(2400) }),
876
+ actions: z2.array(sceneActionSchema).min(1),
877
+ narration: z2.string(),
878
+ caption: z2.string(),
879
+ durationMs: z2.number().int().min(1e3).max(3e4),
880
+ evidenceHints: z2.array(z2.string()).default([]),
881
+ direction: sceneDirectionSchema.optional()
882
+ });
883
+ var demoPlanSchema = z2.object({
884
+ title: z2.string(),
885
+ summary: z2.string(),
886
+ branch: z2.string(),
887
+ generatedAt: z2.string(),
888
+ scenes: z2.array(demoSceneSchema).min(1).max(6)
889
+ });
890
+
891
+ // src/planner/prompt.ts
892
+ var EXPLORE_SCRIPT = `(() => {
893
+ function norm(v) { return (v||"").replace(/\\s+/g," ").trim().slice(0,140); }
894
+ function vis(el) {
895
+ const r = el.getBoundingClientRect(), s = getComputedStyle(el);
896
+ return r.width>0 && r.height>0 && s.visibility!=="hidden" && s.display!=="none" && s.opacity!=="0";
897
+ }
898
+ function role(el) {
899
+ const r = el.getAttribute("role"); if(r) return r;
900
+ const t = el.tagName.toLowerCase();
901
+ if(t==="a") return "link"; if(t==="button") return "button";
902
+ if(t==="select") return "combobox"; if(t==="textarea") return "textbox";
903
+ if(t==="input") { const tp=(el.type||"text").toLowerCase(); return ["submit","button"].includes(tp)?"button":"textbox"; }
904
+ return t;
905
+ }
906
+ function name(el) {
907
+ return norm(el.getAttribute("aria-label")) || norm(el.placeholder) || norm(el.textContent) || role(el);
908
+ }
909
+ const sels = "button,a[href],input,select,textarea,[role=button],[role=link],[role=tab],[role=menuitem],[data-testid]";
910
+ const elems = Array.from(document.querySelectorAll(sels)).filter(vis).slice(0,50).map(el => ({
911
+ tag: el.tagName.toLowerCase(), role: role(el), name: name(el),
912
+ text: norm(el.textContent)||undefined, label: undefined,
913
+ placeholder: norm(el.placeholder)||undefined,
914
+ href: el.href||undefined, testId: el.getAttribute("data-testid")||undefined,
915
+ }));
916
+ const headings = Array.from(document.querySelectorAll("h1,h2,h3")).map(h=>norm(h.textContent)).filter(Boolean).slice(0,10);
917
+ const text = norm(document.body?.innerText).slice(0,800);
918
+ const navLinks = Array.from(document.querySelectorAll("nav a[href], aside a[href], [role=navigation] a[href]"))
919
+ .filter(vis).slice(0,20).map(a => ({ text: norm(a.textContent), href: a.href }));
920
+ return { headings, textPreview: text, interactiveElements: elems, navLinks };
921
+ })()`;
922
+ var collectPageSnapshot = async (page) => {
923
+ const data = await page.evaluate(EXPLORE_SCRIPT);
924
+ return {
925
+ url: page.url(),
926
+ title: await page.title().catch(() => ""),
927
+ ...data
928
+ };
929
+ };
930
+ var exploreSite = async (baseUrl, outputDir, projectConfig) => {
931
+ const screenshotDir = join3(outputDir, "exploration");
932
+ await mkdir3(screenshotDir, { recursive: true });
933
+ const session = resolveSessionConfig(outputDir);
934
+ const browser = await chromium2.launch({ headless: true });
935
+ const context = await browser.newContext(
936
+ await getContextOptionsWithSession(
937
+ { viewport: { width: 1440, height: 900 } },
938
+ session
939
+ )
940
+ );
941
+ const page = await context.newPage();
942
+ const pages = [];
943
+ const screenshotPaths = [];
944
+ const visited = /* @__PURE__ */ new Set();
945
+ const startUrls = ["/"];
946
+ if (projectConfig?.preferredRoutes) {
947
+ startUrls.push(...projectConfig.preferredRoutes);
948
+ }
949
+ try {
950
+ for (const route of startUrls) {
951
+ const fullUrl = new URL(route, baseUrl).toString();
952
+ const normalized = new URL(fullUrl).pathname;
953
+ if (visited.has(normalized)) continue;
954
+ visited.add(normalized);
955
+ try {
956
+ await page.goto(fullUrl, { waitUntil: "networkidle", timeout: 2e4 });
957
+ } catch {
958
+ try {
959
+ await page.goto(fullUrl, { waitUntil: "domcontentloaded", timeout: 2e4 });
960
+ await page.waitForTimeout(1500);
961
+ } catch {
962
+ continue;
963
+ }
964
+ }
965
+ const snapshot = await collectPageSnapshot(page);
966
+ pages.push(snapshot);
967
+ const ssPath = join3(screenshotDir, `page-${pages.length}.png`);
968
+ await page.screenshot({ path: ssPath });
969
+ screenshotPaths.push(ssPath);
970
+ if (pages.length >= 4) break;
971
+ for (const link of snapshot.navLinks) {
972
+ if (pages.length >= 4) break;
973
+ try {
974
+ const linkPath = new URL(link.href).pathname;
975
+ if (visited.has(linkPath)) continue;
976
+ if (!link.href.startsWith(baseUrl)) continue;
977
+ visited.add(linkPath);
978
+ await page.goto(link.href, { waitUntil: "networkidle", timeout: 15e3 });
979
+ const navSnapshot = await collectPageSnapshot(page);
980
+ pages.push(navSnapshot);
981
+ const navSsPath = join3(screenshotDir, `page-${pages.length}.png`);
982
+ await page.screenshot({ path: navSsPath });
983
+ screenshotPaths.push(navSsPath);
984
+ } catch {
985
+ continue;
986
+ }
987
+ }
988
+ }
989
+ } finally {
990
+ await context.close();
991
+ await browser.close();
992
+ }
993
+ return { pages, screenshotPaths };
994
+ };
995
+ var buildPromptPlannerPrompt = (userPrompt, exploration, projectConfig) => {
996
+ const pageDescriptions = exploration.pages.map((p, i) => {
997
+ const elements = p.interactiveElements.map((el) => {
998
+ const parts = [`${el.role}: "${el.name}"`];
999
+ if (el.href) parts.push(`\u2192 ${el.href}`);
1000
+ if (el.placeholder) parts.push(`(placeholder: "${el.placeholder}")`);
1001
+ if (el.testId) parts.push(`[data-testid="${el.testId}"]`);
1002
+ return ` ${parts.join(" ")}`;
1003
+ }).join("\n");
1004
+ return [
1005
+ `Page ${i + 1}: ${p.title}`,
1006
+ ` URL: ${p.url}`,
1007
+ ` Headings: ${p.headings.join(" | ")}`,
1008
+ ` Content preview: ${p.textPreview.slice(0, 300)}`,
1009
+ ` Interactive elements (${p.interactiveElements.length}):`,
1010
+ elements
1011
+ ].join("\n");
1012
+ });
1013
+ return [
1014
+ "You are a product demo director.",
1015
+ "A user wants to create a demo video of their web app with a single prompt.",
1016
+ "",
1017
+ "USER PROMPT:",
1018
+ `"${userPrompt}"`,
1019
+ "",
1020
+ "SITE EXPLORATION:",
1021
+ "I visited the app and found these pages with their interactive elements:",
1022
+ "",
1023
+ ...pageDescriptions,
1024
+ "",
1025
+ "INSTRUCTIONS:",
1026
+ "1. Create 3-6 scenes that tell the story the user described.",
1027
+ "2. Each scene should have specific, executable actions (navigate, click, hover, fill, scroll, wait, press).",
1028
+ "3. Use REAL element selectors from the exploration above. Prefer strategy: 'text' or 'role' with the exact name/text shown.",
1029
+ "4. Write narration that sounds like a human giving a product tour \u2014 conversational, not robotic.",
1030
+ "5. All URLs must be relative paths (e.g., /inbox not https://app.example.com/inbox).",
1031
+ "6. Keep durationMs between 4000 and 9000 per scene.",
1032
+ "7. Start with a navigate action in the first scene.",
1033
+ "8. Add wait actions (300-800ms) between interactions for pacing.",
1034
+ "9. Use scroll actions to reveal below-the-fold content when relevant.",
1035
+ "",
1036
+ "ACTION TARGET STRATEGIES (use these exact formats):",
1037
+ ' { "strategy": "text", "value": "Button Text", "exact": false } \u2014 match by visible text',
1038
+ ' { "strategy": "role", "role": "button", "name": "Submit" } \u2014 match by ARIA role + name',
1039
+ ' { "strategy": "css", "value": ".my-class" } \u2014 CSS selector',
1040
+ ' { "strategy": "placeholder", "value": "Search..." } \u2014 match by placeholder',
1041
+ ' { "strategy": "testId", "value": "my-test-id" } \u2014 match by data-testid',
1042
+ "",
1043
+ "Project hints:",
1044
+ JSON.stringify(summarizeProjectHints(projectConfig ?? {}), null, 2),
1045
+ "",
1046
+ "EXACT JSON SCHEMA (follow this structure precisely):",
1047
+ JSON.stringify({
1048
+ title: "Demo Title",
1049
+ summary: "One sentence summary",
1050
+ branch: "prompt",
1051
+ generatedAt: "2026-01-01T00:00:00.000Z",
1052
+ scenes: [{
1053
+ id: "scene-1",
1054
+ title: "Scene Title",
1055
+ goal: "What this scene demonstrates",
1056
+ url: "/relative-path",
1057
+ viewport: { width: 1440, height: 900 },
1058
+ actions: [
1059
+ { type: "navigate", url: "/relative-path" },
1060
+ { type: "wait", ms: 500 },
1061
+ { type: "click", target: { strategy: "text", value: "Button Text", exact: false } },
1062
+ { type: "hover", target: { strategy: "role", role: "button", name: "Name" } },
1063
+ { type: "scroll", y: 200 },
1064
+ { type: "fill", target: { strategy: "placeholder", value: "Search..." }, value: "typed text" }
1065
+ ],
1066
+ narration: "Conversational narration for this scene.",
1067
+ caption: "Short caption",
1068
+ durationMs: 6e3,
1069
+ evidenceHints: []
1070
+ }]
1071
+ }, null, 2),
1072
+ "",
1073
+ "Output a strict JSON DemoPlan following the schema above. No markdown, no explanation."
1074
+ ].join("\n");
1075
+ };
1076
+ var buildPromptPlan = async (options) => {
1077
+ const exploration = await exploreSite(
1078
+ options.baseUrl,
1079
+ options.outputDir,
1080
+ options.projectConfig
1081
+ );
1082
+ if (exploration.pages.length === 0) {
1083
+ throw new Error("Could not load any pages from " + options.baseUrl);
1084
+ }
1085
+ const prompt = buildPromptPlannerPrompt(
1086
+ options.prompt,
1087
+ exploration,
1088
+ options.projectConfig
1089
+ );
1090
+ const plan = await requestAiJson({
1091
+ system: "You are a product demo director. You create demo plans from natural language prompts. You have been given a site exploration with real page content and interactive elements. Use ONLY elements that actually exist on the pages. Output strict JSON only.",
1092
+ prompt,
1093
+ schema: demoPlanSchema,
1094
+ temperature: 0.3
1095
+ });
1096
+ if (!plan) {
1097
+ throw new Error(
1098
+ "AI provider could not generate a plan. Set DEMO_OPENAI_API_KEY or use a local AI provider."
1099
+ );
1100
+ }
1101
+ return {
1102
+ ...plan,
1103
+ branch: "prompt",
1104
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1105
+ scenes: plan.scenes.map((scene) => ({
1106
+ ...scene,
1107
+ evidenceHints: scene.evidenceHints ?? []
1108
+ }))
1109
+ };
1110
+ };
1111
+
1112
+ // src/render/visual-plan.ts
1113
+ var CLICK_ZOOM_SCALE = 1.6;
1114
+ var HOVER_ZOOM_SCALE = 1.3;
1115
+ var FILL_ZOOM_SCALE = 1.5;
1116
+ var DEFAULT_ZOOM_SCALE = 1;
1117
+ var MIN_ZOOM_GAP_MS = 800;
1118
+ var IDLE_THRESHOLD_MS = 1500;
1119
+ var IDLE_SPEED = 6;
1120
+ var LOADING_SPEED = 2.5;
1121
+ var SMOOTH_WINDOW = 5;
1122
+ var zoomScaleForEvent = (type) => {
1123
+ switch (type) {
1124
+ case "click":
1125
+ return CLICK_ZOOM_SCALE;
1126
+ case "fill":
1127
+ return FILL_ZOOM_SCALE;
1128
+ case "hover":
1129
+ return HOVER_ZOOM_SCALE;
1130
+ case "select":
1131
+ return CLICK_ZOOM_SCALE;
1132
+ case "dragSelect":
1133
+ return FILL_ZOOM_SCALE;
1134
+ default:
1135
+ return DEFAULT_ZOOM_SCALE;
1136
+ }
1137
+ };
1138
+ var isInteractive = (type) => type === "click" || type === "hover" || type === "fill" || type === "select" || type === "dragSelect" || type === "press";
1139
+ var buildZoomKeyframes = (interactions, viewport) => {
1140
+ const keyframes = [];
1141
+ keyframes.push({
1142
+ atMs: 0,
1143
+ centerX: 0.5,
1144
+ centerY: 0.5,
1145
+ scale: 1,
1146
+ holdMs: 500,
1147
+ easing: "ease-in-out",
1148
+ transitionMs: 0
1149
+ });
1150
+ let lastKeyframeMs = 0;
1151
+ for (const interaction of interactions) {
1152
+ if (!isInteractive(interaction.type)) continue;
1153
+ if (interaction.x == null || interaction.y == null) continue;
1154
+ if (interaction.atMs - lastKeyframeMs < MIN_ZOOM_GAP_MS) continue;
1155
+ const centerX = interaction.x / viewport.width;
1156
+ const centerY = interaction.y / viewport.height;
1157
+ const scale = zoomScaleForEvent(interaction.type);
1158
+ keyframes.push({
1159
+ atMs: interaction.atMs - 300,
1160
+ // Start zooming 300ms before
1161
+ centerX,
1162
+ centerY,
1163
+ scale,
1164
+ holdMs: 600,
1165
+ easing: "spring",
1166
+ transitionMs: 500
1167
+ });
1168
+ keyframes.push({
1169
+ atMs: interaction.atMs + 800,
1170
+ centerX: 0.5,
1171
+ centerY: 0.5,
1172
+ scale: 1,
1173
+ holdMs: 200,
1174
+ easing: "ease-in-out",
1175
+ transitionMs: 600
1176
+ });
1177
+ lastKeyframeMs = interaction.atMs + 800;
1178
+ }
1179
+ return mergeNearbyKeyframes(keyframes);
1180
+ };
1181
+ var mergeNearbyKeyframes = (keyframes) => {
1182
+ if (keyframes.length <= 2) return keyframes;
1183
+ const merged = [keyframes[0]];
1184
+ for (let i = 1; i < keyframes.length; i++) {
1185
+ const prev = merged[merged.length - 1];
1186
+ const curr = keyframes[i];
1187
+ if (curr.atMs - prev.atMs < 400 && Math.abs(curr.scale - prev.scale) < 0.2) {
1188
+ if (curr.scale > prev.scale) {
1189
+ merged[merged.length - 1] = curr;
1190
+ }
1191
+ continue;
1192
+ }
1193
+ merged.push(curr);
1194
+ }
1195
+ return merged;
1196
+ };
1197
+ var buildSpeedSegments = (interactions, sceneMarkers, totalDurationMs) => {
1198
+ const segments = [];
1199
+ const navigations = interactions.filter((i) => i.type === "navigate");
1200
+ const interactiveEvents = interactions.filter((i) => isInteractive(i.type));
1201
+ const moments = [
1202
+ ...interactiveEvents.map((i) => ({ atMs: i.atMs, type: "interactive" })),
1203
+ ...navigations.map((i) => ({ atMs: i.atMs, type: "navigate" }))
1204
+ ].sort((a, b) => a.atMs - b.atMs);
1205
+ if (moments.length === 0) {
1206
+ segments.push({
1207
+ startMs: 0,
1208
+ endMs: totalDurationMs,
1209
+ speed: 1,
1210
+ reason: "normal"
1211
+ });
1212
+ return segments;
1213
+ }
1214
+ let cursor = 0;
1215
+ for (let i = 0; i < moments.length; i++) {
1216
+ const moment = moments[i];
1217
+ const gapMs = moment.atMs - cursor;
1218
+ if (gapMs > IDLE_THRESHOLD_MS) {
1219
+ const idleEnd = moment.atMs - 500;
1220
+ if (idleEnd > cursor) {
1221
+ segments.push({
1222
+ startMs: cursor,
1223
+ endMs: idleEnd,
1224
+ speed: IDLE_SPEED,
1225
+ reason: "idle"
1226
+ });
1227
+ cursor = idleEnd;
1228
+ }
1229
+ }
1230
+ if (moment.type === "navigate") {
1231
+ const nextInteractive = moments.find(
1232
+ (m) => m.atMs > moment.atMs && m.type === "interactive"
1233
+ );
1234
+ const loadEnd = nextInteractive ? Math.min(nextInteractive.atMs - 300, moment.atMs + 3e3) : moment.atMs + 2e3;
1235
+ if (loadEnd > moment.atMs + 500) {
1236
+ segments.push({
1237
+ startMs: cursor,
1238
+ endMs: moment.atMs,
1239
+ speed: 1,
1240
+ reason: "normal"
1241
+ });
1242
+ segments.push({
1243
+ startMs: moment.atMs,
1244
+ endMs: loadEnd,
1245
+ speed: LOADING_SPEED,
1246
+ reason: "loading"
1247
+ });
1248
+ cursor = loadEnd;
1249
+ continue;
1250
+ }
1251
+ }
1252
+ const nextMoment = moments[i + 1];
1253
+ const endMs = nextMoment ? nextMoment.atMs : totalDurationMs;
1254
+ if (cursor < endMs) {
1255
+ segments.push({
1256
+ startMs: cursor,
1257
+ endMs,
1258
+ speed: 1,
1259
+ reason: "normal"
1260
+ });
1261
+ cursor = endMs;
1262
+ }
1263
+ }
1264
+ if (cursor < totalDurationMs) {
1265
+ segments.push({
1266
+ startMs: cursor,
1267
+ endMs: totalDurationMs,
1268
+ speed: 1,
1269
+ reason: "normal"
1270
+ });
1271
+ }
1272
+ return mergeAdjacentSegments(segments);
1273
+ };
1274
+ var mergeAdjacentSegments = (segments) => {
1275
+ if (segments.length <= 1) return segments;
1276
+ const merged = [segments[0]];
1277
+ for (let i = 1; i < segments.length; i++) {
1278
+ const prev = merged[merged.length - 1];
1279
+ const curr = segments[i];
1280
+ if (prev.speed === curr.speed && prev.endMs >= curr.startMs - 10) {
1281
+ prev.endMs = curr.endMs;
1282
+ } else {
1283
+ merged.push(curr);
1284
+ }
1285
+ }
1286
+ return merged;
1287
+ };
1288
+ var smoothCursorLog = (raw, windowSize = SMOOTH_WINDOW) => {
1289
+ if (raw.length === 0) return [];
1290
+ const smoothed = [];
1291
+ for (let i = 0; i < raw.length; i++) {
1292
+ const start = Math.max(0, i - Math.floor(windowSize / 2));
1293
+ const end = Math.min(raw.length, i + Math.ceil(windowSize / 2));
1294
+ let sumX = 0;
1295
+ let sumY = 0;
1296
+ let count = 0;
1297
+ for (let j = start; j < end; j++) {
1298
+ const weight = 1 - Math.abs(j - i) / windowSize;
1299
+ sumX += raw[j].x * weight;
1300
+ sumY += raw[j].y * weight;
1301
+ count += weight;
1302
+ }
1303
+ smoothed.push({
1304
+ atMs: raw[i].atMs,
1305
+ x: Math.round(sumX / count),
1306
+ y: Math.round(sumY / count)
1307
+ });
1308
+ }
1309
+ const downsampled = [];
1310
+ let lastMs = -Infinity;
1311
+ for (const pt of smoothed) {
1312
+ if (pt.atMs - lastMs >= 33) {
1313
+ downsampled.push(pt);
1314
+ lastMs = pt.atMs;
1315
+ }
1316
+ }
1317
+ return downsampled;
1318
+ };
1319
+ var calculateAdjustedDuration = (segments) => {
1320
+ let total = 0;
1321
+ for (const seg of segments) {
1322
+ total += (seg.endMs - seg.startMs) / seg.speed;
1323
+ }
1324
+ return Math.round(total);
1325
+ };
1326
+ var buildVisualPlan = (capture) => {
1327
+ const zoomKeyframes = buildZoomKeyframes(
1328
+ capture.interactions,
1329
+ capture.viewport
1330
+ );
1331
+ const speedSegments = buildSpeedSegments(
1332
+ capture.interactions,
1333
+ capture.sceneMarkers,
1334
+ capture.totalDurationMs
1335
+ );
1336
+ const smoothedCursor = smoothCursorLog(capture.cursorLog);
1337
+ const adjustedDurationMs = calculateAdjustedDuration(speedSegments);
1338
+ return {
1339
+ zoomKeyframes,
1340
+ speedSegments,
1341
+ smoothedCursor,
1342
+ adjustedDurationMs
1343
+ };
1344
+ };
1345
+
1346
+ // src/render/ffmpeg-compose.ts
1347
+ import { execFile as execFile3 } from "child_process";
1348
+ import { writeFile as writeFile2 } from "fs/promises";
1349
+ import { join as join5, dirname as dirname3, resolve as resolve2 } from "path";
1350
+ import { mkdir as mkdir5 } from "fs/promises";
1351
+ import { promisify as promisify3 } from "util";
1352
+
1353
+ // src/render/browser-frame.ts
1354
+ import { execFile as execFile2 } from "child_process";
1355
+ import { mkdir as mkdir4 } from "fs/promises";
1356
+ import { join as join4, dirname as dirname2 } from "path";
1357
+ import { promisify as promisify2 } from "util";
1358
+ import { chromium as chromium3 } from "playwright";
1359
+ var execFileAsync2 = promisify2(execFile2);
1360
+ var DEFAULT_GRADIENT_FROM = "#f97316";
1361
+ var DEFAULT_GRADIENT_TO = "#a855f7";
1362
+ var DEFAULT_PADDING = 48;
1363
+ var CHROME_HEIGHT = 52;
1364
+ var renderFrameTemplate = async (outputPath, contentWidth, contentHeight, options) => {
1365
+ const padding = options.padding ?? DEFAULT_PADDING;
1366
+ const gradFrom = options.gradientFrom ?? DEFAULT_GRADIENT_FROM;
1367
+ const gradTo = options.gradientTo ?? DEFAULT_GRADIENT_TO;
1368
+ const displayUrl = options.displayUrl ?? "app.example.com";
1369
+ const windowWidth = contentWidth;
1370
+ const windowHeight = contentHeight + CHROME_HEIGHT;
1371
+ const canvasWidth = windowWidth + padding * 2;
1372
+ const canvasHeight = windowHeight + padding * 2;
1373
+ const contentOffsetX = padding;
1374
+ const contentOffsetY = padding + CHROME_HEIGHT;
1375
+ const html = `<!DOCTYPE html>
1376
+ <html><head><style>
1377
+ * { margin: 0; padding: 0; box-sizing: border-box; }
1378
+ body {
1379
+ width: ${canvasWidth}px; height: ${canvasHeight}px;
1380
+ background: linear-gradient(135deg, ${gradFrom} 0%, ${gradTo} 100%);
1381
+ display: flex; align-items: center; justify-content: center;
1382
+ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif;
1383
+ }
1384
+ .window {
1385
+ width: ${windowWidth}px; height: ${windowHeight}px;
1386
+ border-radius: 12px;
1387
+ overflow: hidden;
1388
+ box-shadow: 0 25px 60px rgba(0,0,0,0.35), 0 8px 20px rgba(0,0,0,0.2);
1389
+ }
1390
+ .chrome {
1391
+ height: ${CHROME_HEIGHT}px;
1392
+ background: linear-gradient(180deg, #e8e6e3 0%, #d5d3d0 100%);
1393
+ display: flex; align-items: center; padding: 0 16px;
1394
+ border-bottom: 1px solid #c4c2bf;
1395
+ }
1396
+ .traffic-lights { display: flex; gap: 8px; margin-right: 16px; }
1397
+ .dot { width: 12px; height: 12px; border-radius: 50%; }
1398
+ .dot-red { background: #ff5f57; border: 1px solid #e0443e; }
1399
+ .dot-yellow { background: #febc2e; border: 1px solid #d4a020; }
1400
+ .dot-green { background: #28c840; border: 1px solid #1ea633; }
1401
+ .nav-buttons { display: flex; gap: 6px; margin-right: 12px; }
1402
+ .nav-btn { width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; color: #888; font-size: 16px; }
1403
+ .url-bar {
1404
+ flex: 1; height: 30px; border-radius: 6px;
1405
+ background: rgba(255,255,255,0.75); border: 1px solid rgba(0,0,0,0.1);
1406
+ display: flex; align-items: center; justify-content: center;
1407
+ padding: 0 12px; font-size: 13px; color: #555;
1408
+ }
1409
+ .lock { margin-right: 5px; font-size: 11px; color: #888; }
1410
+ .content { width: ${contentWidth}px; height: ${contentHeight}px; background: #fff; }
1411
+ </style></head><body>
1412
+ <div class="window">
1413
+ <div class="chrome">
1414
+ <div class="traffic-lights">
1415
+ <div class="dot dot-red"></div>
1416
+ <div class="dot dot-yellow"></div>
1417
+ <div class="dot dot-green"></div>
1418
+ </div>
1419
+ <div class="nav-buttons">
1420
+ <div class="nav-btn">&larr;</div>
1421
+ <div class="nav-btn">&rarr;</div>
1422
+ </div>
1423
+ <div class="url-bar">
1424
+ <span class="lock">&#x1f512;</span>
1425
+ ${escapeHtml(displayUrl)}
1426
+ </div>
1427
+ </div>
1428
+ <div class="content"></div>
1429
+ </div>
1430
+ </body></html>`;
1431
+ await mkdir4(dirname2(outputPath), { recursive: true });
1432
+ const browser = await chromium3.launch({ headless: true });
1433
+ const page = await browser.newPage({ viewport: { width: canvasWidth, height: canvasHeight } });
1434
+ await page.setContent(html, { waitUntil: "networkidle" });
1435
+ await page.screenshot({ path: outputPath, omitBackground: false });
1436
+ await browser.close();
1437
+ return { canvasWidth, canvasHeight, contentOffsetX, contentOffsetY };
1438
+ };
1439
+ var escapeHtml = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1440
+ var applyBrowserFrame = async (inputPath, outputPath, contentWidth, contentHeight, options, crf, preset) => {
1441
+ const tempDir = join4(dirname2(outputPath), ".frame-temp");
1442
+ await mkdir4(tempDir, { recursive: true });
1443
+ const framePngPath = join4(tempDir, "browser-frame.png");
1444
+ const { canvasWidth, canvasHeight, contentOffsetX, contentOffsetY } = await renderFrameTemplate(
1445
+ framePngPath,
1446
+ contentWidth,
1447
+ contentHeight,
1448
+ options
1449
+ );
1450
+ await execFileAsync2("ffmpeg", [
1451
+ "-i",
1452
+ inputPath,
1453
+ "-i",
1454
+ framePngPath,
1455
+ "-filter_complex",
1456
+ `[0:v]scale=${contentWidth}:${contentHeight}:flags=lanczos[vid];[1:v]loop=loop=-1:size=1:start=0,setpts=N/FRAME_RATE/TB[frame];[frame][vid]overlay=${contentOffsetX}:${contentOffsetY}:shortest=1[out]`,
1457
+ "-map",
1458
+ "[out]",
1459
+ "-c:v",
1460
+ "libx264",
1461
+ "-preset",
1462
+ preset,
1463
+ "-crf",
1464
+ String(crf),
1465
+ "-pix_fmt",
1466
+ "yuv420p",
1467
+ "-an",
1468
+ "-y",
1469
+ outputPath
1470
+ ]);
1471
+ return { outputWidth: canvasWidth, outputHeight: canvasHeight };
1472
+ };
1473
+
1474
+ // src/render/ffmpeg-compose.ts
1475
+ var execFileAsync3 = promisify3(execFile3);
1476
+ var QUALITY_PRESETS = {
1477
+ draft: { crf: 28, preset: "ultrafast", fps: 24 },
1478
+ standard: { crf: 18, preset: "fast", fps: 30 },
1479
+ high: { crf: 12, preset: "slow", fps: 60 }
1480
+ };
1481
+ var hasDrawtext = async () => {
1482
+ try {
1483
+ const { stdout } = await execFileAsync3("ffmpeg", ["-filters"], { maxBuffer: 1024 * 1024 });
1484
+ return stdout.includes("drawtext");
1485
+ } catch {
1486
+ return false;
1487
+ }
1488
+ };
1489
+ var generateTitleCard = async (title, outputPath, width, height, durationSec, fps) => {
1490
+ await mkdir5(dirname3(outputPath), { recursive: true });
1491
+ const canDrawText = await hasDrawtext();
1492
+ if (canDrawText) {
1493
+ await execFileAsync3("ffmpeg", [
1494
+ "-f",
1495
+ "lavfi",
1496
+ "-i",
1497
+ `color=c=0x171410:s=${width}x${height}:d=${durationSec}:r=${fps}`,
1498
+ "-vf",
1499
+ `drawtext=text='${escapeFFmpegText(title)}':fontsize=48:fontcolor=white:x=(w-text_w)/2:y=(h-text_h)/2`,
1500
+ "-c:v",
1501
+ "libx264",
1502
+ "-pix_fmt",
1503
+ "yuv420p",
1504
+ "-y",
1505
+ outputPath
1506
+ ]);
1507
+ } else {
1508
+ await execFileAsync3("ffmpeg", [
1509
+ "-f",
1510
+ "lavfi",
1511
+ "-i",
1512
+ `color=c=0x171410:s=${width}x${height}:d=${durationSec}:r=${fps}`,
1513
+ "-c:v",
1514
+ "libx264",
1515
+ "-pix_fmt",
1516
+ "yuv420p",
1517
+ "-y",
1518
+ outputPath
1519
+ ]);
1520
+ }
1521
+ };
1522
+ var escapeFFmpegText = (text) => text.replace(/'/g, "'\\''").replace(/:/g, "\\:").replace(/\\/g, "\\\\");
1523
+ var buildNarrationTracks = (voiceLines, sceneMarkers, speedSegments, interactions) => {
1524
+ const tracks = [];
1525
+ const markerBySceneId = new Map(sceneMarkers.map((m) => [m.sceneId, m]));
1526
+ for (const line of voiceLines) {
1527
+ if (!line.audioPath) continue;
1528
+ const marker = markerBySceneId.get(line.sceneId);
1529
+ if (!marker) continue;
1530
+ const outputTimeSec = rawTimeToOutputTime(marker.startMs, speedSegments) / 1e3;
1531
+ const sceneInteractions = interactions.filter(
1532
+ (i) => i.sceneId === marker.sceneId && i.type !== "scene-start" && i.type !== "scene-end"
1533
+ );
1534
+ const firstInteraction = sceneInteractions[0];
1535
+ let anchorSec = outputTimeSec;
1536
+ if (firstInteraction) {
1537
+ const interactionOutputSec = rawTimeToOutputTime(firstInteraction.atMs, speedSegments) / 1e3;
1538
+ anchorSec = Math.min(interactionOutputSec + 0.3, outputTimeSec + 3);
1539
+ anchorSec = Math.max(anchorSec, outputTimeSec);
1540
+ }
1541
+ const prev = tracks[tracks.length - 1];
1542
+ if (prev) {
1543
+ const prevVoice = voiceLines.find((v) => v.audioPath === prev.path);
1544
+ const prevEndSec = prev.startSec + (prevVoice?.audioDurationMs ?? 0) / 1e3;
1545
+ if (anchorSec < prevEndSec) {
1546
+ anchorSec = prevEndSec + 0.3;
1547
+ }
1548
+ }
1549
+ tracks.push({
1550
+ path: line.audioPath,
1551
+ startSec: anchorSec
1552
+ });
1553
+ }
1554
+ return tracks;
1555
+ };
1556
+ var rawTimeToOutputTime = (rawMs, segments) => {
1557
+ let outputMs = 0;
1558
+ for (const seg of segments) {
1559
+ if (rawMs <= seg.startMs) break;
1560
+ const segStart = seg.startMs;
1561
+ const segEnd = Math.min(seg.endMs, rawMs);
1562
+ const segDuration = segEnd - segStart;
1563
+ outputMs += segDuration / seg.speed;
1564
+ if (rawMs <= seg.endMs) break;
1565
+ }
1566
+ return outputMs;
1567
+ };
1568
+ var composeVideo = async (options) => {
1569
+ const q = QUALITY_PRESETS[options.quality ?? "standard"];
1570
+ const width = options.width ?? options.capture.viewport.width;
1571
+ const height = options.height ?? options.capture.viewport.height;
1572
+ const fps = options.fps ?? q.fps;
1573
+ await mkdir5(dirname3(options.outputPath), { recursive: true });
1574
+ const tempDir = join5(dirname3(options.outputPath), ".ffmpeg-temp");
1575
+ await mkdir5(tempDir, { recursive: true });
1576
+ const speedAdjustedPath = join5(tempDir, "speed-adjusted.mp4");
1577
+ await applySpeedRamps(
1578
+ options.videoPath,
1579
+ speedAdjustedPath,
1580
+ options.visualPlan.speedSegments,
1581
+ fps,
1582
+ width,
1583
+ height,
1584
+ q.crf,
1585
+ q.preset
1586
+ );
1587
+ let mainVideoPath = speedAdjustedPath;
1588
+ let finalWidth = width;
1589
+ let finalHeight = height;
1590
+ if (options.frame) {
1591
+ const frameOpts = options.frame === true ? {} : options.frame;
1592
+ if (!frameOpts.displayUrl && options.capture.sceneMarkers[0]) {
1593
+ try {
1594
+ frameOpts.displayUrl = new URL(options.capture.sceneMarkers[0].url).host;
1595
+ } catch {
1596
+ }
1597
+ }
1598
+ const framedPath = join5(tempDir, "framed.mp4");
1599
+ const { outputWidth, outputHeight } = await applyBrowserFrame(
1600
+ speedAdjustedPath,
1601
+ framedPath,
1602
+ width,
1603
+ height,
1604
+ frameOpts,
1605
+ q.crf,
1606
+ q.preset
1607
+ );
1608
+ mainVideoPath = framedPath;
1609
+ finalWidth = outputWidth;
1610
+ finalHeight = outputHeight;
1611
+ }
1612
+ let introPath;
1613
+ if (options.title) {
1614
+ introPath = join5(tempDir, "intro.mp4");
1615
+ await generateTitleCard(options.title, introPath, finalWidth, finalHeight, 2, fps);
1616
+ }
1617
+ const concatPath = introPath ? join5(tempDir, "concatenated.mp4") : mainVideoPath;
1618
+ if (introPath) {
1619
+ await concatVideos([introPath, mainVideoPath], concatPath);
1620
+ }
1621
+ if (options.voiceLines?.length || options.bgm) {
1622
+ await mixAudio(
1623
+ concatPath,
1624
+ options.outputPath,
1625
+ options.voiceLines ?? [],
1626
+ options.capture.sceneMarkers,
1627
+ options.visualPlan.speedSegments,
1628
+ options.capture.interactions,
1629
+ options.bgm,
1630
+ introPath ? 2 : 0
1631
+ );
1632
+ } else {
1633
+ await execFileAsync3("ffmpeg", [
1634
+ "-i",
1635
+ concatPath,
1636
+ "-c",
1637
+ "copy",
1638
+ "-y",
1639
+ options.outputPath
1640
+ ]);
1641
+ }
1642
+ return options.outputPath;
1643
+ };
1644
+ var applySpeedRamps = async (inputPath, outputPath, segments, fps, width, height, crf, preset) => {
1645
+ const allNormal = segments.every((s) => s.speed === 1);
1646
+ if (allNormal || segments.length <= 1) {
1647
+ await execFileAsync3("ffmpeg", [
1648
+ "-i",
1649
+ inputPath,
1650
+ "-vf",
1651
+ `scale=${width}:${height}:flags=lanczos,fps=${fps}`,
1652
+ "-c:v",
1653
+ "libx264",
1654
+ "-preset",
1655
+ preset,
1656
+ "-crf",
1657
+ String(crf),
1658
+ "-pix_fmt",
1659
+ "yuv420p",
1660
+ "-an",
1661
+ "-y",
1662
+ outputPath
1663
+ ]);
1664
+ return;
1665
+ }
1666
+ const tempDir = dirname3(outputPath);
1667
+ const segmentPaths = [];
1668
+ for (let i = 0; i < segments.length; i++) {
1669
+ const seg = segments[i];
1670
+ const segPath = join5(tempDir, `seg-${i}.mp4`);
1671
+ const startSec = (seg.startMs / 1e3).toFixed(3);
1672
+ const durationSec = ((seg.endMs - seg.startMs) / 1e3).toFixed(3);
1673
+ const ptsExpr = seg.speed === 1 ? "PTS-STARTPTS" : `(PTS-STARTPTS)/${seg.speed.toFixed(2)}`;
1674
+ await execFileAsync3("ffmpeg", [
1675
+ "-ss",
1676
+ startSec,
1677
+ "-t",
1678
+ durationSec,
1679
+ "-i",
1680
+ inputPath,
1681
+ "-vf",
1682
+ `scale=${width}:${height}:flags=lanczos,fps=${fps},setpts=${ptsExpr}`,
1683
+ "-c:v",
1684
+ "libx264",
1685
+ "-preset",
1686
+ preset,
1687
+ "-crf",
1688
+ String(crf),
1689
+ "-pix_fmt",
1690
+ "yuv420p",
1691
+ "-an",
1692
+ "-y",
1693
+ segPath
1694
+ ]);
1695
+ segmentPaths.push(segPath);
1696
+ }
1697
+ const listPath = join5(tempDir, "speed-concat.txt");
1698
+ const listContent = segmentPaths.map((p) => `file '${resolve2(p)}'`).join("\n");
1699
+ await writeFile2(listPath, listContent, "utf-8");
1700
+ await execFileAsync3("ffmpeg", [
1701
+ "-f",
1702
+ "concat",
1703
+ "-safe",
1704
+ "0",
1705
+ "-i",
1706
+ listPath,
1707
+ "-c",
1708
+ "copy",
1709
+ "-y",
1710
+ outputPath
1711
+ ]);
1712
+ };
1713
+ var concatVideos = async (inputs, outputPath) => {
1714
+ if (inputs.length === 1) {
1715
+ await execFileAsync3("ffmpeg", ["-i", inputs[0], "-c", "copy", "-y", outputPath]);
1716
+ return;
1717
+ }
1718
+ const inputArgs = inputs.flatMap((p) => ["-i", resolve2(p)]);
1719
+ const filterParts = inputs.map((_, i) => `[${i}:v]`).join("");
1720
+ const filter = `${filterParts}concat=n=${inputs.length}:v=1:a=0[outv]`;
1721
+ await execFileAsync3("ffmpeg", [
1722
+ ...inputArgs,
1723
+ "-filter_complex",
1724
+ filter,
1725
+ "-map",
1726
+ "[outv]",
1727
+ "-c:v",
1728
+ "libx264",
1729
+ "-preset",
1730
+ "fast",
1731
+ "-crf",
1732
+ "18",
1733
+ "-pix_fmt",
1734
+ "yuv420p",
1735
+ "-y",
1736
+ outputPath
1737
+ ]);
1738
+ };
1739
+ var mixAudio = async (videoPath, outputPath, voiceLines, sceneMarkers, speedSegments, interactions, bgm, introOffsetSec) => {
1740
+ const narrationTracks = buildNarrationTracks(voiceLines, sceneMarkers, speedSegments, interactions);
1741
+ const adjustedTracks = narrationTracks.map((t) => ({
1742
+ ...t,
1743
+ startSec: t.startSec + introOffsetSec
1744
+ }));
1745
+ if (adjustedTracks.length === 0 && !bgm) {
1746
+ await execFileAsync3("ffmpeg", ["-i", videoPath, "-c", "copy", "-y", outputPath]);
1747
+ return;
1748
+ }
1749
+ const inputs = ["-i", videoPath];
1750
+ const filterParts = [];
1751
+ let streamIdx = 1;
1752
+ filterParts.push(`anullsrc=r=44100:cl=stereo[silence]`);
1753
+ const overlayLabels = ["[silence]"];
1754
+ for (const track of adjustedTracks) {
1755
+ inputs.push("-i", track.path);
1756
+ const delayMs = Math.round(track.startSec * 1e3);
1757
+ filterParts.push(
1758
+ `[${streamIdx}]aresample=44100,adelay=${delayMs}|${delayMs}:all=1,apad[narr${streamIdx}]`
1759
+ );
1760
+ overlayLabels.push(`[narr${streamIdx}]`);
1761
+ streamIdx++;
1762
+ }
1763
+ if (bgm) {
1764
+ inputs.push("-i", bgm.path);
1765
+ const vol = bgm.volume ?? 0.16;
1766
+ filterParts.push(
1767
+ `[${streamIdx}]aresample=44100,volume=${vol.toFixed(2)},aloop=loop=-1:size=2e+09,apad[bgm]`
1768
+ );
1769
+ overlayLabels.push("[bgm]");
1770
+ streamIdx++;
1771
+ }
1772
+ filterParts.push(
1773
+ `${overlayLabels.join("")}amix=inputs=${overlayLabels.length}:duration=first:normalize=0[aout]`
1774
+ );
1775
+ const filterComplex = filterParts.join(";");
1776
+ await execFileAsync3("ffmpeg", [
1777
+ ...inputs,
1778
+ "-filter_complex",
1779
+ filterComplex,
1780
+ "-map",
1781
+ "0:v",
1782
+ "-map",
1783
+ "[aout]",
1784
+ "-c:v",
1785
+ "copy",
1786
+ "-c:a",
1787
+ "aac",
1788
+ "-b:a",
1789
+ "192k",
1790
+ "-shortest",
1791
+ "-y",
1792
+ outputPath
1793
+ ]);
1794
+ };
1795
+
1796
+ // src/lib/fs.ts
1797
+ import { mkdir as mkdir6, writeFile as writeFile3 } from "fs/promises";
1798
+ import { dirname as dirname4 } from "path";
1799
+ var writeJson = async (path, value) => {
1800
+ await mkdir6(dirname4(path), { recursive: true });
1801
+ await writeFile3(path, JSON.stringify(value, null, 2) + "\n", "utf8");
1802
+ };
1803
+
1804
+ // src/voice/script.ts
1805
+ var estimateMs = (text) => {
1806
+ const words = text.trim().split(/\s+/).filter(Boolean).length;
1807
+ const chineseChars = text.replace(/\s+/g, "").length;
1808
+ return Math.max(2500, Math.round((words > 1 ? words : chineseChars) * 280));
1809
+ };
1810
+ var tokenizeNarration = (text, estimatedMs) => {
1811
+ const tokens = text.match(/\S+\s*/g) ?? [text];
1812
+ const weighted = tokens.map((token) => ({
1813
+ text: token,
1814
+ weight: Math.max(token.replace(/\s+/g, "").length, 1)
1815
+ }));
1816
+ const totalWeight = weighted.reduce((sum, token) => sum + token.weight, 0) || 1;
1817
+ let cursor = 0;
1818
+ return weighted.map((token, index) => {
1819
+ const remaining = estimatedMs - cursor;
1820
+ const sliceMs = index === weighted.length - 1 ? remaining : Math.max(120, Math.round(token.weight / totalWeight * estimatedMs));
1821
+ const startMs = cursor;
1822
+ const endMs = Math.min(estimatedMs, startMs + sliceMs);
1823
+ cursor = endMs;
1824
+ return {
1825
+ text: token.text,
1826
+ startMs,
1827
+ endMs
1828
+ };
1829
+ });
1830
+ };
1831
+ var buildVoiceScript = (plan) => {
1832
+ return plan.scenes.map((scene) => {
1833
+ const estimatedMs = estimateMs(scene.narration);
1834
+ return {
1835
+ sceneId: scene.id,
1836
+ text: scene.narration,
1837
+ estimatedMs,
1838
+ tokens: tokenizeNarration(scene.narration, estimatedMs)
1839
+ };
1840
+ });
1841
+ };
1842
+
1843
+ // src/voice/tts.ts
1844
+ import { execFile as execFile5 } from "child_process";
1845
+ import { access as access4, mkdir as mkdir7, mkdtemp as mkdtemp2, rm as rm2, writeFile as writeFile4 } from "fs/promises";
1846
+ import { tmpdir as tmpdir2 } from "os";
1847
+ import { join as join6 } from "path";
1848
+ import { promisify as promisify5 } from "util";
1849
+
1850
+ // src/lib/data-uri.ts
1851
+ import { readFile as readFile3 } from "fs/promises";
1852
+ import { extname } from "path";
1853
+ var MIME_TYPES = {
1854
+ ".png": "image/png",
1855
+ ".jpg": "image/jpeg",
1856
+ ".jpeg": "image/jpeg",
1857
+ ".webp": "image/webp",
1858
+ ".mp3": "audio/mpeg",
1859
+ ".wav": "audio/wav",
1860
+ ".m4a": "audio/mp4",
1861
+ ".webm": "video/webm",
1862
+ ".mp4": "video/mp4"
1863
+ };
1864
+ var fileToDataUri = async (path) => {
1865
+ const buffer = await readFile3(path);
1866
+ const ext = extname(path).toLowerCase();
1867
+ const mimeType = MIME_TYPES[ext] ?? "application/octet-stream";
1868
+ return `data:${mimeType};base64,${buffer.toString("base64")}`;
1869
+ };
1870
+
1871
+ // src/lib/media.ts
1872
+ import { execFile as execFile4 } from "child_process";
1873
+ import { promisify as promisify4 } from "util";
1874
+ var execFileAsync4 = promisify4(execFile4);
1875
+ var getMediaDurationMs = async (path) => {
1876
+ try {
1877
+ const { stdout } = await execFileAsync4("ffprobe", [
1878
+ "-v",
1879
+ "error",
1880
+ "-show_entries",
1881
+ "format=duration",
1882
+ "-of",
1883
+ "default=noprint_wrappers=1:nokey=1",
1884
+ path
1885
+ ]);
1886
+ const seconds = Number(stdout.trim());
1887
+ if (!Number.isFinite(seconds) || seconds <= 0) return void 0;
1888
+ return Math.round(seconds * 1e3);
1889
+ } catch {
1890
+ return void 0;
1891
+ }
1892
+ };
1893
+
1894
+ // src/voice/tts.ts
1895
+ var execFileAsync5 = promisify5(execFile5);
1896
+ var parseOptionalNumber = (value) => {
1897
+ if (!value?.trim()) return void 0;
1898
+ const parsed = Number(value);
1899
+ return Number.isFinite(parsed) ? parsed : void 0;
1900
+ };
1901
+ var parseOptionalBoolean = (value) => {
1902
+ if (!value?.trim()) return void 0;
1903
+ if (["1", "true", "yes", "on"].includes(value.toLowerCase())) return true;
1904
+ if (["0", "false", "no", "off"].includes(value.toLowerCase())) return false;
1905
+ return void 0;
1906
+ };
1907
+ var normalizeBaseUrl = (baseUrl) => baseUrl.replace(/\/+$/, "");
1908
+ var getTtsConfig = () => {
1909
+ return {
1910
+ provider: process.env.DEMO_TTS_PROVIDER ?? "auto",
1911
+ openAiApiKey: process.env.DEMO_OPENAI_API_KEY,
1912
+ openAiBaseUrl: process.env.DEMO_OPENAI_BASE_URL ?? "https://api.openai.com/v1",
1913
+ openAiModel: process.env.DEMO_TTS_MODEL ?? "gpt-4o-mini-tts",
1914
+ openAiVoice: process.env.DEMO_TTS_VOICE ?? "alloy",
1915
+ elevenlabsApiKey: process.env.DEMO_ELEVENLABS_API_KEY,
1916
+ elevenlabsBaseUrl: process.env.DEMO_ELEVENLABS_BASE_URL ?? "https://api.elevenlabs.io/v1",
1917
+ elevenlabsVoiceId: process.env.DEMO_ELEVENLABS_VOICE_ID,
1918
+ elevenlabsModel: process.env.DEMO_ELEVENLABS_MODEL ?? "eleven_multilingual_v2",
1919
+ elevenlabsOutputFormat: process.env.DEMO_ELEVENLABS_OUTPUT_FORMAT ?? "mp3_44100_128",
1920
+ elevenlabsStability: parseOptionalNumber(process.env.DEMO_ELEVENLABS_STABILITY),
1921
+ elevenlabsSimilarityBoost: parseOptionalNumber(process.env.DEMO_ELEVENLABS_SIMILARITY_BOOST),
1922
+ elevenlabsStyle: parseOptionalNumber(process.env.DEMO_ELEVENLABS_STYLE),
1923
+ elevenlabsSpeakerBoost: parseOptionalBoolean(process.env.DEMO_ELEVENLABS_SPEAKER_BOOST),
1924
+ localVoice: process.env.DEMO_LOCAL_TTS_VOICE ?? "Samantha",
1925
+ localRate: process.env.DEMO_LOCAL_TTS_RATE ?? "185"
1926
+ };
1927
+ };
1928
+ var commandExists2 = async (command) => {
1929
+ try {
1930
+ await access4(command);
1931
+ return true;
1932
+ } catch {
1933
+ try {
1934
+ await execFileAsync5("which", [command]);
1935
+ return true;
1936
+ } catch {
1937
+ return false;
1938
+ }
1939
+ }
1940
+ };
1941
+ var retimeLineToAudioDuration = (line, audioDurationMs) => {
1942
+ if (!audioDurationMs || !line.tokens.length || line.estimatedMs <= 0) {
1943
+ return {
1944
+ ...line,
1945
+ audioDurationMs
1946
+ };
1947
+ }
1948
+ const scale = audioDurationMs / line.estimatedMs;
1949
+ return {
1950
+ ...line,
1951
+ audioDurationMs,
1952
+ tokens: line.tokens.map((token, index) => ({
1953
+ ...token,
1954
+ startMs: Math.max(0, Math.round(token.startMs * scale)),
1955
+ endMs: index === line.tokens.length - 1 ? audioDurationMs : Math.max(1, Math.round(token.endMs * scale))
1956
+ }))
1957
+ };
1958
+ };
1959
+ var writeAudioLine = async (line, audioPath, arrayBuffer) => {
1960
+ await writeFile4(audioPath, Buffer.from(arrayBuffer));
1961
+ const audioDurationMs = await getMediaDurationMs(audioPath);
1962
+ const timedLine = retimeLineToAudioDuration(line, audioDurationMs);
1963
+ return {
1964
+ ...timedLine,
1965
+ audioPath,
1966
+ audioSrc: await fileToDataUri(audioPath)
1967
+ };
1968
+ };
1969
+ var synthesizeOpenAiLine = async (line, outputDir, config) => {
1970
+ if (!config.openAiApiKey) throw new Error("OpenAI TTS not configured. Set DEMO_OPENAI_API_KEY.");
1971
+ const response = await fetch(`${normalizeBaseUrl(config.openAiBaseUrl)}/audio/speech`, {
1972
+ method: "POST",
1973
+ headers: {
1974
+ authorization: `Bearer ${config.openAiApiKey}`,
1975
+ "content-type": "application/json"
1976
+ },
1977
+ body: JSON.stringify({
1978
+ model: config.openAiModel,
1979
+ voice: config.openAiVoice,
1980
+ input: line.text,
1981
+ format: "mp3"
1982
+ })
1983
+ });
1984
+ if (!response.ok) {
1985
+ const errorText = await response.text();
1986
+ throw new Error(`OpenAI TTS request failed: ${response.status} ${errorText}`);
1987
+ }
1988
+ return writeAudioLine(line, join6(outputDir, `${line.sceneId}.mp3`), await response.arrayBuffer());
1989
+ };
1990
+ var synthesizeElevenLabsLine = async (line, outputDir, config) => {
1991
+ if (!config.elevenlabsApiKey) throw new Error("ElevenLabs TTS not configured. Set DEMO_ELEVENLABS_API_KEY.");
1992
+ if (!config.elevenlabsVoiceId) throw new Error("ElevenLabs voice id not configured");
1993
+ const voiceSettings = {
1994
+ ...config.elevenlabsStability === void 0 ? {} : { stability: config.elevenlabsStability },
1995
+ ...config.elevenlabsSimilarityBoost === void 0 ? {} : { similarity_boost: config.elevenlabsSimilarityBoost },
1996
+ ...config.elevenlabsStyle === void 0 ? {} : { style: config.elevenlabsStyle },
1997
+ ...config.elevenlabsSpeakerBoost === void 0 ? {} : { use_speaker_boost: config.elevenlabsSpeakerBoost }
1998
+ };
1999
+ const response = await fetch(
2000
+ `${normalizeBaseUrl(config.elevenlabsBaseUrl)}/text-to-speech/${encodeURIComponent(config.elevenlabsVoiceId)}`,
2001
+ {
2002
+ method: "POST",
2003
+ headers: {
2004
+ accept: "audio/mpeg",
2005
+ "content-type": "application/json",
2006
+ "xi-api-key": config.elevenlabsApiKey
2007
+ },
2008
+ body: JSON.stringify({
2009
+ text: line.text,
2010
+ model_id: config.elevenlabsModel,
2011
+ output_format: config.elevenlabsOutputFormat,
2012
+ ...Object.keys(voiceSettings).length > 0 ? { voice_settings: voiceSettings } : {}
2013
+ })
2014
+ }
2015
+ );
2016
+ if (!response.ok) {
2017
+ const errorText = await response.text();
2018
+ throw new Error(`ElevenLabs TTS request failed: ${response.status} ${errorText}`);
2019
+ }
2020
+ return writeAudioLine(line, join6(outputDir, `${line.sceneId}.mp3`), await response.arrayBuffer());
2021
+ };
2022
+ var synthesizeLocalLine = async (line, outputDir, config) => {
2023
+ const hasSay = await commandExists2("say");
2024
+ const hasFfmpeg = await commandExists2("ffmpeg");
2025
+ if (!hasSay || !hasFfmpeg) {
2026
+ throw new Error("Local TTS tools not available");
2027
+ }
2028
+ const tempDir = await mkdtemp2(join6(tmpdir2(), "demo-dev-tts-"));
2029
+ const aiffPath = join6(tempDir, `${line.sceneId}.aiff`);
2030
+ const audioPath = join6(outputDir, `${line.sceneId}.mp3`);
2031
+ try {
2032
+ await execFileAsync5("say", ["-v", config.localVoice, "-r", config.localRate, "-o", aiffPath, line.text], {
2033
+ maxBuffer: 1024 * 1024 * 10
2034
+ });
2035
+ await execFileAsync5(
2036
+ "ffmpeg",
2037
+ ["-y", "-i", aiffPath, "-codec:a", "libmp3lame", "-q:a", "3", audioPath],
2038
+ { maxBuffer: 1024 * 1024 * 10 }
2039
+ );
2040
+ const audioDurationMs = await getMediaDurationMs(audioPath);
2041
+ const timedLine = retimeLineToAudioDuration(line, audioDurationMs);
2042
+ return {
2043
+ ...timedLine,
2044
+ audioPath,
2045
+ audioSrc: await fileToDataUri(audioPath)
2046
+ };
2047
+ } finally {
2048
+ await rm2(tempDir, { recursive: true, force: true }).catch(() => void 0);
2049
+ }
2050
+ };
2051
+ var getProviderOrder = (config) => {
2052
+ switch (config.provider) {
2053
+ case "elevenlabs":
2054
+ return ["elevenlabs"];
2055
+ case "openai":
2056
+ return ["openai"];
2057
+ case "local":
2058
+ return ["local"];
2059
+ case "auto":
2060
+ default: {
2061
+ const providers = [];
2062
+ if (config.elevenlabsApiKey && config.elevenlabsVoiceId) providers.push("elevenlabs");
2063
+ if (config.openAiApiKey) providers.push("openai");
2064
+ providers.push("local");
2065
+ return providers;
2066
+ }
2067
+ }
2068
+ };
2069
+ var synthesizeWithProvider = async (provider, line, outputDir, config) => {
2070
+ switch (provider) {
2071
+ case "elevenlabs":
2072
+ return synthesizeElevenLabsLine(line, outputDir, config);
2073
+ case "openai":
2074
+ return synthesizeOpenAiLine(line, outputDir, config);
2075
+ case "local":
2076
+ return synthesizeLocalLine(line, outputDir, config);
2077
+ }
2078
+ };
2079
+ var synthesizeVoice = async (lines, options = {}) => {
2080
+ const outputDir = options.outputDir ?? "artifacts/audio";
2081
+ const config = getTtsConfig();
2082
+ const providers = getProviderOrder(config);
2083
+ await mkdir7(outputDir, { recursive: true });
2084
+ const results = [];
2085
+ for (const line of lines) {
2086
+ let synthesized;
2087
+ const errors = [];
2088
+ for (const provider of providers) {
2089
+ try {
2090
+ synthesized = await synthesizeWithProvider(provider, line, outputDir, config);
2091
+ break;
2092
+ } catch (error) {
2093
+ const message = error instanceof Error ? error.message : String(error);
2094
+ errors.push(`${provider}: ${message}`);
2095
+ }
2096
+ }
2097
+ if (synthesized) {
2098
+ results.push(synthesized);
2099
+ continue;
2100
+ }
2101
+ console.warn(`TTS failed for ${line.sceneId}, fallback to text-only`, errors.join(" | "));
2102
+ results.push(line);
2103
+ }
2104
+ return results;
2105
+ };
2106
+ export {
2107
+ applyProjectEnvironment,
2108
+ buildPromptPlan,
2109
+ buildVisualPlan,
2110
+ buildVoiceScript,
2111
+ capturePlanContinuous,
2112
+ composeVideo,
2113
+ loadProjectConfig,
2114
+ synthesizeVoice,
2115
+ writeJson
2116
+ };