@demon-utils/playwright 0.1.2 → 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.
- package/dist/bin/demon-demo-review.js +303 -0
- package/dist/bin/demon-demo-review.js.map +12 -0
- package/dist/index.js +267 -8
- package/dist/index.js.map +6 -4
- package/package.json +10 -1
- package/src/bin/demon-demo-review.ts +73 -0
- package/src/commentary.test.ts +96 -0
- package/src/commentary.ts +31 -8
- package/src/html-generator.test.ts +195 -0
- package/src/html-generator.ts +152 -0
- package/src/index.ts +7 -0
- package/src/review-types.ts +9 -0
- package/src/review.test.ts +167 -0
- package/src/review.ts +144 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { ReviewMetadata } from "./review-types.ts";
|
|
2
|
+
|
|
3
|
+
export interface GenerateReviewHtmlOptions {
|
|
4
|
+
metadata: ReviewMetadata;
|
|
5
|
+
title?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function escapeHtml(s: string): string {
|
|
9
|
+
return s
|
|
10
|
+
.replace(/&/g, "&")
|
|
11
|
+
.replace(/</g, "<")
|
|
12
|
+
.replace(/>/g, ">")
|
|
13
|
+
.replace(/"/g, """)
|
|
14
|
+
.replace(/'/g, "'");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function escapeAttr(s: string): string {
|
|
18
|
+
return escapeHtml(s);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function generateReviewHtml(options: GenerateReviewHtmlOptions): string {
|
|
22
|
+
const { metadata, title = "Demo Review" } = options;
|
|
23
|
+
|
|
24
|
+
if (metadata.demos.length === 0) {
|
|
25
|
+
throw new Error("metadata.demos must not be empty");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const firstDemo = metadata.demos[0];
|
|
29
|
+
|
|
30
|
+
const demoButtons = metadata.demos
|
|
31
|
+
.map((demo, i) => {
|
|
32
|
+
const activeClass = i === 0 ? ' class="active"' : "";
|
|
33
|
+
return `<li><button data-index="${i}"${activeClass}>${escapeHtml(demo.file)}</button></li>`;
|
|
34
|
+
})
|
|
35
|
+
.join("\n ");
|
|
36
|
+
|
|
37
|
+
const metadataJson = JSON.stringify(metadata).replace(/<\//g, "<\\/");
|
|
38
|
+
|
|
39
|
+
return `<!DOCTYPE html>
|
|
40
|
+
<html lang="en">
|
|
41
|
+
<head>
|
|
42
|
+
<meta charset="UTF-8">
|
|
43
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
44
|
+
<title>${escapeHtml(title)}</title>
|
|
45
|
+
<style>
|
|
46
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
47
|
+
body { font-family: system-ui, -apple-system, sans-serif; background: #1a1a2e; color: #e0e0e0; min-height: 100vh; }
|
|
48
|
+
header { padding: 1rem 2rem; background: #16213e; border-bottom: 1px solid #0f3460; }
|
|
49
|
+
header h1 { font-size: 1.4rem; color: #e94560; }
|
|
50
|
+
.review-layout { display: flex; height: calc(100vh - 60px); }
|
|
51
|
+
.video-panel { flex: 4; padding: 1rem; display: flex; align-items: center; justify-content: center; background: #0f0f23; }
|
|
52
|
+
.video-panel video { width: 100%; max-height: 100%; border-radius: 4px; }
|
|
53
|
+
.side-panel { flex: 1; min-width: 260px; max-width: 360px; padding: 1rem; overflow-y: auto; background: #16213e; border-left: 1px solid #0f3460; }
|
|
54
|
+
.side-panel h2 { font-size: 1rem; margin-bottom: 0.5rem; color: #e94560; }
|
|
55
|
+
.side-panel section { margin-bottom: 1.5rem; }
|
|
56
|
+
#demo-list { list-style: none; }
|
|
57
|
+
#demo-list li { margin-bottom: 0.25rem; }
|
|
58
|
+
#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; }
|
|
59
|
+
#demo-list button:hover { background: #0f3460; }
|
|
60
|
+
#demo-list button.active { background: #e94560; color: #fff; border-color: #e94560; }
|
|
61
|
+
#summary-text { font-size: 0.9rem; line-height: 1.5; color: #ccc; }
|
|
62
|
+
#annotations-list { list-style: none; }
|
|
63
|
+
#annotations-list li { margin-bottom: 0.3rem; }
|
|
64
|
+
#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; }
|
|
65
|
+
#annotations-list button:hover { color: #e94560; text-decoration: underline; }
|
|
66
|
+
.timestamp { font-weight: bold; margin-right: 0.4rem; color: #e94560; }
|
|
67
|
+
</style>
|
|
68
|
+
</head>
|
|
69
|
+
<body>
|
|
70
|
+
<header>
|
|
71
|
+
<h1>${escapeHtml(title)}</h1>
|
|
72
|
+
</header>
|
|
73
|
+
<main class="review-layout">
|
|
74
|
+
<div class="video-panel">
|
|
75
|
+
<video id="review-video" controls src="${escapeAttr(firstDemo.file)}"></video>
|
|
76
|
+
</div>
|
|
77
|
+
<div class="side-panel">
|
|
78
|
+
<section>
|
|
79
|
+
<h2>Demos</h2>
|
|
80
|
+
<ul id="demo-list">
|
|
81
|
+
${demoButtons}
|
|
82
|
+
</ul>
|
|
83
|
+
</section>
|
|
84
|
+
<section>
|
|
85
|
+
<h2>Summary</h2>
|
|
86
|
+
<p id="summary-text"></p>
|
|
87
|
+
</section>
|
|
88
|
+
<section>
|
|
89
|
+
<h2>Annotations</h2>
|
|
90
|
+
<ul id="annotations-list"></ul>
|
|
91
|
+
</section>
|
|
92
|
+
</div>
|
|
93
|
+
</main>
|
|
94
|
+
<script>
|
|
95
|
+
(function() {
|
|
96
|
+
var metadata = ${metadataJson};
|
|
97
|
+
var video = document.getElementById("review-video");
|
|
98
|
+
var summaryText = document.getElementById("summary-text");
|
|
99
|
+
var annotationsList = document.getElementById("annotations-list");
|
|
100
|
+
var demoButtons = document.querySelectorAll("#demo-list button");
|
|
101
|
+
|
|
102
|
+
function esc(s) {
|
|
103
|
+
var d = document.createElement("div");
|
|
104
|
+
d.appendChild(document.createTextNode(s));
|
|
105
|
+
return d.innerHTML;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function formatTime(seconds) {
|
|
109
|
+
var m = Math.floor(seconds / 60);
|
|
110
|
+
var s = Math.floor(seconds % 60);
|
|
111
|
+
return m + ":" + (s < 10 ? "0" : "") + s;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function selectDemo(index) {
|
|
115
|
+
var demo = metadata.demos[index];
|
|
116
|
+
video.src = demo.file;
|
|
117
|
+
video.load();
|
|
118
|
+
summaryText.textContent = demo.summary;
|
|
119
|
+
|
|
120
|
+
demoButtons.forEach(function(btn, i) {
|
|
121
|
+
btn.classList.toggle("active", i === index);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
var html = "";
|
|
125
|
+
demo.annotations.forEach(function(ann) {
|
|
126
|
+
html += '<li><button data-time="' + ann.timestampSeconds + '">' +
|
|
127
|
+
'<span class="timestamp">' + esc(formatTime(ann.timestampSeconds)) + '</span>' +
|
|
128
|
+
esc(ann.text) + '</button></li>';
|
|
129
|
+
});
|
|
130
|
+
annotationsList.innerHTML = html;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
demoButtons.forEach(function(btn) {
|
|
134
|
+
btn.addEventListener("click", function() {
|
|
135
|
+
selectDemo(parseInt(btn.getAttribute("data-index"), 10));
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
annotationsList.addEventListener("click", function(e) {
|
|
140
|
+
var btn = e.target.closest("button[data-time]");
|
|
141
|
+
if (btn) {
|
|
142
|
+
video.currentTime = parseFloat(btn.getAttribute("data-time"));
|
|
143
|
+
video.play();
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
selectDemo(0);
|
|
148
|
+
})();
|
|
149
|
+
</script>
|
|
150
|
+
</body>
|
|
151
|
+
</html>`;
|
|
152
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,2 +1,9 @@
|
|
|
1
1
|
export { showCommentary, hideCommentary } from "./commentary.ts";
|
|
2
2
|
export type { ShowCommentaryOptions } from "./commentary.ts";
|
|
3
|
+
|
|
4
|
+
export type { DemoMetadata, ReviewMetadata } from "./review-types.ts";
|
|
5
|
+
export { buildReviewPrompt, invokeClaude, parseReviewMetadata } from "./review.ts";
|
|
6
|
+
export type { InvokeClaudeOptions, SpawnFn } from "./review.ts";
|
|
7
|
+
|
|
8
|
+
export { generateReviewHtml } from "./html-generator.ts";
|
|
9
|
+
export type { GenerateReviewHtmlOptions } from "./html-generator.ts";
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import type { SpawnFn } from "./review.ts";
|
|
4
|
+
import {
|
|
5
|
+
buildReviewPrompt,
|
|
6
|
+
invokeClaude,
|
|
7
|
+
parseReviewMetadata,
|
|
8
|
+
} from "./review.ts";
|
|
9
|
+
|
|
10
|
+
describe("buildReviewPrompt", () => {
|
|
11
|
+
test("includes all filenames in the prompt", () => {
|
|
12
|
+
const prompt = buildReviewPrompt(["login-flow.webm", "signup.webm"]);
|
|
13
|
+
expect(prompt).toContain("- login-flow.webm");
|
|
14
|
+
expect(prompt).toContain("- signup.webm");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("includes schema instructions", () => {
|
|
18
|
+
const prompt = buildReviewPrompt(["demo.webm"]);
|
|
19
|
+
expect(prompt).toContain('"demos"');
|
|
20
|
+
expect(prompt).toContain('"file"');
|
|
21
|
+
expect(prompt).toContain('"summary"');
|
|
22
|
+
expect(prompt).toContain('"annotations"');
|
|
23
|
+
expect(prompt).toContain('"timestampSeconds"');
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("parseReviewMetadata", () => {
|
|
28
|
+
const validInput = JSON.stringify({
|
|
29
|
+
demos: [
|
|
30
|
+
{
|
|
31
|
+
file: "login.webm",
|
|
32
|
+
summary: "Shows a login flow",
|
|
33
|
+
annotations: [{ timestampSeconds: 0, text: "Start" }],
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("parses valid input correctly", () => {
|
|
39
|
+
const result = parseReviewMetadata(validInput);
|
|
40
|
+
expect(result.demos).toHaveLength(1);
|
|
41
|
+
expect(result.demos[0]!.file).toBe("login.webm");
|
|
42
|
+
expect(result.demos[0]!.summary).toBe("Shows a login flow");
|
|
43
|
+
expect(result.demos[0]!.annotations).toHaveLength(1);
|
|
44
|
+
expect(result.demos[0]!.annotations[0]!.timestampSeconds).toBe(0);
|
|
45
|
+
expect(result.demos[0]!.annotations[0]!.text).toBe("Start");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("throws on invalid JSON", () => {
|
|
49
|
+
expect(() => parseReviewMetadata("not json")).toThrow("Invalid JSON");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("throws when demos array is missing", () => {
|
|
53
|
+
expect(() => parseReviewMetadata("{}")).toThrow("Missing 'demos' array");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("throws when demos is not an array", () => {
|
|
57
|
+
expect(() => parseReviewMetadata('{"demos": "nope"}')).toThrow(
|
|
58
|
+
"'demos' must be an array",
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("throws when demo is missing file", () => {
|
|
63
|
+
expect(() =>
|
|
64
|
+
parseReviewMetadata(
|
|
65
|
+
JSON.stringify({ demos: [{ summary: "x", annotations: [] }] }),
|
|
66
|
+
),
|
|
67
|
+
).toThrow("'file' string");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("throws when demo is missing summary", () => {
|
|
71
|
+
expect(() =>
|
|
72
|
+
parseReviewMetadata(
|
|
73
|
+
JSON.stringify({ demos: [{ file: "x", annotations: [] }] }),
|
|
74
|
+
),
|
|
75
|
+
).toThrow("'summary' string");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("throws when annotations is missing", () => {
|
|
79
|
+
expect(() =>
|
|
80
|
+
parseReviewMetadata(
|
|
81
|
+
JSON.stringify({ demos: [{ file: "x", summary: "s" }] }),
|
|
82
|
+
),
|
|
83
|
+
).toThrow("'annotations' array");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("throws when annotation has wrong timestampSeconds type", () => {
|
|
87
|
+
expect(() =>
|
|
88
|
+
parseReviewMetadata(
|
|
89
|
+
JSON.stringify({
|
|
90
|
+
demos: [
|
|
91
|
+
{
|
|
92
|
+
file: "x",
|
|
93
|
+
summary: "s",
|
|
94
|
+
annotations: [{ timestampSeconds: "0", text: "t" }],
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
}),
|
|
98
|
+
),
|
|
99
|
+
).toThrow("'timestampSeconds' number");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("throws when annotation has wrong text type", () => {
|
|
103
|
+
expect(() =>
|
|
104
|
+
parseReviewMetadata(
|
|
105
|
+
JSON.stringify({
|
|
106
|
+
demos: [
|
|
107
|
+
{
|
|
108
|
+
file: "x",
|
|
109
|
+
summary: "s",
|
|
110
|
+
annotations: [{ timestampSeconds: 0, text: 123 }],
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
}),
|
|
114
|
+
),
|
|
115
|
+
).toThrow("'text' string");
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("invokeClaude", () => {
|
|
120
|
+
function mockSpawn(
|
|
121
|
+
stdout: string,
|
|
122
|
+
exitCode: number,
|
|
123
|
+
): SpawnFn {
|
|
124
|
+
return (_cmd: string[]) => ({
|
|
125
|
+
exitCode: Promise.resolve(exitCode),
|
|
126
|
+
stdout: new ReadableStream({
|
|
127
|
+
start(controller) {
|
|
128
|
+
controller.enqueue(new TextEncoder().encode(stdout));
|
|
129
|
+
controller.close();
|
|
130
|
+
},
|
|
131
|
+
}),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
test("returns stdout on success", async () => {
|
|
136
|
+
const result = await invokeClaude("test prompt", { spawn: mockSpawn("hello", 0) });
|
|
137
|
+
expect(result).toBe("hello");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("trims whitespace from output", async () => {
|
|
141
|
+
const result = await invokeClaude(
|
|
142
|
+
"test prompt",
|
|
143
|
+
{ spawn: mockSpawn(" hello \n", 0) },
|
|
144
|
+
);
|
|
145
|
+
expect(result).toBe("hello");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("throws on non-zero exit code", async () => {
|
|
149
|
+
try {
|
|
150
|
+
await invokeClaude("test prompt", { spawn: mockSpawn("error msg", 1) });
|
|
151
|
+
expect(true).toBe(false); // should not reach here
|
|
152
|
+
} catch (e) {
|
|
153
|
+
expect((e as Error).message).toContain("exited with code 1");
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("passes agent option to spawn command", async () => {
|
|
158
|
+
let capturedCmd: string[] = [];
|
|
159
|
+
const spySpawn: SpawnFn = (cmd) => {
|
|
160
|
+
capturedCmd = cmd;
|
|
161
|
+
return mockSpawn("ok", 0)(cmd);
|
|
162
|
+
};
|
|
163
|
+
await invokeClaude("test prompt", { agent: "my-agent", spawn: spySpawn });
|
|
164
|
+
expect(capturedCmd[0]).toBe("my-agent");
|
|
165
|
+
expect(capturedCmd[1]).toBe("-p");
|
|
166
|
+
});
|
|
167
|
+
});
|
package/src/review.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { ReviewMetadata } from "./review-types.ts";
|
|
2
|
+
|
|
3
|
+
export type SpawnFn = (
|
|
4
|
+
cmd: string[],
|
|
5
|
+
) => { exitCode: Promise<number>; stdout: ReadableStream<Uint8Array> };
|
|
6
|
+
|
|
7
|
+
export interface InvokeClaudeOptions {
|
|
8
|
+
agent?: string;
|
|
9
|
+
spawn?: SpawnFn;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function buildReviewPrompt(filenames: string[]): string {
|
|
13
|
+
const fileList = filenames.map((f) => `- ${f}`).join("\n");
|
|
14
|
+
|
|
15
|
+
return `You are given the following .webm demo video filenames:
|
|
16
|
+
|
|
17
|
+
${fileList}
|
|
18
|
+
|
|
19
|
+
Based on the filenames, generate a JSON object matching this exact schema:
|
|
20
|
+
|
|
21
|
+
{
|
|
22
|
+
"demos": [
|
|
23
|
+
{
|
|
24
|
+
"file": "<filename>",
|
|
25
|
+
"summary": "<a short sentence describing what the demo likely shows>",
|
|
26
|
+
"annotations": [
|
|
27
|
+
{ "timestampSeconds": <number>, "text": "<annotation text>" }
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
Rules:
|
|
34
|
+
- Return ONLY the JSON object, no markdown fences or extra text.
|
|
35
|
+
- Include one entry in "demos" for each filename, in the same order.
|
|
36
|
+
- Infer the summary and annotations from the filename.
|
|
37
|
+
- Each demo should have at least one annotation starting at timestampSeconds 0.
|
|
38
|
+
- "file" must exactly match the provided filename.`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function invokeClaude(
|
|
42
|
+
prompt: string,
|
|
43
|
+
options?: InvokeClaudeOptions,
|
|
44
|
+
): Promise<string> {
|
|
45
|
+
const spawnFn = options?.spawn ?? defaultSpawn;
|
|
46
|
+
const agent = options?.agent ?? "claude";
|
|
47
|
+
const proc = spawnFn([agent, "-p", prompt]);
|
|
48
|
+
|
|
49
|
+
const reader = proc.stdout.getReader();
|
|
50
|
+
const chunks: Uint8Array[] = [];
|
|
51
|
+
for (;;) {
|
|
52
|
+
const { done, value } = await reader.read();
|
|
53
|
+
if (done) break;
|
|
54
|
+
chunks.push(value);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const exitCode = await proc.exitCode;
|
|
58
|
+
const output = new TextDecoder().decode(
|
|
59
|
+
concatUint8Arrays(chunks),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
if (exitCode !== 0) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`claude process exited with code ${exitCode}: ${output.trim()}`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return output.trim();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function parseReviewMetadata(raw: string): ReviewMetadata {
|
|
72
|
+
let parsed: unknown;
|
|
73
|
+
try {
|
|
74
|
+
parsed = JSON.parse(raw);
|
|
75
|
+
} catch {
|
|
76
|
+
throw new Error(`Invalid JSON from LLM: ${raw.slice(0, 200)}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (typeof parsed !== "object" || parsed === null || !("demos" in parsed)) {
|
|
80
|
+
throw new Error("Missing 'demos' array in review metadata");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const obj = parsed as Record<string, unknown>;
|
|
84
|
+
if (!Array.isArray(obj["demos"])) {
|
|
85
|
+
throw new Error("'demos' must be an array");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for (const demo of obj["demos"] as unknown[]) {
|
|
89
|
+
if (typeof demo !== "object" || demo === null) {
|
|
90
|
+
throw new Error("Each demo must be an object");
|
|
91
|
+
}
|
|
92
|
+
const d = demo as Record<string, unknown>;
|
|
93
|
+
|
|
94
|
+
if (typeof d["file"] !== "string") {
|
|
95
|
+
throw new Error("Each demo must have a 'file' string");
|
|
96
|
+
}
|
|
97
|
+
if (typeof d["summary"] !== "string") {
|
|
98
|
+
throw new Error("Each demo must have a 'summary' string");
|
|
99
|
+
}
|
|
100
|
+
if (!Array.isArray(d["annotations"])) {
|
|
101
|
+
throw new Error("Each demo must have an 'annotations' array");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
for (const ann of d["annotations"] as unknown[]) {
|
|
105
|
+
if (typeof ann !== "object" || ann === null) {
|
|
106
|
+
throw new Error("Each annotation must be an object");
|
|
107
|
+
}
|
|
108
|
+
const a = ann as Record<string, unknown>;
|
|
109
|
+
if (typeof a["timestampSeconds"] !== "number") {
|
|
110
|
+
throw new Error("Each annotation must have a 'timestampSeconds' number");
|
|
111
|
+
}
|
|
112
|
+
if (typeof a["text"] !== "string") {
|
|
113
|
+
throw new Error("Each annotation must have a 'text' string");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return parsed as ReviewMetadata;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function defaultSpawn(
|
|
122
|
+
cmd: string[],
|
|
123
|
+
): { exitCode: Promise<number>; stdout: ReadableStream<Uint8Array> } {
|
|
124
|
+
const [command, ...args] = cmd;
|
|
125
|
+
const proc = Bun.spawn([command!, ...args], {
|
|
126
|
+
stdout: "pipe",
|
|
127
|
+
stderr: "pipe",
|
|
128
|
+
});
|
|
129
|
+
return {
|
|
130
|
+
exitCode: proc.exited,
|
|
131
|
+
stdout: proc.stdout as unknown as ReadableStream<Uint8Array>,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function concatUint8Arrays(arrays: Uint8Array[]): Uint8Array {
|
|
136
|
+
const totalLength = arrays.reduce((sum, a) => sum + a.length, 0);
|
|
137
|
+
const result = new Uint8Array(totalLength);
|
|
138
|
+
let offset = 0;
|
|
139
|
+
for (const a of arrays) {
|
|
140
|
+
result.set(a, offset);
|
|
141
|
+
offset += a.length;
|
|
142
|
+
}
|
|
143
|
+
return result;
|
|
144
|
+
}
|