@demon-utils/playwright 0.1.3 → 0.1.5

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.
@@ -0,0 +1,303 @@
1
+ #!/usr/bin/env bun
2
+ // @bun
3
+
4
+ // src/bin/demon-demo-review.ts
5
+ import { existsSync, statSync, readdirSync, writeFileSync } from "fs";
6
+ import { resolve, join, basename } from "path";
7
+
8
+ // src/review.ts
9
+ function buildReviewPrompt(filenames) {
10
+ const fileList = filenames.map((f) => `- ${f}`).join(`
11
+ `);
12
+ return `You are given the following .webm demo video filenames:
13
+
14
+ ${fileList}
15
+
16
+ Based on the filenames, generate a JSON object matching this exact schema:
17
+
18
+ {
19
+ "demos": [
20
+ {
21
+ "file": "<filename>",
22
+ "summary": "<a short sentence describing what the demo likely shows>",
23
+ "annotations": [
24
+ { "timestampSeconds": <number>, "text": "<annotation text>" }
25
+ ]
26
+ }
27
+ ]
28
+ }
29
+
30
+ Rules:
31
+ - Return ONLY the JSON object, no markdown fences or extra text.
32
+ - Include one entry in "demos" for each filename, in the same order.
33
+ - Infer the summary and annotations from the filename.
34
+ - Each demo should have at least one annotation starting at timestampSeconds 0.
35
+ - "file" must exactly match the provided filename.`;
36
+ }
37
+ async function invokeClaude(prompt, options) {
38
+ const spawnFn = options?.spawn ?? defaultSpawn;
39
+ const agent = options?.agent ?? "claude";
40
+ const proc = spawnFn([agent, "-p", prompt]);
41
+ const reader = proc.stdout.getReader();
42
+ const chunks = [];
43
+ for (;; ) {
44
+ const { done, value } = await reader.read();
45
+ if (done)
46
+ break;
47
+ chunks.push(value);
48
+ }
49
+ const exitCode = await proc.exitCode;
50
+ const output = new TextDecoder().decode(concatUint8Arrays(chunks));
51
+ if (exitCode !== 0) {
52
+ throw new Error(`claude process exited with code ${exitCode}: ${output.trim()}`);
53
+ }
54
+ return output.trim();
55
+ }
56
+ function parseReviewMetadata(raw) {
57
+ let parsed;
58
+ try {
59
+ parsed = JSON.parse(raw);
60
+ } catch {
61
+ throw new Error(`Invalid JSON from LLM: ${raw.slice(0, 200)}`);
62
+ }
63
+ if (typeof parsed !== "object" || parsed === null || !("demos" in parsed)) {
64
+ throw new Error("Missing 'demos' array in review metadata");
65
+ }
66
+ const obj = parsed;
67
+ if (!Array.isArray(obj["demos"])) {
68
+ throw new Error("'demos' must be an array");
69
+ }
70
+ for (const demo of obj["demos"]) {
71
+ if (typeof demo !== "object" || demo === null) {
72
+ throw new Error("Each demo must be an object");
73
+ }
74
+ const d = demo;
75
+ if (typeof d["file"] !== "string") {
76
+ throw new Error("Each demo must have a 'file' string");
77
+ }
78
+ if (typeof d["summary"] !== "string") {
79
+ throw new Error("Each demo must have a 'summary' string");
80
+ }
81
+ if (!Array.isArray(d["annotations"])) {
82
+ throw new Error("Each demo must have an 'annotations' array");
83
+ }
84
+ for (const ann of d["annotations"]) {
85
+ if (typeof ann !== "object" || ann === null) {
86
+ throw new Error("Each annotation must be an object");
87
+ }
88
+ const a = ann;
89
+ if (typeof a["timestampSeconds"] !== "number") {
90
+ throw new Error("Each annotation must have a 'timestampSeconds' number");
91
+ }
92
+ if (typeof a["text"] !== "string") {
93
+ throw new Error("Each annotation must have a 'text' string");
94
+ }
95
+ }
96
+ }
97
+ return parsed;
98
+ }
99
+ function defaultSpawn(cmd) {
100
+ const [command, ...args] = cmd;
101
+ const proc = Bun.spawn([command, ...args], {
102
+ stdout: "pipe",
103
+ stderr: "pipe"
104
+ });
105
+ return {
106
+ exitCode: proc.exited,
107
+ stdout: proc.stdout
108
+ };
109
+ }
110
+ function concatUint8Arrays(arrays) {
111
+ const totalLength = arrays.reduce((sum, a) => sum + a.length, 0);
112
+ const result = new Uint8Array(totalLength);
113
+ let offset = 0;
114
+ for (const a of arrays) {
115
+ result.set(a, offset);
116
+ offset += a.length;
117
+ }
118
+ return result;
119
+ }
120
+
121
+ // src/html-generator.ts
122
+ function escapeHtml(s) {
123
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
124
+ }
125
+ function escapeAttr(s) {
126
+ return escapeHtml(s);
127
+ }
128
+ function generateReviewHtml(options) {
129
+ const { metadata, title = "Demo Review" } = options;
130
+ if (metadata.demos.length === 0) {
131
+ throw new Error("metadata.demos must not be empty");
132
+ }
133
+ const firstDemo = metadata.demos[0];
134
+ const demoButtons = metadata.demos.map((demo, i) => {
135
+ const activeClass = i === 0 ? ' class="active"' : "";
136
+ return `<li><button data-index="${i}"${activeClass}>${escapeHtml(demo.file)}</button></li>`;
137
+ }).join(`
138
+ `);
139
+ const metadataJson = JSON.stringify(metadata).replace(/<\//g, "<\\/");
140
+ return `<!DOCTYPE html>
141
+ <html lang="en">
142
+ <head>
143
+ <meta charset="UTF-8">
144
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
145
+ <title>${escapeHtml(title)}</title>
146
+ <style>
147
+ * { margin: 0; padding: 0; box-sizing: border-box; }
148
+ body { font-family: system-ui, -apple-system, sans-serif; background: #1a1a2e; color: #e0e0e0; min-height: 100vh; }
149
+ header { padding: 1rem 2rem; background: #16213e; border-bottom: 1px solid #0f3460; }
150
+ header h1 { font-size: 1.4rem; color: #e94560; }
151
+ .review-layout { display: flex; height: calc(100vh - 60px); }
152
+ .video-panel { flex: 4; padding: 1rem; display: flex; align-items: center; justify-content: center; background: #0f0f23; }
153
+ .video-panel video { width: 100%; max-height: 100%; border-radius: 4px; }
154
+ .side-panel { flex: 1; min-width: 260px; max-width: 360px; padding: 1rem; overflow-y: auto; background: #16213e; border-left: 1px solid #0f3460; }
155
+ .side-panel h2 { font-size: 1rem; margin-bottom: 0.5rem; color: #e94560; }
156
+ .side-panel section { margin-bottom: 1.5rem; }
157
+ #demo-list { list-style: none; }
158
+ #demo-list li { margin-bottom: 0.25rem; }
159
+ #demo-list button { width: 100%; text-align: left; padding: 0.4rem 0.6rem; background: #1a1a2e; color: #e0e0e0; border: 1px solid #0f3460; border-radius: 4px; cursor: pointer; font-size: 0.85rem; }
160
+ #demo-list button:hover { background: #0f3460; }
161
+ #demo-list button.active { background: #e94560; color: #fff; border-color: #e94560; }
162
+ #summary-text { font-size: 0.9rem; line-height: 1.5; color: #ccc; }
163
+ #annotations-list { list-style: none; }
164
+ #annotations-list li { margin-bottom: 0.3rem; }
165
+ #annotations-list button { width: 100%; text-align: left; padding: 0.3rem 0.5rem; background: transparent; color: #53a8b6; border: none; cursor: pointer; font-size: 0.85rem; }
166
+ #annotations-list button:hover { color: #e94560; text-decoration: underline; }
167
+ .timestamp { font-weight: bold; margin-right: 0.4rem; color: #e94560; }
168
+ </style>
169
+ </head>
170
+ <body>
171
+ <header>
172
+ <h1>${escapeHtml(title)}</h1>
173
+ </header>
174
+ <main class="review-layout">
175
+ <div class="video-panel">
176
+ <video id="review-video" controls src="${escapeAttr(firstDemo.file)}"></video>
177
+ </div>
178
+ <div class="side-panel">
179
+ <section>
180
+ <h2>Demos</h2>
181
+ <ul id="demo-list">
182
+ ${demoButtons}
183
+ </ul>
184
+ </section>
185
+ <section>
186
+ <h2>Summary</h2>
187
+ <p id="summary-text"></p>
188
+ </section>
189
+ <section>
190
+ <h2>Annotations</h2>
191
+ <ul id="annotations-list"></ul>
192
+ </section>
193
+ </div>
194
+ </main>
195
+ <script>
196
+ (function() {
197
+ var metadata = ${metadataJson};
198
+ var video = document.getElementById("review-video");
199
+ var summaryText = document.getElementById("summary-text");
200
+ var annotationsList = document.getElementById("annotations-list");
201
+ var demoButtons = document.querySelectorAll("#demo-list button");
202
+
203
+ function esc(s) {
204
+ var d = document.createElement("div");
205
+ d.appendChild(document.createTextNode(s));
206
+ return d.innerHTML;
207
+ }
208
+
209
+ function formatTime(seconds) {
210
+ var m = Math.floor(seconds / 60);
211
+ var s = Math.floor(seconds % 60);
212
+ return m + ":" + (s < 10 ? "0" : "") + s;
213
+ }
214
+
215
+ function selectDemo(index) {
216
+ var demo = metadata.demos[index];
217
+ video.src = demo.file;
218
+ video.load();
219
+ summaryText.textContent = demo.summary;
220
+
221
+ demoButtons.forEach(function(btn, i) {
222
+ btn.classList.toggle("active", i === index);
223
+ });
224
+
225
+ var html = "";
226
+ demo.annotations.forEach(function(ann) {
227
+ html += '<li><button data-time="' + ann.timestampSeconds + '">' +
228
+ '<span class="timestamp">' + esc(formatTime(ann.timestampSeconds)) + '</span>' +
229
+ esc(ann.text) + '</button></li>';
230
+ });
231
+ annotationsList.innerHTML = html;
232
+ }
233
+
234
+ demoButtons.forEach(function(btn) {
235
+ btn.addEventListener("click", function() {
236
+ selectDemo(parseInt(btn.getAttribute("data-index"), 10));
237
+ });
238
+ });
239
+
240
+ annotationsList.addEventListener("click", function(e) {
241
+ var btn = e.target.closest("button[data-time]");
242
+ if (btn) {
243
+ video.currentTime = parseFloat(btn.getAttribute("data-time"));
244
+ video.play();
245
+ }
246
+ });
247
+
248
+ selectDemo(0);
249
+ })();
250
+ </script>
251
+ </body>
252
+ </html>`;
253
+ }
254
+
255
+ // src/bin/demon-demo-review.ts
256
+ var dir;
257
+ var agent;
258
+ var args = process.argv.slice(2);
259
+ for (let i = 0;i < args.length; i++) {
260
+ if (args[i] === "--agent") {
261
+ agent = args[++i];
262
+ } else if (!dir) {
263
+ dir = args[i];
264
+ }
265
+ }
266
+ if (!dir) {
267
+ console.error("Usage: demon-demo-review [--agent <path>] <directory>");
268
+ console.error(" Discovers .webm video files in the given directory.");
269
+ process.exit(1);
270
+ }
271
+ var resolved = resolve(dir);
272
+ if (!existsSync(resolved) || !statSync(resolved).isDirectory()) {
273
+ console.error(`Error: "${resolved}" is not a valid directory.`);
274
+ process.exit(1);
275
+ }
276
+ var webmFiles = readdirSync(resolved).filter((f) => f.endsWith(".webm")).map((f) => join(resolved, f)).sort();
277
+ if (webmFiles.length === 0) {
278
+ console.error(`Error: No .webm files found in "${resolved}".`);
279
+ process.exit(1);
280
+ }
281
+ for (const file of webmFiles) {
282
+ console.log(file);
283
+ }
284
+ try {
285
+ const basenames = webmFiles.map((f) => basename(f));
286
+ const prompt = buildReviewPrompt(basenames);
287
+ console.log("Invoking claude to generate review metadata...");
288
+ const rawOutput = await invokeClaude(prompt, { agent });
289
+ const metadata = parseReviewMetadata(rawOutput);
290
+ const outputPath = join(resolved, "review-metadata.json");
291
+ writeFileSync(outputPath, JSON.stringify(metadata, null, 2) + `
292
+ `);
293
+ console.log(`Review metadata written to ${outputPath}`);
294
+ const html = generateReviewHtml({ metadata });
295
+ const htmlPath = join(resolved, "review.html");
296
+ writeFileSync(htmlPath, html);
297
+ console.log(resolve(htmlPath));
298
+ } catch (err) {
299
+ console.error("Error generating review metadata:", err instanceof Error ? err.message : err);
300
+ process.exit(1);
301
+ }
302
+
303
+ //# debugId=8D4EBA82376B7AB564756E2164756E21
@@ -0,0 +1,12 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/bin/demon-demo-review.ts", "../../src/review.ts", "../../src/html-generator.ts"],
4
+ "sourcesContent": [
5
+ "#!/usr/bin/env bun\nimport { existsSync, statSync, readdirSync, writeFileSync } from \"node:fs\";\nimport { resolve, join, basename } from \"node:path\";\n\nimport {\n buildReviewPrompt,\n invokeClaude,\n parseReviewMetadata,\n} from \"../review.ts\";\nimport { generateReviewHtml } from \"../html-generator.ts\";\n\nlet dir: string | undefined;\nlet agent: string | undefined;\n\nconst args = process.argv.slice(2);\nfor (let i = 0; i < args.length; i++) {\n if (args[i] === \"--agent\") {\n agent = args[++i];\n } else if (!dir) {\n dir = args[i];\n }\n}\n\nif (!dir) {\n console.error(\"Usage: demon-demo-review [--agent <path>] <directory>\");\n console.error(\" Discovers .webm video files in the given directory.\");\n process.exit(1);\n}\n\nconst resolved = resolve(dir);\n\nif (!existsSync(resolved) || !statSync(resolved).isDirectory()) {\n console.error(`Error: \"${resolved}\" is not a valid directory.`);\n process.exit(1);\n}\n\nconst webmFiles = readdirSync(resolved)\n .filter((f) => f.endsWith(\".webm\"))\n .map((f) => join(resolved, f))\n .sort();\n\nif (webmFiles.length === 0) {\n console.error(`Error: No .webm files found in \"${resolved}\".`);\n process.exit(1);\n}\n\nfor (const file of webmFiles) {\n console.log(file);\n}\n\ntry {\n const basenames = webmFiles.map((f) => basename(f));\n const prompt = buildReviewPrompt(basenames);\n\n console.log(\"Invoking claude to generate review metadata...\");\n const rawOutput = await invokeClaude(prompt, { agent });\n\n const metadata = parseReviewMetadata(rawOutput);\n const outputPath = join(resolved, \"review-metadata.json\");\n writeFileSync(outputPath, JSON.stringify(metadata, null, 2) + \"\\n\");\n console.log(`Review metadata written to ${outputPath}`);\n\n const html = generateReviewHtml({ metadata });\n const htmlPath = join(resolved, \"review.html\");\n writeFileSync(htmlPath, html);\n console.log(resolve(htmlPath));\n} catch (err) {\n console.error(\n \"Error generating review metadata:\",\n err instanceof Error ? err.message : err,\n );\n process.exit(1);\n}\n",
6
+ "import type { ReviewMetadata } from \"./review-types.ts\";\n\nexport type SpawnFn = (\n cmd: string[],\n) => { exitCode: Promise<number>; stdout: ReadableStream<Uint8Array> };\n\nexport interface InvokeClaudeOptions {\n agent?: string;\n spawn?: SpawnFn;\n}\n\nexport function buildReviewPrompt(filenames: string[]): string {\n const fileList = filenames.map((f) => `- ${f}`).join(\"\\n\");\n\n return `You are given the following .webm demo video filenames:\n\n${fileList}\n\nBased on the filenames, generate a JSON object matching this exact schema:\n\n{\n \"demos\": [\n {\n \"file\": \"<filename>\",\n \"summary\": \"<a short sentence describing what the demo likely shows>\",\n \"annotations\": [\n { \"timestampSeconds\": <number>, \"text\": \"<annotation text>\" }\n ]\n }\n ]\n}\n\nRules:\n- Return ONLY the JSON object, no markdown fences or extra text.\n- Include one entry in \"demos\" for each filename, in the same order.\n- Infer the summary and annotations from the filename.\n- Each demo should have at least one annotation starting at timestampSeconds 0.\n- \"file\" must exactly match the provided filename.`;\n}\n\nexport async function invokeClaude(\n prompt: string,\n options?: InvokeClaudeOptions,\n): Promise<string> {\n const spawnFn = options?.spawn ?? defaultSpawn;\n const agent = options?.agent ?? \"claude\";\n const proc = spawnFn([agent, \"-p\", prompt]);\n\n const reader = proc.stdout.getReader();\n const chunks: Uint8Array[] = [];\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n chunks.push(value);\n }\n\n const exitCode = await proc.exitCode;\n const output = new TextDecoder().decode(\n concatUint8Arrays(chunks),\n );\n\n if (exitCode !== 0) {\n throw new Error(\n `claude process exited with code ${exitCode}: ${output.trim()}`,\n );\n }\n\n return output.trim();\n}\n\nexport function parseReviewMetadata(raw: string): ReviewMetadata {\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n throw new Error(`Invalid JSON from LLM: ${raw.slice(0, 200)}`);\n }\n\n if (typeof parsed !== \"object\" || parsed === null || !(\"demos\" in parsed)) {\n throw new Error(\"Missing 'demos' array in review metadata\");\n }\n\n const obj = parsed as Record<string, unknown>;\n if (!Array.isArray(obj[\"demos\"])) {\n throw new Error(\"'demos' must be an array\");\n }\n\n for (const demo of obj[\"demos\"] as unknown[]) {\n if (typeof demo !== \"object\" || demo === null) {\n throw new Error(\"Each demo must be an object\");\n }\n const d = demo as Record<string, unknown>;\n\n if (typeof d[\"file\"] !== \"string\") {\n throw new Error(\"Each demo must have a 'file' string\");\n }\n if (typeof d[\"summary\"] !== \"string\") {\n throw new Error(\"Each demo must have a 'summary' string\");\n }\n if (!Array.isArray(d[\"annotations\"])) {\n throw new Error(\"Each demo must have an 'annotations' array\");\n }\n\n for (const ann of d[\"annotations\"] as unknown[]) {\n if (typeof ann !== \"object\" || ann === null) {\n throw new Error(\"Each annotation must be an object\");\n }\n const a = ann as Record<string, unknown>;\n if (typeof a[\"timestampSeconds\"] !== \"number\") {\n throw new Error(\"Each annotation must have a 'timestampSeconds' number\");\n }\n if (typeof a[\"text\"] !== \"string\") {\n throw new Error(\"Each annotation must have a 'text' string\");\n }\n }\n }\n\n return parsed as ReviewMetadata;\n}\n\nfunction defaultSpawn(\n cmd: string[],\n): { exitCode: Promise<number>; stdout: ReadableStream<Uint8Array> } {\n const [command, ...args] = cmd;\n const proc = Bun.spawn([command!, ...args], {\n stdout: \"pipe\",\n stderr: \"pipe\",\n });\n return {\n exitCode: proc.exited,\n stdout: proc.stdout as unknown as ReadableStream<Uint8Array>,\n };\n}\n\nfunction concatUint8Arrays(arrays: Uint8Array[]): Uint8Array {\n const totalLength = arrays.reduce((sum, a) => sum + a.length, 0);\n const result = new Uint8Array(totalLength);\n let offset = 0;\n for (const a of arrays) {\n result.set(a, offset);\n offset += a.length;\n }\n return result;\n}\n",
7
+ "import type { ReviewMetadata } from \"./review-types.ts\";\n\nexport interface GenerateReviewHtmlOptions {\n metadata: ReviewMetadata;\n title?: string;\n}\n\nfunction escapeHtml(s: string): string {\n return s\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\")\n .replace(/'/g, \"&#39;\");\n}\n\nfunction escapeAttr(s: string): string {\n return escapeHtml(s);\n}\n\nexport function generateReviewHtml(options: GenerateReviewHtmlOptions): string {\n const { metadata, title = \"Demo Review\" } = options;\n\n if (metadata.demos.length === 0) {\n throw new Error(\"metadata.demos must not be empty\");\n }\n\n const firstDemo = metadata.demos[0];\n\n const demoButtons = metadata.demos\n .map((demo, i) => {\n const activeClass = i === 0 ? ' class=\"active\"' : \"\";\n return `<li><button data-index=\"${i}\"${activeClass}>${escapeHtml(demo.file)}</button></li>`;\n })\n .join(\"\\n \");\n\n const metadataJson = JSON.stringify(metadata).replace(/<\\//g, \"<\\\\/\");\n\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>${escapeHtml(title)}</title>\n <style>\n * { margin: 0; padding: 0; box-sizing: border-box; }\n body { font-family: system-ui, -apple-system, sans-serif; background: #1a1a2e; color: #e0e0e0; min-height: 100vh; }\n header { padding: 1rem 2rem; background: #16213e; border-bottom: 1px solid #0f3460; }\n header h1 { font-size: 1.4rem; color: #e94560; }\n .review-layout { display: flex; height: calc(100vh - 60px); }\n .video-panel { flex: 4; padding: 1rem; display: flex; align-items: center; justify-content: center; background: #0f0f23; }\n .video-panel video { width: 100%; max-height: 100%; border-radius: 4px; }\n .side-panel { flex: 1; min-width: 260px; max-width: 360px; padding: 1rem; overflow-y: auto; background: #16213e; border-left: 1px solid #0f3460; }\n .side-panel h2 { font-size: 1rem; margin-bottom: 0.5rem; color: #e94560; }\n .side-panel section { margin-bottom: 1.5rem; }\n #demo-list { list-style: none; }\n #demo-list li { margin-bottom: 0.25rem; }\n #demo-list button { width: 100%; text-align: left; padding: 0.4rem 0.6rem; background: #1a1a2e; color: #e0e0e0; border: 1px solid #0f3460; border-radius: 4px; cursor: pointer; font-size: 0.85rem; }\n #demo-list button:hover { background: #0f3460; }\n #demo-list button.active { background: #e94560; color: #fff; border-color: #e94560; }\n #summary-text { font-size: 0.9rem; line-height: 1.5; color: #ccc; }\n #annotations-list { list-style: none; }\n #annotations-list li { margin-bottom: 0.3rem; }\n #annotations-list button { width: 100%; text-align: left; padding: 0.3rem 0.5rem; background: transparent; color: #53a8b6; border: none; cursor: pointer; font-size: 0.85rem; }\n #annotations-list button:hover { color: #e94560; text-decoration: underline; }\n .timestamp { font-weight: bold; margin-right: 0.4rem; color: #e94560; }\n </style>\n</head>\n<body>\n <header>\n <h1>${escapeHtml(title)}</h1>\n </header>\n <main class=\"review-layout\">\n <div class=\"video-panel\">\n <video id=\"review-video\" controls src=\"${escapeAttr(firstDemo.file)}\"></video>\n </div>\n <div class=\"side-panel\">\n <section>\n <h2>Demos</h2>\n <ul id=\"demo-list\">\n ${demoButtons}\n </ul>\n </section>\n <section>\n <h2>Summary</h2>\n <p id=\"summary-text\"></p>\n </section>\n <section>\n <h2>Annotations</h2>\n <ul id=\"annotations-list\"></ul>\n </section>\n </div>\n </main>\n <script>\n (function() {\n var metadata = ${metadataJson};\n var video = document.getElementById(\"review-video\");\n var summaryText = document.getElementById(\"summary-text\");\n var annotationsList = document.getElementById(\"annotations-list\");\n var demoButtons = document.querySelectorAll(\"#demo-list button\");\n\n function esc(s) {\n var d = document.createElement(\"div\");\n d.appendChild(document.createTextNode(s));\n return d.innerHTML;\n }\n\n function formatTime(seconds) {\n var m = Math.floor(seconds / 60);\n var s = Math.floor(seconds % 60);\n return m + \":\" + (s < 10 ? \"0\" : \"\") + s;\n }\n\n function selectDemo(index) {\n var demo = metadata.demos[index];\n video.src = demo.file;\n video.load();\n summaryText.textContent = demo.summary;\n\n demoButtons.forEach(function(btn, i) {\n btn.classList.toggle(\"active\", i === index);\n });\n\n var html = \"\";\n demo.annotations.forEach(function(ann) {\n html += '<li><button data-time=\"' + ann.timestampSeconds + '\">' +\n '<span class=\"timestamp\">' + esc(formatTime(ann.timestampSeconds)) + '</span>' +\n esc(ann.text) + '</button></li>';\n });\n annotationsList.innerHTML = html;\n }\n\n demoButtons.forEach(function(btn) {\n btn.addEventListener(\"click\", function() {\n selectDemo(parseInt(btn.getAttribute(\"data-index\"), 10));\n });\n });\n\n annotationsList.addEventListener(\"click\", function(e) {\n var btn = e.target.closest(\"button[data-time]\");\n if (btn) {\n video.currentTime = parseFloat(btn.getAttribute(\"data-time\"));\n video.play();\n }\n });\n\n selectDemo(0);\n })();\n </script>\n</body>\n</html>`;\n}\n"
8
+ ],
9
+ "mappings": ";;;;AACA;AACA;;;ACSO,SAAS,iBAAiB,CAAC,WAA6B;AAAA,EAC7D,MAAM,WAAW,UAAU,IAAI,CAAC,MAAM,KAAK,GAAG,EAAE,KAAK;AAAA,CAAI;AAAA,EAEzD,OAAO;AAAA;AAAA,EAEP;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAwBF,eAAsB,YAAY,CAChC,QACA,SACiB;AAAA,EACjB,MAAM,UAAU,SAAS,SAAS;AAAA,EAClC,MAAM,QAAQ,SAAS,SAAS;AAAA,EAChC,MAAM,OAAO,QAAQ,CAAC,OAAO,MAAM,MAAM,CAAC;AAAA,EAE1C,MAAM,SAAS,KAAK,OAAO,UAAU;AAAA,EACrC,MAAM,SAAuB,CAAC;AAAA,EAC9B,UAAS;AAAA,IACP,QAAQ,MAAM,UAAU,MAAM,OAAO,KAAK;AAAA,IAC1C,IAAI;AAAA,MAAM;AAAA,IACV,OAAO,KAAK,KAAK;AAAA,EACnB;AAAA,EAEA,MAAM,WAAW,MAAM,KAAK;AAAA,EAC5B,MAAM,SAAS,IAAI,YAAY,EAAE,OAC/B,kBAAkB,MAAM,CAC1B;AAAA,EAEA,IAAI,aAAa,GAAG;AAAA,IAClB,MAAM,IAAI,MACR,mCAAmC,aAAa,OAAO,KAAK,GAC9D;AAAA,EACF;AAAA,EAEA,OAAO,OAAO,KAAK;AAAA;AAGd,SAAS,mBAAmB,CAAC,KAA6B;AAAA,EAC/D,IAAI;AAAA,EACJ,IAAI;AAAA,IACF,SAAS,KAAK,MAAM,GAAG;AAAA,IACvB,MAAM;AAAA,IACN,MAAM,IAAI,MAAM,0BAA0B,IAAI,MAAM,GAAG,GAAG,GAAG;AAAA;AAAA,EAG/D,IAAI,OAAO,WAAW,YAAY,WAAW,QAAQ,EAAE,WAAW,SAAS;AAAA,IACzE,MAAM,IAAI,MAAM,0CAA0C;AAAA,EAC5D;AAAA,EAEA,MAAM,MAAM;AAAA,EACZ,IAAI,CAAC,MAAM,QAAQ,IAAI,QAAQ,GAAG;AAAA,IAChC,MAAM,IAAI,MAAM,0BAA0B;AAAA,EAC5C;AAAA,EAEA,WAAW,QAAQ,IAAI,UAAuB;AAAA,IAC5C,IAAI,OAAO,SAAS,YAAY,SAAS,MAAM;AAAA,MAC7C,MAAM,IAAI,MAAM,6BAA6B;AAAA,IAC/C;AAAA,IACA,MAAM,IAAI;AAAA,IAEV,IAAI,OAAO,EAAE,YAAY,UAAU;AAAA,MACjC,MAAM,IAAI,MAAM,qCAAqC;AAAA,IACvD;AAAA,IACA,IAAI,OAAO,EAAE,eAAe,UAAU;AAAA,MACpC,MAAM,IAAI,MAAM,wCAAwC;AAAA,IAC1D;AAAA,IACA,IAAI,CAAC,MAAM,QAAQ,EAAE,cAAc,GAAG;AAAA,MACpC,MAAM,IAAI,MAAM,4CAA4C;AAAA,IAC9D;AAAA,IAEA,WAAW,OAAO,EAAE,gBAA6B;AAAA,MAC/C,IAAI,OAAO,QAAQ,YAAY,QAAQ,MAAM;AAAA,QAC3C,MAAM,IAAI,MAAM,mCAAmC;AAAA,MACrD;AAAA,MACA,MAAM,IAAI;AAAA,MACV,IAAI,OAAO,EAAE,wBAAwB,UAAU;AAAA,QAC7C,MAAM,IAAI,MAAM,uDAAuD;AAAA,MACzE;AAAA,MACA,IAAI,OAAO,EAAE,YAAY,UAAU;AAAA,QACjC,MAAM,IAAI,MAAM,2CAA2C;AAAA,MAC7D;AAAA,IACF;AAAA,EACF;AAAA,EAEA,OAAO;AAAA;AAGT,SAAS,YAAY,CACnB,KACmE;AAAA,EACnE,OAAO,YAAY,QAAQ;AAAA,EAC3B,MAAM,OAAO,IAAI,MAAM,CAAC,SAAU,GAAG,IAAI,GAAG;AAAA,IAC1C,QAAQ;AAAA,IACR,QAAQ;AAAA,EACV,CAAC;AAAA,EACD,OAAO;AAAA,IACL,UAAU,KAAK;AAAA,IACf,QAAQ,KAAK;AAAA,EACf;AAAA;AAGF,SAAS,iBAAiB,CAAC,QAAkC;AAAA,EAC3D,MAAM,cAAc,OAAO,OAAO,CAAC,KAAK,MAAM,MAAM,EAAE,QAAQ,CAAC;AAAA,EAC/D,MAAM,SAAS,IAAI,WAAW,WAAW;AAAA,EACzC,IAAI,SAAS;AAAA,EACb,WAAW,KAAK,QAAQ;AAAA,IACtB,OAAO,IAAI,GAAG,MAAM;AAAA,IACpB,UAAU,EAAE;AAAA,EACd;AAAA,EACA,OAAO;AAAA;;;ACvIT,SAAS,UAAU,CAAC,GAAmB;AAAA,EACrC,OAAO,EACJ,QAAQ,MAAM,OAAO,EACrB,QAAQ,MAAM,MAAM,EACpB,QAAQ,MAAM,MAAM,EACpB,QAAQ,MAAM,QAAQ,EACtB,QAAQ,MAAM,OAAO;AAAA;AAG1B,SAAS,UAAU,CAAC,GAAmB;AAAA,EACrC,OAAO,WAAW,CAAC;AAAA;AAGd,SAAS,kBAAkB,CAAC,SAA4C;AAAA,EAC7E,QAAQ,UAAU,QAAQ,kBAAkB;AAAA,EAE5C,IAAI,SAAS,MAAM,WAAW,GAAG;AAAA,IAC/B,MAAM,IAAI,MAAM,kCAAkC;AAAA,EACpD;AAAA,EAEA,MAAM,YAAY,SAAS,MAAM;AAAA,EAEjC,MAAM,cAAc,SAAS,MAC1B,IAAI,CAAC,MAAM,MAAM;AAAA,IAChB,MAAM,cAAc,MAAM,IAAI,oBAAoB;AAAA,IAClD,OAAO,2BAA2B,KAAK,eAAe,WAAW,KAAK,IAAI;AAAA,GAC3E,EACA,KAAK;AAAA,aAAgB;AAAA,EAExB,MAAM,eAAe,KAAK,UAAU,QAAQ,EAAE,QAAQ,QAAQ,MAAM;AAAA,EAEpE,OAAO;AAAA;AAAA;AAAA;AAAA;AAAA,WAKE,WAAW,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,UA2BjB,WAAW,KAAK;AAAA;AAAA;AAAA;AAAA,+CAIqB,WAAW,UAAU,IAAI;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,cAM1D;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,uBAeS;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;AFpFvB,IAAI;AACJ,IAAI;AAEJ,IAAM,OAAO,QAAQ,KAAK,MAAM,CAAC;AACjC,SAAS,IAAI,EAAG,IAAI,KAAK,QAAQ,KAAK;AAAA,EACpC,IAAI,KAAK,OAAO,WAAW;AAAA,IACzB,QAAQ,KAAK,EAAE;AAAA,EACjB,EAAO,SAAI,CAAC,KAAK;AAAA,IACf,MAAM,KAAK;AAAA,EACb;AACF;AAEA,IAAI,CAAC,KAAK;AAAA,EACR,QAAQ,MAAM,uDAAuD;AAAA,EACrE,QAAQ,MAAM,uDAAuD;AAAA,EACrE,QAAQ,KAAK,CAAC;AAChB;AAEA,IAAM,WAAW,QAAQ,GAAG;AAE5B,IAAI,CAAC,WAAW,QAAQ,KAAK,CAAC,SAAS,QAAQ,EAAE,YAAY,GAAG;AAAA,EAC9D,QAAQ,MAAM,WAAW,qCAAqC;AAAA,EAC9D,QAAQ,KAAK,CAAC;AAChB;AAEA,IAAM,YAAY,YAAY,QAAQ,EACnC,OAAO,CAAC,MAAM,EAAE,SAAS,OAAO,CAAC,EACjC,IAAI,CAAC,MAAM,KAAK,UAAU,CAAC,CAAC,EAC5B,KAAK;AAER,IAAI,UAAU,WAAW,GAAG;AAAA,EAC1B,QAAQ,MAAM,mCAAmC,YAAY;AAAA,EAC7D,QAAQ,KAAK,CAAC;AAChB;AAEA,WAAW,QAAQ,WAAW;AAAA,EAC5B,QAAQ,IAAI,IAAI;AAClB;AAEA,IAAI;AAAA,EACF,MAAM,YAAY,UAAU,IAAI,CAAC,MAAM,SAAS,CAAC,CAAC;AAAA,EAClD,MAAM,SAAS,kBAAkB,SAAS;AAAA,EAE1C,QAAQ,IAAI,gDAAgD;AAAA,EAC5D,MAAM,YAAY,MAAM,aAAa,QAAQ,EAAE,MAAM,CAAC;AAAA,EAEtD,MAAM,WAAW,oBAAoB,SAAS;AAAA,EAC9C,MAAM,aAAa,KAAK,UAAU,sBAAsB;AAAA,EACxD,cAAc,YAAY,KAAK,UAAU,UAAU,MAAM,CAAC,IAAI;AAAA,CAAI;AAAA,EAClE,QAAQ,IAAI,8BAA8B,YAAY;AAAA,EAEtD,MAAM,OAAO,mBAAmB,EAAE,SAAS,CAAC;AAAA,EAC5C,MAAM,WAAW,KAAK,UAAU,aAAa;AAAA,EAC7C,cAAc,UAAU,IAAI;AAAA,EAC5B,QAAQ,IAAI,QAAQ,QAAQ,CAAC;AAAA,EAC7B,OAAO,KAAK;AAAA,EACZ,QAAQ,MACN,qCACA,eAAe,QAAQ,IAAI,UAAU,GACvC;AAAA,EACA,QAAQ,KAAK,CAAC;AAAA;",
10
+ "debugId": "8D4EBA82376B7AB564756E2164756E21",
11
+ "names": []
12
+ }
package/dist/index.js CHANGED
@@ -17,7 +17,7 @@ async function showCommentary(page, options) {
17
17
  @keyframes demon-commentary-in {
18
18
  from {
19
19
  opacity: 0;
20
- transform: translateY(8px);
20
+ transform: translateY(var(--demon-slide-y, 8px));
21
21
  }
22
22
  to {
23
23
  opacity: 1;
@@ -31,10 +31,11 @@ async function showCommentary(page, options) {
31
31
  }
32
32
  to {
33
33
  opacity: 0;
34
- transform: translateY(8px);
34
+ transform: translateY(var(--demon-slide-y, 8px));
35
35
  }
36
36
  }
37
37
  #${tooltipId} {
38
+ --demon-slide-y: 8px;
38
39
  position: fixed;
39
40
  z-index: 2147483647;
40
41
  background: #1a1a2e;
@@ -53,12 +54,21 @@ async function showCommentary(page, options) {
53
54
  `;
54
55
  document.querySelector("style[data-demon-commentary]")?.remove();
55
56
  document.head.appendChild(style);
56
- const top = rect.bottom + 10;
57
- const left = rect.left + rect.width / 2;
57
+ tooltip.style.visibility = "hidden";
58
+ document.body.appendChild(tooltip);
59
+ const tooltipRect = tooltip.getBoundingClientRect();
60
+ const tooltipWidth = tooltipRect.width;
61
+ const tooltipHeight = tooltipRect.height;
62
+ const viewportWidth = window.innerWidth;
63
+ let top = rect.bottom + 10;
64
+ if (top + tooltipHeight > window.innerHeight && rect.top - 10 - tooltipHeight >= 0) {
65
+ top = rect.top - 10 - tooltipHeight;
66
+ tooltip.style.setProperty("--demon-slide-y", "-8px");
67
+ }
68
+ const left = Math.max(4, Math.min(rect.left + rect.width / 2 - tooltipWidth / 2, viewportWidth - 4 - tooltipWidth));
58
69
  tooltip.style.top = `${top}px`;
59
70
  tooltip.style.left = `${left}px`;
60
- tooltip.style.transform = `translateX(-50%)`;
61
- document.body.appendChild(tooltip);
71
+ tooltip.style.visibility = "";
62
72
  }, { selector: options.selector, text: options.text, tooltipId: TOOLTIP_ID });
63
73
  }
64
74
  async function hideCommentary(page) {
@@ -74,9 +84,258 @@ async function hideCommentary(page) {
74
84
  }, TOOLTIP_ID);
75
85
  await page.waitForTimeout(300);
76
86
  }
87
+ // src/review.ts
88
+ function buildReviewPrompt(filenames) {
89
+ const fileList = filenames.map((f) => `- ${f}`).join(`
90
+ `);
91
+ return `You are given the following .webm demo video filenames:
92
+
93
+ ${fileList}
94
+
95
+ Based on the filenames, generate a JSON object matching this exact schema:
96
+
97
+ {
98
+ "demos": [
99
+ {
100
+ "file": "<filename>",
101
+ "summary": "<a short sentence describing what the demo likely shows>",
102
+ "annotations": [
103
+ { "timestampSeconds": <number>, "text": "<annotation text>" }
104
+ ]
105
+ }
106
+ ]
107
+ }
108
+
109
+ Rules:
110
+ - Return ONLY the JSON object, no markdown fences or extra text.
111
+ - Include one entry in "demos" for each filename, in the same order.
112
+ - Infer the summary and annotations from the filename.
113
+ - Each demo should have at least one annotation starting at timestampSeconds 0.
114
+ - "file" must exactly match the provided filename.`;
115
+ }
116
+ async function invokeClaude(prompt, options) {
117
+ const spawnFn = options?.spawn ?? defaultSpawn;
118
+ const agent = options?.agent ?? "claude";
119
+ const proc = spawnFn([agent, "-p", prompt]);
120
+ const reader = proc.stdout.getReader();
121
+ const chunks = [];
122
+ for (;; ) {
123
+ const { done, value } = await reader.read();
124
+ if (done)
125
+ break;
126
+ chunks.push(value);
127
+ }
128
+ const exitCode = await proc.exitCode;
129
+ const output = new TextDecoder().decode(concatUint8Arrays(chunks));
130
+ if (exitCode !== 0) {
131
+ throw new Error(`claude process exited with code ${exitCode}: ${output.trim()}`);
132
+ }
133
+ return output.trim();
134
+ }
135
+ function parseReviewMetadata(raw) {
136
+ let parsed;
137
+ try {
138
+ parsed = JSON.parse(raw);
139
+ } catch {
140
+ throw new Error(`Invalid JSON from LLM: ${raw.slice(0, 200)}`);
141
+ }
142
+ if (typeof parsed !== "object" || parsed === null || !("demos" in parsed)) {
143
+ throw new Error("Missing 'demos' array in review metadata");
144
+ }
145
+ const obj = parsed;
146
+ if (!Array.isArray(obj["demos"])) {
147
+ throw new Error("'demos' must be an array");
148
+ }
149
+ for (const demo of obj["demos"]) {
150
+ if (typeof demo !== "object" || demo === null) {
151
+ throw new Error("Each demo must be an object");
152
+ }
153
+ const d = demo;
154
+ if (typeof d["file"] !== "string") {
155
+ throw new Error("Each demo must have a 'file' string");
156
+ }
157
+ if (typeof d["summary"] !== "string") {
158
+ throw new Error("Each demo must have a 'summary' string");
159
+ }
160
+ if (!Array.isArray(d["annotations"])) {
161
+ throw new Error("Each demo must have an 'annotations' array");
162
+ }
163
+ for (const ann of d["annotations"]) {
164
+ if (typeof ann !== "object" || ann === null) {
165
+ throw new Error("Each annotation must be an object");
166
+ }
167
+ const a = ann;
168
+ if (typeof a["timestampSeconds"] !== "number") {
169
+ throw new Error("Each annotation must have a 'timestampSeconds' number");
170
+ }
171
+ if (typeof a["text"] !== "string") {
172
+ throw new Error("Each annotation must have a 'text' string");
173
+ }
174
+ }
175
+ }
176
+ return parsed;
177
+ }
178
+ function defaultSpawn(cmd) {
179
+ const [command, ...args] = cmd;
180
+ const proc = Bun.spawn([command, ...args], {
181
+ stdout: "pipe",
182
+ stderr: "pipe"
183
+ });
184
+ return {
185
+ exitCode: proc.exited,
186
+ stdout: proc.stdout
187
+ };
188
+ }
189
+ function concatUint8Arrays(arrays) {
190
+ const totalLength = arrays.reduce((sum, a) => sum + a.length, 0);
191
+ const result = new Uint8Array(totalLength);
192
+ let offset = 0;
193
+ for (const a of arrays) {
194
+ result.set(a, offset);
195
+ offset += a.length;
196
+ }
197
+ return result;
198
+ }
199
+ // src/html-generator.ts
200
+ function escapeHtml(s) {
201
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
202
+ }
203
+ function escapeAttr(s) {
204
+ return escapeHtml(s);
205
+ }
206
+ function generateReviewHtml(options) {
207
+ const { metadata, title = "Demo Review" } = options;
208
+ if (metadata.demos.length === 0) {
209
+ throw new Error("metadata.demos must not be empty");
210
+ }
211
+ const firstDemo = metadata.demos[0];
212
+ const demoButtons = metadata.demos.map((demo, i) => {
213
+ const activeClass = i === 0 ? ' class="active"' : "";
214
+ return `<li><button data-index="${i}"${activeClass}>${escapeHtml(demo.file)}</button></li>`;
215
+ }).join(`
216
+ `);
217
+ const metadataJson = JSON.stringify(metadata).replace(/<\//g, "<\\/");
218
+ return `<!DOCTYPE html>
219
+ <html lang="en">
220
+ <head>
221
+ <meta charset="UTF-8">
222
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
223
+ <title>${escapeHtml(title)}</title>
224
+ <style>
225
+ * { margin: 0; padding: 0; box-sizing: border-box; }
226
+ body { font-family: system-ui, -apple-system, sans-serif; background: #1a1a2e; color: #e0e0e0; min-height: 100vh; }
227
+ header { padding: 1rem 2rem; background: #16213e; border-bottom: 1px solid #0f3460; }
228
+ header h1 { font-size: 1.4rem; color: #e94560; }
229
+ .review-layout { display: flex; height: calc(100vh - 60px); }
230
+ .video-panel { flex: 4; padding: 1rem; display: flex; align-items: center; justify-content: center; background: #0f0f23; }
231
+ .video-panel video { width: 100%; max-height: 100%; border-radius: 4px; }
232
+ .side-panel { flex: 1; min-width: 260px; max-width: 360px; padding: 1rem; overflow-y: auto; background: #16213e; border-left: 1px solid #0f3460; }
233
+ .side-panel h2 { font-size: 1rem; margin-bottom: 0.5rem; color: #e94560; }
234
+ .side-panel section { margin-bottom: 1.5rem; }
235
+ #demo-list { list-style: none; }
236
+ #demo-list li { margin-bottom: 0.25rem; }
237
+ #demo-list button { width: 100%; text-align: left; padding: 0.4rem 0.6rem; background: #1a1a2e; color: #e0e0e0; border: 1px solid #0f3460; border-radius: 4px; cursor: pointer; font-size: 0.85rem; }
238
+ #demo-list button:hover { background: #0f3460; }
239
+ #demo-list button.active { background: #e94560; color: #fff; border-color: #e94560; }
240
+ #summary-text { font-size: 0.9rem; line-height: 1.5; color: #ccc; }
241
+ #annotations-list { list-style: none; }
242
+ #annotations-list li { margin-bottom: 0.3rem; }
243
+ #annotations-list button { width: 100%; text-align: left; padding: 0.3rem 0.5rem; background: transparent; color: #53a8b6; border: none; cursor: pointer; font-size: 0.85rem; }
244
+ #annotations-list button:hover { color: #e94560; text-decoration: underline; }
245
+ .timestamp { font-weight: bold; margin-right: 0.4rem; color: #e94560; }
246
+ </style>
247
+ </head>
248
+ <body>
249
+ <header>
250
+ <h1>${escapeHtml(title)}</h1>
251
+ </header>
252
+ <main class="review-layout">
253
+ <div class="video-panel">
254
+ <video id="review-video" controls src="${escapeAttr(firstDemo.file)}"></video>
255
+ </div>
256
+ <div class="side-panel">
257
+ <section>
258
+ <h2>Demos</h2>
259
+ <ul id="demo-list">
260
+ ${demoButtons}
261
+ </ul>
262
+ </section>
263
+ <section>
264
+ <h2>Summary</h2>
265
+ <p id="summary-text"></p>
266
+ </section>
267
+ <section>
268
+ <h2>Annotations</h2>
269
+ <ul id="annotations-list"></ul>
270
+ </section>
271
+ </div>
272
+ </main>
273
+ <script>
274
+ (function() {
275
+ var metadata = ${metadataJson};
276
+ var video = document.getElementById("review-video");
277
+ var summaryText = document.getElementById("summary-text");
278
+ var annotationsList = document.getElementById("annotations-list");
279
+ var demoButtons = document.querySelectorAll("#demo-list button");
280
+
281
+ function esc(s) {
282
+ var d = document.createElement("div");
283
+ d.appendChild(document.createTextNode(s));
284
+ return d.innerHTML;
285
+ }
286
+
287
+ function formatTime(seconds) {
288
+ var m = Math.floor(seconds / 60);
289
+ var s = Math.floor(seconds % 60);
290
+ return m + ":" + (s < 10 ? "0" : "") + s;
291
+ }
292
+
293
+ function selectDemo(index) {
294
+ var demo = metadata.demos[index];
295
+ video.src = demo.file;
296
+ video.load();
297
+ summaryText.textContent = demo.summary;
298
+
299
+ demoButtons.forEach(function(btn, i) {
300
+ btn.classList.toggle("active", i === index);
301
+ });
302
+
303
+ var html = "";
304
+ demo.annotations.forEach(function(ann) {
305
+ html += '<li><button data-time="' + ann.timestampSeconds + '">' +
306
+ '<span class="timestamp">' + esc(formatTime(ann.timestampSeconds)) + '</span>' +
307
+ esc(ann.text) + '</button></li>';
308
+ });
309
+ annotationsList.innerHTML = html;
310
+ }
311
+
312
+ demoButtons.forEach(function(btn) {
313
+ btn.addEventListener("click", function() {
314
+ selectDemo(parseInt(btn.getAttribute("data-index"), 10));
315
+ });
316
+ });
317
+
318
+ annotationsList.addEventListener("click", function(e) {
319
+ var btn = e.target.closest("button[data-time]");
320
+ if (btn) {
321
+ video.currentTime = parseFloat(btn.getAttribute("data-time"));
322
+ video.play();
323
+ }
324
+ });
325
+
326
+ selectDemo(0);
327
+ })();
328
+ </script>
329
+ </body>
330
+ </html>`;
331
+ }
77
332
  export {
78
333
  showCommentary,
79
- hideCommentary
334
+ parseReviewMetadata,
335
+ invokeClaude,
336
+ hideCommentary,
337
+ generateReviewHtml,
338
+ buildReviewPrompt
80
339
  };
81
340
 
82
- //# debugId=308A425302523F0964756E2164756E21
341
+ //# debugId=C244AE0F9893F9BF64756E2164756E21