opencode-drive 0.1.6 → 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/README.md +3 -1
- package/package.json +9 -3
- package/src/cli/commands.ts +5 -9
- package/src/cli/control.ts +55 -11
- package/src/cli/index.ts +5 -34
- package/src/cli/instance.ts +30 -4
- package/src/cli/media.ts +16 -0
- package/src/cli/restart.ts +2 -2
- package/src/cli/script.ts +2 -1
- package/src/cli/send.ts +4 -6
- package/src/cli/start.ts +108 -19
- package/src/cli/stop.ts +11 -4
- package/src/cli/types.ts +1 -0
- package/src/client/client.ts +33 -18
- package/src/client/protocol.ts +13 -11
- package/src/experimental/reproduce-stale-exploring-empty.ts +102 -0
- package/src/recording/decode.ts +93 -0
- package/src/recording/export.ts +113 -0
- package/src/recording/index.ts +13 -0
- package/src/recording/render.ts +80 -0
- package/src/recording/replay.ts +55 -0
- package/src/recording/terminal.ts +124 -0
- package/src/recording/types.ts +50 -0
- package/src/cli/api.ts +0 -5
- package/src/client/protocol.types.ts +0 -62
- package/src/experimental/reproduce-stale-running-visible.ts +0 -60
- package/src/experimental/reproduce-stale-running.ts +0 -26
- package/src/experimental/stale-running-driver.ts +0 -106
package/src/cli/stop.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import {
|
|
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
|
|
7
|
-
|
|
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(
|
|
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
package/src/client/client.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
182
|
-
return this.call("ui.
|
|
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. */
|
package/src/client/protocol.ts
CHANGED
|
@@ -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
|
|
115
|
-
export
|
|
114
|
+
export const RecordingFinish = Schema.String
|
|
115
|
+
export type RecordingFinish = Schema.Schema.Type<typeof RecordingFinish>
|
|
116
116
|
|
|
117
|
-
export const
|
|
118
|
-
|
|
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.
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
+
}
|