opencode-drive 0.1.7 → 0.1.8

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/src/cli/stop.ts CHANGED
@@ -1,10 +1,12 @@
1
- import { request } from "./control.js"
1
+ import { requestStop } from "./control.js"
2
2
  import { manifestPath, resolveInstance } from "./registry.js"
3
3
 
4
4
  export async function stop(name?: string) {
5
5
  const manifest = await resolveInstance(name)
6
- await request(manifest.control, "stop")
7
- const deadline = Date.now() + 10_000
6
+ const result = await requestStop(manifest.control, (percent) => {
7
+ console.error(`Rendering video: ${percent}%`)
8
+ })
9
+ const deadline = Date.now() + 5 * 60_000
8
10
  while (Date.now() < deadline) {
9
11
  const current: unknown = await Bun.file(manifestPath(manifest.name))
10
12
  .json()
@@ -15,7 +17,12 @@ export async function stop(name?: string) {
15
17
  !("pid" in current) ||
16
18
  current.pid !== manifest.pid
17
19
  ) {
18
- console.log("success")
20
+ for (const screenshot of result.screenshots) console.log(screenshot)
21
+ if (result.recording) {
22
+ console.error(`Video successfully created: ${result.recording}`)
23
+ } else if (result.screenshots.length === 0) {
24
+ console.log("success")
25
+ }
19
26
  return
20
27
  }
21
28
  await Bun.sleep(25)
package/src/cli/types.ts CHANGED
@@ -9,6 +9,7 @@ export interface StartOptions {
9
9
  readonly daemon: boolean
10
10
  readonly script?: string
11
11
  readonly visible: boolean
12
+ readonly record: boolean
12
13
  readonly dev?: string
13
14
  readonly state?: string
14
15
  readonly command: ReadonlyArray<string>
@@ -4,20 +4,16 @@ const defaultPort = 40900
4
4
 
5
5
  type Methods = {
6
6
  readonly "ui.screenshot": {
7
- readonly params: undefined
7
+ readonly params: Frontend.ScreenshotParams | undefined
8
8
  readonly result: Frontend.Screenshot
9
9
  }
10
10
  readonly "ui.state": {
11
11
  readonly params: undefined
12
12
  readonly result: Frontend.State
13
13
  }
14
- readonly "ui.start-record": {
15
- readonly params: undefined
16
- readonly result: Frontend.StartRecord
17
- }
18
- readonly "ui.end-record": {
14
+ readonly "ui.recording.finish": {
19
15
  readonly params: undefined
20
- readonly result: Frontend.EndRecord
16
+ readonly result: Frontend.RecordingFinish
21
17
  }
22
18
  readonly "ui.type": {
23
19
  readonly params: Frontend.TypeParams
@@ -69,6 +65,7 @@ export interface SimulationClientOptions {
69
65
  readonly portAttempts?: number
70
66
  /** Per-call timeout in milliseconds. Defaults to 30_000. */
71
67
  readonly timeout?: number
68
+ readonly onScreenshot?: (path: string) => void
72
69
  }
73
70
 
74
71
  export class SimulationError extends Error {
@@ -93,13 +90,20 @@ export class SimulationClient {
93
90
 
94
91
  private readonly socket: WebSocket
95
92
  private readonly timeout: number
93
+ private readonly onScreenshot?: (path: string) => void
96
94
  private nextId = 1
97
95
  private readonly pending = new Map<number, Waiter>()
98
96
 
99
- private constructor(socket: WebSocket, url: string, timeout: number) {
97
+ private constructor(
98
+ socket: WebSocket,
99
+ url: string,
100
+ timeout: number,
101
+ onScreenshot?: (path: string) => void,
102
+ ) {
100
103
  this.socket = socket
101
104
  this.url = url
102
105
  this.timeout = timeout
106
+ this.onScreenshot = onScreenshot
103
107
  socket.addEventListener("message", (event) =>
104
108
  this.onMessage(String(event.data)),
105
109
  )
@@ -116,14 +120,24 @@ export class SimulationClient {
116
120
  ): Promise<SimulationClient> {
117
121
  const timeout = options?.timeout ?? 30_000
118
122
  if (options?.url !== undefined) {
119
- return new SimulationClient(await open(options.url), options.url, timeout)
123
+ return new SimulationClient(
124
+ await open(options.url),
125
+ options.url,
126
+ timeout,
127
+ options.onScreenshot,
128
+ )
120
129
  }
121
130
  const first = options?.port ?? defaultPort
122
131
  const attempts = options?.portAttempts ?? 10
123
132
  for (let offset = 0; offset < attempts; offset++) {
124
133
  const url = `ws://127.0.0.1:${first + offset}`
125
134
  try {
126
- return new SimulationClient(await open(url), url, timeout)
135
+ return new SimulationClient(
136
+ await open(url),
137
+ url,
138
+ timeout,
139
+ options?.onScreenshot,
140
+ )
127
141
  } catch {
128
142
  // occupied by something else or nothing listening; try the next port
129
143
  }
@@ -170,16 +184,17 @@ export class SimulationClient {
170
184
  return this.call("ui.state")
171
185
  }
172
186
 
173
- screenshot(): Promise<Frontend.Screenshot> {
174
- return this.call("ui.screenshot")
175
- }
176
-
177
- startRecord(): Promise<Frontend.StartRecord> {
178
- return this.call("ui.start-record")
187
+ async screenshot(name?: string): Promise<Frontend.Screenshot> {
188
+ const path = await this.call(
189
+ "ui.screenshot",
190
+ name === undefined ? undefined : { name },
191
+ )
192
+ this.onScreenshot?.(path)
193
+ return path
179
194
  }
180
195
 
181
- endRecord(): Promise<Frontend.EndRecord> {
182
- return this.call("ui.end-record")
196
+ finishRecording(): Promise<Frontend.RecordingFinish> {
197
+ return this.call("ui.recording.finish")
183
198
  }
184
199
 
185
200
  /** Executes one user-level action and returns the post-action state. */
@@ -111,11 +111,14 @@ export namespace Frontend {
111
111
  export const Screenshot = Schema.String
112
112
  export type Screenshot = Schema.Schema.Type<typeof Screenshot>
113
113
 
114
- export const StartRecord = Schema.Struct({ recording: Schema.Literal(true) })
115
- export interface StartRecord extends Schema.Schema.Type<typeof StartRecord> {}
114
+ export const RecordingFinish = Schema.String
115
+ export type RecordingFinish = Schema.Schema.Type<typeof RecordingFinish>
116
116
 
117
- export const EndRecord = Schema.String
118
- export type EndRecord = Schema.Schema.Type<typeof EndRecord>
117
+ export const ScreenshotParams = Schema.Struct({
118
+ name: Schema.optional(Schema.String),
119
+ })
120
+ export interface ScreenshotParams
121
+ extends Schema.Schema.Type<typeof ScreenshotParams> {}
119
122
 
120
123
  export const TypeParams = Schema.Struct({ text: Schema.String })
121
124
  export interface TypeParams extends Schema.Schema.Type<typeof TypeParams> {}
@@ -169,13 +172,12 @@ export namespace Frontend {
169
172
  }),
170
173
  Schema.Struct({
171
174
  ...JsonRpc.RequestFields,
172
- method: Schema.Literals([
173
- "ui.enter",
174
- "ui.screenshot",
175
- "ui.state",
176
- "ui.start-record",
177
- "ui.end-record",
178
- ]),
175
+ method: Schema.Literal("ui.screenshot"),
176
+ params: Schema.optional(ScreenshotParams),
177
+ }),
178
+ Schema.Struct({
179
+ ...JsonRpc.RequestFields,
180
+ method: Schema.Literals(["ui.enter", "ui.state", "ui.recording.finish"]),
179
181
  }),
180
182
  ])
181
183
  export type Request = Schema.Schema.Type<typeof Request>
@@ -0,0 +1,102 @@
1
+ import { join } from "node:path"
2
+ import { defineScript } from "../index.js"
3
+
4
+ export default defineScript(async ({ artifacts, backend, ui }) => {
5
+ const completed = Array.from({ length: 3 }, () => Promise.withResolvers<void>())
6
+ let turn = 0
7
+
8
+ await backend.attach(async (request) => {
9
+ if (isTitleRequest(request.body)) {
10
+ await backend.chunk(request.id, [{ type: "textDelta", text: "Stale exploring reproduction" }])
11
+ await backend.finish(request.id)
12
+ return
13
+ }
14
+ const current = turn++
15
+ if (current === 0) {
16
+ await backend.chunk(request.id, [
17
+ {
18
+ type: "toolCall",
19
+ index: 0,
20
+ id: "call_read",
21
+ name: "read",
22
+ input: { filePath: join(artifacts, "state", "files", "src", "garden.js") },
23
+ },
24
+ ])
25
+ await backend.finish(request.id, "tool-calls")
26
+ return
27
+ }
28
+ if (current === 1) {
29
+ await backend.finish(request.id, "tool-calls")
30
+ completed[0]?.resolve()
31
+ return
32
+ }
33
+ const response = current === 2 ? "The file exports a small greeting function." : "Confirmed again with no more tools."
34
+ await backend.chunk(request.id, [{ type: "textDelta", text: response }])
35
+ await backend.finish(request.id)
36
+ completed[current - 1]?.resolve()
37
+ })
38
+
39
+ const prompts = [
40
+ "Read src/garden.js, then tell me what it contains.",
41
+ "Now inspect that file one more time.",
42
+ "Finally, verify the same file again.",
43
+ ]
44
+ for (const [index, prompt] of prompts.entries()) {
45
+ if (index === 0) await ui.typeText(prompt)
46
+ else await typeSlowly(ui, prompt)
47
+ await ui.pressEnter()
48
+ await withTimeout(
49
+ completed[index]!.promise,
50
+ 30_000,
51
+ `timed out waiting for empty continuation ${index + 1}`,
52
+ )
53
+ await waitForEditor(ui)
54
+ await Bun.sleep(500)
55
+ await ui.screenshot(`stale-exploring-${index + 1}`)
56
+ }
57
+ })
58
+
59
+ async function withTimeout(promise: Promise<void>, timeout: number, message: string) {
60
+ const expired = Promise.withResolvers<never>()
61
+ const timer = setTimeout(() => expired.reject(new Error(message)), timeout)
62
+ try {
63
+ await Promise.race([promise, expired.promise])
64
+ } finally {
65
+ clearTimeout(timer)
66
+ }
67
+ }
68
+
69
+ async function typeSlowly(
70
+ ui: Parameters<Parameters<typeof defineScript>[0]>[0]["ui"],
71
+ text: string,
72
+ ) {
73
+ for (const char of text) {
74
+ await ui.typeText(char)
75
+ await Bun.sleep(55)
76
+ }
77
+ }
78
+
79
+ async function waitForEditor(
80
+ ui: Parameters<Parameters<typeof defineScript>[0]>[0]["ui"],
81
+ ) {
82
+ const deadline = Date.now() + 30_000
83
+ while (Date.now() < deadline) {
84
+ if ((await ui.state()).focused.editor) return
85
+ await Bun.sleep(50)
86
+ }
87
+ throw new Error("timed out waiting for the session to become idle")
88
+ }
89
+
90
+ function isTitleRequest(body: unknown) {
91
+ if (typeof body !== "object" || body === null || !("messages" in body)) return false
92
+ const messages = body.messages
93
+ if (!Array.isArray(messages)) return false
94
+ return messages.some(
95
+ (message) =>
96
+ typeof message === "object" &&
97
+ message !== null &&
98
+ "content" in message &&
99
+ typeof message.content === "string" &&
100
+ message.content.includes("You are a title generator"),
101
+ )
102
+ }
@@ -0,0 +1,93 @@
1
+ import { createReadStream } from "node:fs"
2
+ import type { TimelineHeader, TimelineOutput, TimelineRecord } from "./types.js"
3
+
4
+ function fail(line: number, message: string): never {
5
+ throw new Error(`Invalid recording timeline at line ${line}: ${message}`)
6
+ }
7
+
8
+ function isObject(value: unknown): value is Record<string, unknown> {
9
+ return typeof value === "object" && value !== null && !Array.isArray(value)
10
+ }
11
+
12
+ function hasExactKeys(value: Record<string, unknown>, expected: string[]) {
13
+ const actual = Object.keys(value).sort()
14
+ return actual.length === expected.length && actual.every((key, index) => key === expected[index])
15
+ }
16
+
17
+ function positiveInteger(value: unknown): value is number {
18
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0
19
+ }
20
+
21
+ function nonnegativeInteger(value: unknown): value is number {
22
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0
23
+ }
24
+
25
+ function canonicalBase64(value: string) {
26
+ if (value.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
27
+ return false
28
+ }
29
+ return Buffer.from(value, "base64").toString("base64") === value
30
+ }
31
+
32
+ function parseRecord(text: string, line: number, first: boolean, previousAt: number): TimelineRecord {
33
+ let value: unknown
34
+ try {
35
+ value = JSON.parse(text)
36
+ } catch {
37
+ fail(line, "line is not valid JSON")
38
+ }
39
+ if (!isObject(value)) fail(line, "record must be an object")
40
+
41
+ if (first) {
42
+ if (!hasExactKeys(value, ["cols", "encoding", "rows", "type", "version"])) fail(line, "invalid header fields")
43
+ if (value.type !== "header" || value.version !== 1 || value.encoding !== "base64") {
44
+ fail(line, "unsupported or missing header")
45
+ }
46
+ if (!positiveInteger(value.cols) || !positiveInteger(value.rows)) fail(line, "cols and rows must be positive integers")
47
+ return { type: "header", version: 1, cols: value.cols, rows: value.rows, encoding: "base64" } satisfies TimelineHeader
48
+ }
49
+
50
+ if (!hasExactKeys(value, ["at_ms", "data", "type"]) || value.type !== "output") fail(line, "invalid output fields")
51
+ if (!nonnegativeInteger(value.at_ms)) fail(line, "at_ms must be a nonnegative integer")
52
+ if (value.at_ms < previousAt) fail(line, "output timestamps must be nondecreasing")
53
+ if (typeof value.data !== "string" || !canonicalBase64(value.data)) fail(line, "data must be canonical base64")
54
+ return { type: "output", at_ms: value.at_ms, data: value.data } satisfies TimelineOutput
55
+ }
56
+
57
+ /** Decodes and validates a timeline without loading the complete file into memory. */
58
+ export async function* decodeTimeline(path: string): AsyncGenerator<TimelineRecord> {
59
+ const decoder = new TextDecoder("utf-8", { fatal: true })
60
+ let buffered = ""
61
+ let line = 0
62
+ let records = 0
63
+ let previousAt = -1
64
+
65
+ const consume = (raw: string) => {
66
+ line++
67
+ const text = raw.endsWith("\r") ? raw.slice(0, -1) : raw
68
+ if (text.length === 0) fail(line, "empty lines are not allowed")
69
+ const record = parseRecord(text, line, records === 0, previousAt)
70
+ records++
71
+ if (record.type === "output") previousAt = record.at_ms
72
+ return record
73
+ }
74
+
75
+ try {
76
+ for await (const chunk of createReadStream(path)) {
77
+ buffered += decoder.decode(chunk, { stream: true })
78
+ let newline: number
79
+ while ((newline = buffered.indexOf("\n")) !== -1) {
80
+ const raw = buffered.slice(0, newline)
81
+ buffered = buffered.slice(newline + 1)
82
+ yield consume(raw)
83
+ }
84
+ }
85
+ buffered += decoder.decode()
86
+ } catch (error) {
87
+ if (error instanceof TypeError) fail(line + 1, "file is not valid UTF-8")
88
+ throw error
89
+ }
90
+
91
+ if (buffered.length > 0) yield consume(buffered)
92
+ if (records === 0) fail(1, "missing header")
93
+ }
@@ -0,0 +1,113 @@
1
+ import { spawn } from "node:child_process"
2
+ import { createHash } from "node:crypto"
3
+ import { copyFile, link, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
4
+ import { tmpdir } from "node:os"
5
+ import { dirname, extname, join } from "node:path"
6
+ import { replayRecording, type ReplayOptions } from "./replay.js"
7
+ import { renderFrame } from "./render.js"
8
+
9
+ export interface ExportRecordingOptions extends ReplayOptions {
10
+ ffmpegPath?: string
11
+ onProgress?: (percent: number) => void
12
+ }
13
+
14
+ export interface ExportRecordingResult {
15
+ frames: number
16
+ durationMs: number
17
+ width: number
18
+ height: number
19
+ }
20
+
21
+ function run(command: string, args: string[]) {
22
+ return new Promise<void>((resolve, reject) => {
23
+ const child = spawn(command, args, { stdio: ["ignore", "ignore", "pipe"] })
24
+ let stderr = ""
25
+ child.stderr.setEncoding("utf8")
26
+ child.stderr.on("data", (chunk: string) => (stderr += chunk))
27
+ child.on("error", reject)
28
+ child.on("close", (code) => {
29
+ if (code === 0) resolve()
30
+ else reject(new Error(`ffmpeg exited with code ${code}: ${stderr.trim()}`))
31
+ })
32
+ })
33
+ }
34
+
35
+ async function linkOrCopy(source: string, destination: string) {
36
+ try {
37
+ await link(source, destination)
38
+ } catch {
39
+ await copyFile(source, destination)
40
+ }
41
+ }
42
+
43
+ export async function exportRecording(
44
+ timelinePath: string,
45
+ outputPath: string,
46
+ options: ExportRecordingOptions = {},
47
+ ): Promise<ExportRecordingResult> {
48
+ const frames = await replayRecording(timelinePath, options)
49
+ const final = frames.at(-1)!
50
+ const extension = extname(outputPath).toLowerCase()
51
+ const progress = progressReporter(options.onProgress)
52
+ await mkdir(dirname(outputPath), { recursive: true })
53
+
54
+ if (extension === ".png") {
55
+ await writeFile(outputPath, renderFrame(final.frame))
56
+ progress(100)
57
+ } else if (extension === ".mp4") {
58
+ const directory = await mkdtemp(join(tmpdir(), "opencode-drive-recording-"))
59
+ try {
60
+ const unique = new Map<string, string>()
61
+ for (const [index, sample] of frames.entries()) {
62
+ const hash = createHash("sha256").update(JSON.stringify(sample.frame)).digest("hex")
63
+ let rendered = unique.get(hash)
64
+ if (!rendered) {
65
+ rendered = join(directory, `unique-${hash}.png`)
66
+ await writeFile(rendered, renderFrame(sample.frame))
67
+ unique.set(hash, rendered)
68
+ }
69
+ await linkOrCopy(rendered, join(directory, `frame-${String(index).padStart(8, "0")}.png`))
70
+ progress(((index + 1) / frames.length) * 90)
71
+ }
72
+ await run(options.ffmpegPath ?? "ffmpeg", [
73
+ "-y",
74
+ "-framerate",
75
+ String(options.fps ?? 20),
76
+ "-i",
77
+ join(directory, "frame-%08d.png"),
78
+ "-c:v",
79
+ "libx264",
80
+ "-pix_fmt",
81
+ "yuv420p",
82
+ "-movflags",
83
+ "+faststart",
84
+ "-t",
85
+ String(Math.max(final.atMs, 1000 / (options.fps ?? 20)) / 1000),
86
+ outputPath,
87
+ ])
88
+ progress(100)
89
+ } finally {
90
+ await rm(directory, { recursive: true, force: true })
91
+ }
92
+ } else {
93
+ throw new Error(`Unsupported recording output extension: ${extension || "(none)"}`)
94
+ }
95
+
96
+ return {
97
+ frames: frames.length,
98
+ durationMs: final.atMs,
99
+ width: final.frame.cols * 10,
100
+ height: final.frame.rows * 20,
101
+ }
102
+ }
103
+
104
+ function progressReporter(onProgress?: (percent: number) => void) {
105
+ let reported = 0
106
+ return (percent: number) => {
107
+ const target = Math.min(100, Math.floor(percent / 10) * 10)
108
+ while (reported < target) {
109
+ reported += 10
110
+ onProgress?.(reported)
111
+ }
112
+ }
113
+ }
@@ -0,0 +1,13 @@
1
+ export { decodeTimeline } from "./decode.js"
2
+ export { exportRecording, type ExportRecordingOptions, type ExportRecordingResult } from "./export.js"
3
+ export { renderFrame } from "./render.js"
4
+ export { replayRecording, type ReplayOptions } from "./replay.js"
5
+ export type {
6
+ CapturedFrame,
7
+ CapturedLine,
8
+ CapturedSpan,
9
+ SampledFrame,
10
+ TimelineHeader,
11
+ TimelineOutput,
12
+ TimelineRecord,
13
+ } from "./types.js"
@@ -0,0 +1,80 @@
1
+ import { fileURLToPath } from "node:url"
2
+ import { GlobalFonts, createCanvas } from "@napi-rs/canvas"
3
+ import { TextStyle, type CapturedFrame } from "./types.js"
4
+
5
+ const CellWidth = 10
6
+ const CellHeight = 20
7
+ const FontSize = 16
8
+ const FontFamily = "OpenCode Mono"
9
+
10
+ for (const file of [
11
+ "adwaita-mono-latin-400-normal.woff2",
12
+ "adwaita-mono-latin-700-normal.woff2",
13
+ "adwaita-mono-latin-400-italic.woff2",
14
+ "adwaita-mono-latin-700-italic.woff2",
15
+ ]) {
16
+ GlobalFonts.registerFromPath(
17
+ fileURLToPath(import.meta.resolve(`@fontsource/adwaita-mono/files/${file}`)),
18
+ FontFamily,
19
+ )
20
+ }
21
+
22
+ function color(rgb: number, alpha = 1) {
23
+ return `rgba(${(rgb >> 16) & 255}, ${(rgb >> 8) & 255}, ${rgb & 255}, ${alpha})`
24
+ }
25
+
26
+ export function renderFrame(frame: CapturedFrame): Buffer {
27
+ const canvas = createCanvas(frame.cols * CellWidth, frame.rows * CellHeight)
28
+ const context = canvas.getContext("2d")
29
+ context.fillStyle = "#080808"
30
+ context.fillRect(0, 0, canvas.width, canvas.height)
31
+ context.textBaseline = "top"
32
+
33
+ frame.lines.forEach((line, row) => {
34
+ let column = 0
35
+ for (const span of line.spans) {
36
+ const inverse = Boolean(span.attributes & TextStyle.inverse)
37
+ const hidden = Boolean(span.attributes & TextStyle.invisible)
38
+ const foreground = inverse ? span.bg : span.fg
39
+ const background = inverse ? span.fg : span.bg
40
+ let remaining = span.width
41
+ for (const char of span.text) {
42
+ const cells = Math.min(Math.max(1, Bun.stringWidth(char)), remaining)
43
+ context.fillStyle = color(background)
44
+ context.fillRect(column * CellWidth, row * CellHeight, cells * CellWidth, CellHeight)
45
+ if (!hidden) {
46
+ const italic = span.attributes & TextStyle.italic ? "italic " : ""
47
+ const weight = span.attributes & TextStyle.bold ? "700 " : "400 "
48
+ context.font = `${italic}${weight}${FontSize}px "${FontFamily}"`
49
+ context.fillStyle = color(foreground, span.attributes & TextStyle.dim ? 0.55 : 1)
50
+ context.fillText(char, column * CellWidth, row * CellHeight + 1)
51
+ if (span.attributes & TextStyle.underline) {
52
+ context.fillRect(column * CellWidth, row * CellHeight + 17, cells * CellWidth, 1)
53
+ }
54
+ if (span.attributes & TextStyle.strikethrough) {
55
+ context.fillRect(column * CellWidth, row * CellHeight + 10, cells * CellWidth, 1)
56
+ }
57
+ }
58
+ column += cells
59
+ remaining -= cells
60
+ }
61
+ if (remaining > 0) {
62
+ context.fillStyle = color(background)
63
+ context.fillRect(column * CellWidth, row * CellHeight, remaining * CellWidth, CellHeight)
64
+ column += remaining
65
+ }
66
+ }
67
+ })
68
+
69
+ if (frame.cursor.visible && frame.cursor.row >= 0 && frame.cursor.row < frame.rows) {
70
+ context.strokeStyle = "#d8d8d8"
71
+ context.lineWidth = 2
72
+ context.strokeRect(
73
+ frame.cursor.col * CellWidth + 1,
74
+ frame.cursor.row * CellHeight + 1,
75
+ CellWidth - 2,
76
+ CellHeight - 2,
77
+ )
78
+ }
79
+ return canvas.toBuffer("image/png")
80
+ }
@@ -0,0 +1,55 @@
1
+ import { decodeTimeline } from "./decode.js"
2
+ import { createTerminalParser, type TerminalParserFactory } from "./terminal.js"
3
+ import type { SampledFrame, TimelineHeader } from "./types.js"
4
+
5
+ export interface ReplayOptions {
6
+ fps?: number
7
+ }
8
+
9
+ interface InternalReplayOptions extends ReplayOptions {
10
+ terminalFactory?: TerminalParserFactory
11
+ }
12
+
13
+ function sampleInterval(fps: number) {
14
+ if (!Number.isFinite(fps) || fps <= 0) throw new Error("fps must be a positive finite number")
15
+ return 1000 / fps
16
+ }
17
+
18
+ export async function replayRecording(path: string, options: ReplayOptions = {}): Promise<SampledFrame[]> {
19
+ return replay(path, options)
20
+ }
21
+
22
+ export async function replay(path: string, options: InternalReplayOptions = {}): Promise<SampledFrame[]> {
23
+ const interval = sampleInterval(options.fps ?? 20)
24
+ const records = decodeTimeline(path)[Symbol.asyncIterator]()
25
+ const first = await records.next()
26
+ if (first.done || first.value.type !== "header") throw new Error("Recording timeline is missing its header")
27
+ const header: TimelineHeader = first.value
28
+ const terminal = await (options.terminalFactory ?? createTerminalParser)(header.cols, header.rows)
29
+ const frames: SampledFrame[] = []
30
+ let nextSample = 0
31
+ let finalAt = 0
32
+
33
+ for (;;) {
34
+ const next = await records.next()
35
+ if (next.done) break
36
+ const event = next.value
37
+ if (event.type !== "output") throw new Error("Recording timeline contains a second header")
38
+ while (nextSample < event.at_ms) {
39
+ frames.push({ atMs: nextSample, frame: terminal.snapshot() })
40
+ nextSample += interval
41
+ }
42
+ terminal.write(Buffer.from(event.data, "base64"))
43
+ finalAt = event.at_ms
44
+ }
45
+ terminal.finish()
46
+
47
+ while (nextSample <= finalAt) {
48
+ frames.push({ atMs: nextSample, frame: terminal.snapshot() })
49
+ nextSample += interval
50
+ }
51
+ if (frames.length === 0 || frames.at(-1)!.atMs !== finalAt) {
52
+ frames.push({ atMs: finalAt, frame: terminal.snapshot() })
53
+ }
54
+ return frames
55
+ }