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/src/cli.ts ADDED
@@ -0,0 +1,337 @@
1
+ import { join } from "node:path";
2
+ import { defineCommand, runMain } from "citty";
3
+ import * as p from "@clack/prompts";
4
+ import { buildDiffContext } from "./lib/git.js";
5
+ import { writeJson } from "./lib/fs.js";
6
+ import { buildDemoPlan } from "./planner/index.js";
7
+ import { capturePlanContinuous } from "./capture/continuous-capture.js";
8
+ import { buildVoiceScript } from "./voice/script.js";
9
+ import { synthesizeVoice } from "./voice/tts.js";
10
+ import { buildVisualPlan } from "./render/visual-plan.js";
11
+ import { composeVideo } from "./render/ffmpeg-compose.js";
12
+ import { upsertPrComment } from "./github/comment.js";
13
+ import { buildExecutablePlan, runPipeline } from "./orchestrate.js";
14
+ import { bootstrapAuth } from "./auth/bootstrap.js";
15
+ import { listAvailableProviders } from "./ai/provider.js";
16
+ import { applyProjectEnvironment, getProjectConfigField, loadProjectConfig } from "./config/project.js";
17
+ import { initProject } from "./setup/init.js";
18
+ import { runDoctor } from "./setup/doctor.js";
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Shared args & helpers
22
+ // ---------------------------------------------------------------------------
23
+
24
+ const sharedArgs = {
25
+ "base-url": { type: "string" as const, description: "Base URL of the web app" },
26
+ "base-ref": { type: "string" as const, description: "Git base ref", default: "origin/main" },
27
+ "output-dir": { type: "string" as const, description: "Output directory", default: "artifacts" },
28
+ config: { type: "string" as const, description: "Path to demo.dev.config.json" },
29
+ } as const;
30
+
31
+ const resolveConfig = async (args: Record<string, unknown>) => {
32
+ const { path: configPath, config: projectConfig } = await loadProjectConfig(args.config as string | undefined);
33
+ applyProjectEnvironment(projectConfig);
34
+ return {
35
+ configPath,
36
+ projectConfig,
37
+ baseRef: (args["base-ref"] as string) ?? projectConfig.baseRef ?? "origin/main",
38
+ outputDir: (args["output-dir"] as string) ?? projectConfig.outputDir ?? "artifacts",
39
+ baseUrl: (args["base-url"] as string) ?? projectConfig.baseUrl,
40
+ };
41
+ };
42
+
43
+ const requireBaseUrl = (baseUrl: string | undefined, command: string) => {
44
+ if (!baseUrl) {
45
+ p.log.error(`${command} requires --base-url or baseUrl in demo.dev.config.json`);
46
+ process.exit(1);
47
+ }
48
+ return baseUrl;
49
+ };
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Commands
53
+ // ---------------------------------------------------------------------------
54
+
55
+ const init = defineCommand({
56
+ meta: { name: "init", description: "Initialize demo.dev config in a project" },
57
+ args: {
58
+ ...sharedArgs,
59
+ force: { type: "boolean", description: "Overwrite existing config" },
60
+ },
61
+ async run({ args }) {
62
+ const { projectConfig, baseUrl, baseRef, outputDir } = await resolveConfig(args);
63
+ const s = p.spinner();
64
+ s.start("Creating config");
65
+ const result = await initProject({
66
+ force: args.force,
67
+ existingConfig: { ...projectConfig, baseUrl, baseRef, outputDir },
68
+ });
69
+ s.stop("Config created");
70
+ p.log.success(`Config → ${result.configPath}`);
71
+ p.log.success(`Workflow → ${result.workflowPath}`);
72
+ },
73
+ });
74
+
75
+ const doctor = defineCommand({
76
+ meta: { name: "doctor", description: "Check environment and config" },
77
+ args: sharedArgs,
78
+ async run({ args }) {
79
+ const { configPath, projectConfig, baseUrl, baseRef, outputDir } = await resolveConfig(args);
80
+ const result = await runDoctor({ configPath, config: { ...projectConfig, baseUrl, baseRef, outputDir } });
81
+ if (!result.ok) process.exitCode = 1;
82
+ },
83
+ });
84
+
85
+ const config = defineCommand({
86
+ meta: { name: "config", description: "Show resolved project config" },
87
+ args: {
88
+ ...sharedArgs,
89
+ field: { type: "string", description: "Get a single config field" },
90
+ },
91
+ async run({ args }) {
92
+ const { configPath, projectConfig } = await resolveConfig(args);
93
+ if (args.field) {
94
+ console.log(getProjectConfigField(projectConfig, args.field) ?? "");
95
+ return;
96
+ }
97
+ console.log(JSON.stringify({
98
+ path: configPath,
99
+ ...projectConfig,
100
+ readyUrl: projectConfig.readyUrl ?? projectConfig.baseUrl,
101
+ }, null, 2));
102
+ },
103
+ });
104
+
105
+ const providers = defineCommand({
106
+ meta: { name: "providers", description: "List available AI/TTS providers" },
107
+ async run() {
108
+ const list = await listAvailableProviders();
109
+ console.log(JSON.stringify(list, null, 2));
110
+ },
111
+ });
112
+
113
+ const plan = defineCommand({
114
+ meta: { name: "plan", description: "Generate a demo plan from the current diff" },
115
+ args: sharedArgs,
116
+ async run({ args }) {
117
+ const { projectConfig, baseRef, outputDir } = await resolveConfig(args);
118
+ const s = p.spinner();
119
+ s.start("Analyzing diff");
120
+ const context = await buildDiffContext(baseRef);
121
+ s.stop("Diff analyzed");
122
+ s.start("Planning demo scenes");
123
+ const demoPlan = await buildDemoPlan(context, projectConfig);
124
+ s.stop(`Planned ${demoPlan.scenes.length} scenes`);
125
+ await writeJson(join(outputDir, "demo-context.json"), context);
126
+ await writeJson(join(outputDir, "demo-plan.json"), demoPlan);
127
+ p.log.success(`Plan → ${join(outputDir, "demo-plan.json")}`);
128
+ },
129
+ });
130
+
131
+ const probe = defineCommand({
132
+ meta: { name: "probe", description: "Plan + probe pages to refine the demo" },
133
+ args: sharedArgs,
134
+ async run({ args }) {
135
+ const { projectConfig, baseRef, outputDir, baseUrl } = await resolveConfig(args);
136
+ const resolvedUrl = requireBaseUrl(baseUrl, "probe");
137
+ const s = p.spinner();
138
+ s.start("Building executable plan");
139
+ const result = await buildExecutablePlan({ baseRef, baseUrl: resolvedUrl, outputDir, projectConfig });
140
+ s.stop(`Refined ${result.plan.scenes.length} scenes`);
141
+ p.log.success(`Plan → ${join(outputDir, "demo-plan.json")}`);
142
+ },
143
+ });
144
+
145
+ const auth = defineCommand({
146
+ meta: { name: "auth", description: "Bootstrap browser auth (login + save session)" },
147
+ args: {
148
+ ...sharedArgs,
149
+ email: { type: "string", description: "Login email" },
150
+ password: { type: "string", description: "Login password" },
151
+ "storage-state": { type: "string", description: "Path to save storage state" },
152
+ },
153
+ async run({ args }) {
154
+ const { projectConfig, baseUrl, outputDir } = await resolveConfig(args);
155
+ const resolvedUrl = requireBaseUrl(baseUrl, "auth");
156
+ const email = args.email ?? process.env.DEMO_LOGIN_EMAIL;
157
+ const password = args.password ?? process.env.DEMO_LOGIN_PASSWORD;
158
+ const storageStatePath = args["storage-state"]
159
+ ?? projectConfig.saveStorageStatePath
160
+ ?? process.env.DEMO_SAVE_STORAGE_STATE
161
+ ?? join(outputDir, "storage-state.json");
162
+
163
+ if (!email || !password) {
164
+ p.log.error("Requires --email and --password, or DEMO_LOGIN_EMAIL / DEMO_LOGIN_PASSWORD env vars");
165
+ process.exit(1);
166
+ }
167
+
168
+ const s = p.spinner();
169
+ s.start("Logging in");
170
+ const result = await bootstrapAuth({
171
+ baseUrl: resolvedUrl, email, password,
172
+ outputPath: storageStatePath,
173
+ auth: projectConfig.auth,
174
+ });
175
+ s.stop("Logged in");
176
+ p.log.success(`Session → ${result.storageStatePath}`);
177
+ },
178
+ });
179
+
180
+ const capture = defineCommand({
181
+ meta: { name: "capture", description: "Continuous browser capture with screencast + ghost-cursor" },
182
+ args: sharedArgs,
183
+ async run({ args }) {
184
+ const { projectConfig, baseRef, outputDir, baseUrl } = await resolveConfig(args);
185
+ const resolvedUrl = requireBaseUrl(baseUrl, "capture");
186
+ const s = p.spinner();
187
+ s.start("Building plan");
188
+ const { plan: demoPlan } = await buildExecutablePlan({ baseRef, baseUrl: resolvedUrl, outputDir, projectConfig });
189
+ s.stop(`${demoPlan.scenes.length} scenes planned`);
190
+ s.start("Recording (screencast + ghost-cursor)");
191
+ const result = await capturePlanContinuous(demoPlan, { baseUrl: resolvedUrl, outputDir: join(outputDir, "captures") });
192
+ s.stop(`Recorded ${(result.totalDurationMs / 1000).toFixed(1)}s`);
193
+ await writeJson(join(outputDir, "continuous-capture.json"), {
194
+ videoPath: result.videoPath,
195
+ sceneMarkers: result.sceneMarkers,
196
+ interactions: result.interactions,
197
+ totalDurationMs: result.totalDurationMs,
198
+ viewport: result.viewport,
199
+ cursorSamples: result.cursorLog.length,
200
+ });
201
+ p.log.success(`Video → ${result.videoPath}`);
202
+ },
203
+ });
204
+
205
+ const voice = defineCommand({
206
+ meta: { name: "voice", description: "Generate TTS narration from the demo plan" },
207
+ args: sharedArgs,
208
+ async run({ args }) {
209
+ const { projectConfig, baseRef, outputDir } = await resolveConfig(args);
210
+ const s = p.spinner();
211
+ s.start("Generating plan");
212
+ const context = await buildDiffContext(baseRef);
213
+ const demoPlan = await buildDemoPlan(context, projectConfig);
214
+ s.stop(`${demoPlan.scenes.length} scenes`);
215
+ s.start("Synthesizing voice");
216
+ const lines = await synthesizeVoice(buildVoiceScript(demoPlan), { outputDir: join(outputDir, "audio") });
217
+ s.stop(`${lines.filter(l => l.audioPath).length} audio files generated`);
218
+ await writeJson(join(outputDir, "voice-script.json"), lines);
219
+ p.log.success(`Voice → ${join(outputDir, "voice-script.json")}`);
220
+ },
221
+ });
222
+
223
+ const render = defineCommand({
224
+ meta: { name: "render", description: "Capture + voice + FFmpeg compose → mp4" },
225
+ args: {
226
+ ...sharedArgs,
227
+ out: { type: "string", description: "Output video path" },
228
+ },
229
+ async run({ args }) {
230
+ const { projectConfig, baseRef, outputDir, baseUrl } = await resolveConfig(args);
231
+ const resolvedUrl = requireBaseUrl(baseUrl, "render");
232
+ const s = p.spinner();
233
+
234
+ s.start("Building plan");
235
+ const { plan: demoPlan } = await buildExecutablePlan({ baseRef, baseUrl: resolvedUrl, outputDir, projectConfig });
236
+ s.stop(`${demoPlan.scenes.length} scenes`);
237
+
238
+ s.start("Recording");
239
+ const captureResult = await capturePlanContinuous(demoPlan, { baseUrl: resolvedUrl, outputDir: join(outputDir, "captures") });
240
+ s.stop(`Recorded ${(captureResult.totalDurationMs / 1000).toFixed(1)}s`);
241
+
242
+ s.start("Generating voice");
243
+ const voicedLines = await synthesizeVoice(buildVoiceScript(demoPlan), { outputDir: join(outputDir, "audio") });
244
+ s.stop(`${voicedLines.filter(l => l.audioPath).length} audio tracks`);
245
+
246
+ const visualPlan = buildVisualPlan(captureResult);
247
+ const bgmPath = process.env.DEMO_BGM_PATH;
248
+ const bgmVolume = process.env.DEMO_BGM_VOLUME ? Number(process.env.DEMO_BGM_VOLUME) : undefined;
249
+ const out = args.out ?? join(outputDir, "pr-demo.mp4");
250
+
251
+ s.start("Composing video");
252
+ const videoPath = await composeVideo({
253
+ videoPath: captureResult.videoPath, outputPath: out, visualPlan, capture: captureResult,
254
+ voiceLines: voicedLines,
255
+ bgm: bgmPath ? { path: bgmPath, volume: bgmVolume } : undefined,
256
+ title: demoPlan.title,
257
+ });
258
+ s.stop("Video composed");
259
+ p.log.success(`Video → ${videoPath}`);
260
+ },
261
+ });
262
+
263
+ const comment = defineCommand({
264
+ meta: { name: "comment", description: "Post demo artifacts as a PR comment" },
265
+ args: {
266
+ ...sharedArgs,
267
+ "pr-number": { type: "string", description: "PR number to comment on" },
268
+ },
269
+ async run({ args }) {
270
+ const { outputDir } = await resolveConfig(args);
271
+ if (args["pr-number"]) process.env.DEMO_PR_NUMBER = args["pr-number"];
272
+ const s = p.spinner();
273
+ s.start("Posting PR comment");
274
+ await upsertPrComment({ outputDir });
275
+ s.stop("Comment posted");
276
+ },
277
+ });
278
+
279
+ const demo = defineCommand({
280
+ meta: { name: "demo", description: "Full pipeline: plan → capture → voice → render → mp4" },
281
+ args: {
282
+ ...sharedArgs,
283
+ prompt: { type: "string", description: "Natural language prompt describing the demo (skips diff-based planning)" },
284
+ quality: { type: "enum", options: ["draft", "standard", "high"], description: "Video quality preset", default: "standard" },
285
+ frame: { type: "boolean", description: "Wrap in a Screen Studio–style browser frame with gradient background" },
286
+ },
287
+ async run({ args }) {
288
+ const { projectConfig, baseRef, outputDir, baseUrl } = await resolveConfig(args);
289
+ const resolvedUrl = requireBaseUrl(baseUrl, "demo");
290
+
291
+ p.intro("demo.dev");
292
+
293
+ if (args.prompt) {
294
+ p.log.info(`Prompt mode: "${args.prompt}"`);
295
+ }
296
+
297
+ const result = await runPipeline({
298
+ baseRef, baseUrl: resolvedUrl, outputDir, projectConfig, renderVideo: true,
299
+ prompt: args.prompt,
300
+ quality: args.quality as "draft" | "standard" | "high",
301
+ frame: args.frame,
302
+ });
303
+
304
+ if (result.videoPath) {
305
+ p.log.success(`Video → ${result.videoPath}`);
306
+ }
307
+ p.outro(`Done → ${outputDir}`);
308
+ },
309
+ });
310
+
311
+ // ---------------------------------------------------------------------------
312
+ // Root command
313
+ // ---------------------------------------------------------------------------
314
+
315
+ const main = defineCommand({
316
+ meta: {
317
+ name: "demo-dev",
318
+ version: "0.2.0",
319
+ description: "Turn pull requests into polished product demos",
320
+ },
321
+ subCommands: {
322
+ demo,
323
+ init,
324
+ doctor,
325
+ config,
326
+ providers,
327
+ plan,
328
+ probe,
329
+ auth,
330
+ capture,
331
+ voice,
332
+ render,
333
+ comment,
334
+ },
335
+ });
336
+
337
+ runMain(main);
@@ -0,0 +1,183 @@
1
+ import { access, readFile } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ import type { ActionTarget } from "../types.js";
4
+
5
+ export interface ProjectAuthConfig {
6
+ loginPath?: string;
7
+ emailTarget?: ActionTarget;
8
+ passwordTarget?: ActionTarget;
9
+ submitTarget?: ActionTarget;
10
+ successUrlPattern?: string;
11
+ postSubmitWaitMs?: number;
12
+ }
13
+
14
+ export interface ProjectConfig {
15
+ projectName?: string;
16
+ baseUrl?: string;
17
+ readyUrl?: string;
18
+ devCommand?: string;
19
+ baseRef?: string;
20
+ outputDir?: string;
21
+ storageStatePath?: string;
22
+ saveStorageStatePath?: string;
23
+ preferredRoutes?: string[];
24
+ featureHints?: string[];
25
+ authRequiredRoutes?: string[];
26
+ auth?: ProjectAuthConfig;
27
+ }
28
+
29
+ const DEFAULT_CONFIG_PATHS = ["demo.dev.config.json", ".demo-dev.json"];
30
+
31
+ const fileExists = async (path: string) => {
32
+ try {
33
+ await access(path);
34
+ return true;
35
+ } catch {
36
+ return false;
37
+ }
38
+ };
39
+
40
+ const isRecord = (value: unknown): value is Record<string, unknown> => {
41
+ return typeof value === "object" && value !== null;
42
+ };
43
+
44
+ const readStringArray = (value: unknown): string[] | undefined => {
45
+ if (!Array.isArray(value)) return undefined;
46
+ const items = value.filter((item): item is string => typeof item === "string");
47
+ return items.length > 0 ? items : undefined;
48
+ };
49
+
50
+ const readTarget = (value: unknown): ActionTarget | undefined => {
51
+ if (!isRecord(value) || typeof value.strategy !== "string") return undefined;
52
+
53
+ switch (value.strategy) {
54
+ case "label":
55
+ case "text":
56
+ case "placeholder":
57
+ if (typeof value.value === "string") {
58
+ return {
59
+ strategy: value.strategy,
60
+ value: value.value,
61
+ exact: typeof value.exact === "boolean" ? value.exact : undefined,
62
+ };
63
+ }
64
+ return undefined;
65
+ case "testId":
66
+ case "css":
67
+ if (typeof value.value === "string") {
68
+ return { strategy: value.strategy, value: value.value };
69
+ }
70
+ return undefined;
71
+ case "role":
72
+ if (typeof value.role === "string") {
73
+ return {
74
+ strategy: "role",
75
+ role: value.role,
76
+ name: typeof value.name === "string" ? value.name : undefined,
77
+ exact: typeof value.exact === "boolean" ? value.exact : undefined,
78
+ };
79
+ }
80
+ return undefined;
81
+ default:
82
+ return undefined;
83
+ }
84
+ };
85
+
86
+ const normalizeAuthConfig = (value: unknown): ProjectAuthConfig | undefined => {
87
+ if (!isRecord(value)) return undefined;
88
+
89
+ return {
90
+ loginPath: typeof value.loginPath === "string" ? value.loginPath : undefined,
91
+ emailTarget: readTarget(value.emailTarget),
92
+ passwordTarget: readTarget(value.passwordTarget),
93
+ submitTarget: readTarget(value.submitTarget),
94
+ successUrlPattern: typeof value.successUrlPattern === "string" ? value.successUrlPattern : undefined,
95
+ postSubmitWaitMs: typeof value.postSubmitWaitMs === "number" ? value.postSubmitWaitMs : undefined,
96
+ };
97
+ };
98
+
99
+ const normalizeProjectConfig = (value: unknown): ProjectConfig => {
100
+ if (!isRecord(value)) return {};
101
+
102
+ return {
103
+ projectName: typeof value.projectName === "string" ? value.projectName : undefined,
104
+ baseUrl: typeof value.baseUrl === "string" ? value.baseUrl : undefined,
105
+ readyUrl: typeof value.readyUrl === "string" ? value.readyUrl : undefined,
106
+ devCommand: typeof value.devCommand === "string" ? value.devCommand : undefined,
107
+ baseRef: typeof value.baseRef === "string" ? value.baseRef : undefined,
108
+ outputDir: typeof value.outputDir === "string" ? value.outputDir : undefined,
109
+ storageStatePath: typeof value.storageStatePath === "string" ? value.storageStatePath : undefined,
110
+ saveStorageStatePath: typeof value.saveStorageStatePath === "string" ? value.saveStorageStatePath : undefined,
111
+ preferredRoutes: readStringArray(value.preferredRoutes),
112
+ featureHints: readStringArray(value.featureHints),
113
+ authRequiredRoutes: readStringArray(value.authRequiredRoutes),
114
+ auth: normalizeAuthConfig(value.auth),
115
+ };
116
+ };
117
+
118
+ export const loadProjectConfig = async (configPath?: string): Promise<{ path?: string; config: ProjectConfig }> => {
119
+ const explicitPath = configPath ?? process.env.DEMO_CONFIG;
120
+ const candidatePaths = explicitPath ? [explicitPath] : DEFAULT_CONFIG_PATHS;
121
+
122
+ for (const candidate of candidatePaths) {
123
+ const absolutePath = resolve(candidate);
124
+ if (!(await fileExists(absolutePath))) continue;
125
+
126
+ const parsed = JSON.parse(await readFile(absolutePath, "utf8")) as unknown;
127
+ return {
128
+ path: absolutePath,
129
+ config: normalizeProjectConfig(parsed),
130
+ };
131
+ }
132
+
133
+ return { config: {} };
134
+ };
135
+
136
+ export const applyProjectEnvironment = (config: ProjectConfig) => {
137
+ if (!process.env.DEMO_STORAGE_STATE && config.storageStatePath) {
138
+ process.env.DEMO_STORAGE_STATE = config.storageStatePath;
139
+ }
140
+
141
+ if (!process.env.DEMO_SAVE_STORAGE_STATE && config.saveStorageStatePath) {
142
+ process.env.DEMO_SAVE_STORAGE_STATE = config.saveStorageStatePath;
143
+ }
144
+ };
145
+
146
+ export const getProjectConfigField = (config: ProjectConfig, field: string): string | undefined => {
147
+ switch (field) {
148
+ case "projectName":
149
+ return config.projectName;
150
+ case "baseUrl":
151
+ return config.baseUrl;
152
+ case "readyUrl":
153
+ return config.readyUrl ?? config.baseUrl;
154
+ case "devCommand":
155
+ return config.devCommand;
156
+ case "baseRef":
157
+ return config.baseRef;
158
+ case "outputDir":
159
+ return config.outputDir;
160
+ case "storageStatePath":
161
+ return config.storageStatePath;
162
+ case "saveStorageStatePath":
163
+ return config.saveStorageStatePath;
164
+ case "preferredRoutes":
165
+ return config.preferredRoutes ? JSON.stringify(config.preferredRoutes) : undefined;
166
+ case "featureHints":
167
+ return config.featureHints ? JSON.stringify(config.featureHints) : undefined;
168
+ case "authRequiredRoutes":
169
+ return config.authRequiredRoutes ? JSON.stringify(config.authRequiredRoutes) : undefined;
170
+ case "auth.loginPath":
171
+ return config.auth?.loginPath;
172
+ default:
173
+ return undefined;
174
+ }
175
+ };
176
+
177
+ export const summarizeProjectHints = (config: ProjectConfig) => {
178
+ return {
179
+ preferredRoutes: config.preferredRoutes ?? [],
180
+ featureHints: config.featureHints ?? [],
181
+ authRequiredRoutes: config.authRequiredRoutes ?? [],
182
+ };
183
+ };
@@ -0,0 +1,134 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { DemoPlan } from "../types.js";
4
+
5
+ const COMMENT_MARKER = "<!-- demo-dev-pr-comment -->";
6
+
7
+ interface PullRequestPayload {
8
+ pull_request?: { number?: number };
9
+ }
10
+
11
+ const readJson = async <T>(path: string): Promise<T> => JSON.parse(await readFile(path, "utf8")) as T;
12
+
13
+ const getRequiredEnv = (name: string) => {
14
+ const value = process.env[name];
15
+ if (!value) throw new Error(`Missing environment variable: ${name}`);
16
+ return value;
17
+ };
18
+
19
+ const getPrNumber = async () => {
20
+ const direct = process.env.DEMO_PR_NUMBER;
21
+ if (direct) return Number(direct);
22
+
23
+ const eventPath = process.env.GITHUB_EVENT_PATH;
24
+ if (!eventPath) return undefined;
25
+ const payload = await readJson<PullRequestPayload>(eventPath);
26
+ return payload.pull_request?.number;
27
+ };
28
+
29
+ const buildRunUrl = () => {
30
+ const serverUrl = process.env.GITHUB_SERVER_URL;
31
+ const repository = process.env.GITHUB_REPOSITORY;
32
+ const runId = process.env.GITHUB_RUN_ID;
33
+ if (!serverUrl || !repository || !runId) return undefined;
34
+ return `${serverUrl}/${repository}/actions/runs/${runId}`;
35
+ };
36
+
37
+ const code = (value: string) => `\`${value}\``;
38
+
39
+ const buildArtifactSummary = (outputDir: string) => {
40
+ return [
41
+ `- Video: ${code(join(outputDir, "pr-demo.mp4"))}`,
42
+ `- Cover: ${code(join(outputDir, "cover.png"))}`,
43
+ `- Manifest: ${code(join(outputDir, "render-manifest.json"))}`,
44
+ ].join("\n");
45
+ };
46
+
47
+ const buildBody = (plan: DemoPlan, outputDir: string) => {
48
+ const runUrl = buildRunUrl();
49
+ const sceneLines = plan.scenes.map((scene) => `- **${scene.title}** · ${scene.caption}`).join("\n");
50
+
51
+ return [
52
+ COMMENT_MARKER,
53
+ "## 🎬 PR Demo generated",
54
+ "",
55
+ plan.summary,
56
+ "",
57
+ runUrl ? `- Workflow run: ${runUrl}` : undefined,
58
+ "- Artifacts: download `pr-demo-artifacts` from this workflow run",
59
+ "",
60
+ "### Scenes",
61
+ sceneLines,
62
+ "",
63
+ "### Output files",
64
+ buildArtifactSummary(outputDir),
65
+ "",
66
+ "The cover has been exported as `cover.png`, and the video has been exported as `pr-demo.mp4`.",
67
+ ]
68
+ .filter(Boolean)
69
+ .join("\n");
70
+ };
71
+
72
+ const request = async (path: string, init: RequestInit) => {
73
+ const token = getRequiredEnv("GITHUB_TOKEN");
74
+ const apiUrl = process.env.GITHUB_API_URL ?? "https://api.github.com";
75
+
76
+ const response = await fetch(`${apiUrl}${path}`, {
77
+ ...init,
78
+ headers: {
79
+ authorization: `Bearer ${token}`,
80
+ accept: "application/vnd.github+json",
81
+ "content-type": "application/json",
82
+ ...(init.headers ?? {}),
83
+ },
84
+ });
85
+
86
+ if (!response.ok) {
87
+ const errorText = await response.text();
88
+ throw new Error(`GitHub API error: ${response.status} ${errorText}`);
89
+ }
90
+
91
+ return response;
92
+ };
93
+
94
+ const findExistingCommentId = async (repository: string, prNumber: number) => {
95
+ const response = await request(`/repos/${repository}/issues/${prNumber}/comments?per_page=100`, {
96
+ method: "GET",
97
+ });
98
+ const comments = (await response.json()) as Array<{ id: number; body?: string }>;
99
+ return comments.find((comment) => comment.body?.includes(COMMENT_MARKER))?.id;
100
+ };
101
+
102
+ export const upsertPrComment = async (options: { outputDir: string }) => {
103
+ const repository = process.env.GITHUB_REPOSITORY;
104
+ const token = process.env.GITHUB_TOKEN;
105
+ if (!repository || !token) {
106
+ console.log("Missing GitHub context, skipping PR comment.");
107
+ return;
108
+ }
109
+
110
+ const prNumber = await getPrNumber();
111
+ if (!prNumber) {
112
+ console.log("No PR number found, skipping PR comment.");
113
+ return;
114
+ }
115
+
116
+ const plan = await readJson<DemoPlan>(join(options.outputDir, "demo-plan.json"));
117
+ const body = buildBody(plan, options.outputDir);
118
+ const existingCommentId = await findExistingCommentId(repository, prNumber);
119
+
120
+ if (existingCommentId) {
121
+ await request(`/repos/${repository}/issues/comments/${existingCommentId}`, {
122
+ method: "PATCH",
123
+ body: JSON.stringify({ body }),
124
+ });
125
+ console.log(`Updated PR comment #${existingCommentId}`);
126
+ return;
127
+ }
128
+
129
+ await request(`/repos/${repository}/issues/${prNumber}/comments`, {
130
+ method: "POST",
131
+ body: JSON.stringify({ body }),
132
+ });
133
+ console.log(`Created PR comment on #${prNumber}`);
134
+ };
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export type * from "./types.js";
2
+
3
+ export { capturePlanContinuous } from "./capture/continuous-capture.js";
4
+ export { buildPromptPlan } from "./planner/prompt.js";
5
+ export { buildVisualPlan } from "./render/visual-plan.js";
6
+ export { composeVideo } from "./render/ffmpeg-compose.js";
7
+ export { applyProjectEnvironment, loadProjectConfig } from "./config/project.js";
8
+ export { writeJson } from "./lib/fs.js";
9
+ export { buildVoiceScript } from "./voice/script.js";
10
+ export { synthesizeVoice } from "./voice/tts.js";
@@ -0,0 +1,21 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { extname } from "node:path";
3
+
4
+ const MIME_TYPES: Record<string, string> = {
5
+ ".png": "image/png",
6
+ ".jpg": "image/jpeg",
7
+ ".jpeg": "image/jpeg",
8
+ ".webp": "image/webp",
9
+ ".mp3": "audio/mpeg",
10
+ ".wav": "audio/wav",
11
+ ".m4a": "audio/mp4",
12
+ ".webm": "video/webm",
13
+ ".mp4": "video/mp4",
14
+ };
15
+
16
+ export const fileToDataUri = async (path: string) => {
17
+ const buffer = await readFile(path);
18
+ const ext = extname(path).toLowerCase();
19
+ const mimeType = MIME_TYPES[ext] ?? "application/octet-stream";
20
+ return `data:${mimeType};base64,${buffer.toString("base64")}`;
21
+ };
package/src/lib/fs.ts ADDED
@@ -0,0 +1,7 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+
4
+ export const writeJson = async (path: string, value: unknown) => {
5
+ await mkdir(dirname(path), { recursive: true });
6
+ await writeFile(path, JSON.stringify(value, null, 2) + "\n", "utf8");
7
+ };