@erclx/canon 5.65.0 → 5.67.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/app-drive/REQUIREMENT.md +35 -0
  4. package/claude/skills/app-drive/SKILL.md +112 -0
  5. package/claude/skills/app-drive/references/config.md +38 -0
  6. package/claude/skills/app-drive/references/drive-record.md +70 -0
  7. package/claude/skills/app-drive/references/isolate.md +46 -0
  8. package/claude/skills/canon-cli/SKILL.md +2 -0
  9. package/claude/skills/create-skill/references/skill.md +1 -1
  10. package/claude/skills/git-pr/references/labels.md +6 -2
  11. package/claude/skills/git-pr-close/REQUIREMENT.md +29 -0
  12. package/claude/skills/git-pr-close/SKILL.md +55 -0
  13. package/claude/skills/git-pr-close/references/close.md +54 -0
  14. package/claude/skills/plan-feature/references/plan.md +1 -1
  15. package/claude/skills/plan-intake/references/intake.md +12 -2
  16. package/claude/skills/pr-evidence/SKILL.md +2 -2
  17. package/claude/skills/pr-evidence/references/evidence.md +3 -1
  18. package/claude/skills/review-ui/SKILL.md +5 -4
  19. package/claude/skills/review-ui/references/drive-copy.md +4 -0
  20. package/claude/skills/review-ui/references/driving.md +1 -1
  21. package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +15 -2
  22. package/claude/skills/role-planner/SKILL.md +2 -2
  23. package/claude/skills/role-worker/SKILL.md +6 -8
  24. package/claude/skills/session-map/REQUIREMENT.md +1 -1
  25. package/claude/skills/session-map/SKILL.md +8 -1
  26. package/claude/skills/session-map/references/session.md +19 -5
  27. package/claude/skills/ui-record/REQUIREMENT.md +34 -0
  28. package/claude/skills/ui-record/SKILL.md +103 -0
  29. package/claude/skills/ui-record/references/config.md +40 -0
  30. package/claude/skills/ui-record/references/handoff.md +48 -0
  31. package/claude/skills/ui-record/references/take-craft.md +92 -0
  32. package/docs/agents/records.md +1 -0
  33. package/docs/workflow/parallel-features.md +1 -1
  34. package/docs/workflow/skill-map-on-request.md +1 -0
  35. package/docs/workflow/skill-map.md +2 -0
  36. package/docs/workflow/visual-design-workflow.md +2 -1
  37. package/package.json +1 -1
  38. package/src/audits/catalog.ts +5 -1
  39. package/src/browser/agent-cli.ts +68 -0
  40. package/src/claude/cases/git.ts +4 -0
  41. package/src/claude/cases/workflow.ts +20 -0
  42. package/src/cli/help.ts +3 -0
  43. package/src/cli.ts +4 -0
  44. package/src/commands/browser.ts +102 -0
  45. package/src/commands/intake.ts +181 -1
  46. package/src/commands/labels.ts +152 -4
  47. package/src/commands/pr.ts +278 -8
  48. package/src/commands/records.ts +18 -1
  49. package/src/commands/take.ts +266 -0
  50. package/src/context/requirements.ts +1 -11
  51. package/src/git/status.ts +73 -0
  52. package/src/intake/drive-check.ts +355 -0
  53. package/src/intake/folder.ts +1 -1
  54. package/src/labels/apply.ts +10 -5
  55. package/src/labels/sync.ts +17 -0
  56. package/src/markdown/scan.ts +10 -0
  57. package/src/pr/close.ts +152 -0
  58. package/src/pr/drive-copy.ts +97 -84
  59. package/src/pr/health.ts +56 -0
  60. package/src/pr/local.ts +49 -8
  61. package/src/pr/rest.ts +10 -2
  62. package/src/records/session-map.ts +75 -0
  63. package/src/records/validate.ts +64 -8
  64. package/src/sandbox/replay/run.ts +30 -5
  65. package/src/take/camera.ts +309 -0
  66. package/src/take/encode.ts +170 -0
  67. package/src/take/film.ts +206 -0
  68. package/src/take/fixtures/fade.html +49 -0
  69. package/src/take/fixtures/fetch.html +38 -0
  70. package/src/take/log.ts +109 -0
  71. package/src/take/perform.ts +200 -0
  72. package/src/take/plan.ts +193 -0
  73. package/src/take/screencast.ts +94 -0
  74. package/src/take/shell.ts +287 -0
  75. package/src/take/stepper.ts +252 -0
  76. package/src/take/zoom/NOTICE.md +45 -0
  77. package/src/take/zoom/math-utils.ts +69 -0
  78. package/src/take/zoom/region-utils.ts +55 -0
  79. package/src/take/zoom/spring.ts +133 -0
  80. package/src/tasks/archive.ts +1 -3
  81. package/src/tooling/verify-stack.ts +4 -5
  82. package/tooling/base/reference.md +1 -0
  83. package/tooling/base/seeds/canon/config/pr-labels.toml +41 -0
  84. package/claude/skills/review-ui/scripts/pw.sh +0 -60
@@ -0,0 +1,309 @@
1
+ import {
2
+ type Box,
3
+ type LogEvent,
4
+ type Point,
5
+ type TakeLog,
6
+ cursorAt,
7
+ } from '@/take/log'
8
+ import {
9
+ type SpringState,
10
+ createSpringState,
11
+ stepSpring,
12
+ } from '@/take/zoom/spring'
13
+ import {
14
+ CHAINED_ZOOM_PAN_GAP_MS,
15
+ CONNECTED_ZOOM_PAN_DURATION_MS,
16
+ computeRegionStrength,
17
+ easeConnectedPan,
18
+ lerp,
19
+ } from '@/take/zoom/region-utils'
20
+
21
+ /**
22
+ * Turns a take's log into one camera per frame, preset O4 from groundwork
23
+ * track `193-ui-record`: OpenScreen's timing aimed at each click, a fixed
24
+ * depth, a focus that holds still until a click leaves the inner part of the
25
+ * view, and a dead zone that keeps the cursor in frame. HyperFrames renders the
26
+ * track as it stands, so it carries no curve a renderer would have to agree on.
27
+ */
28
+
29
+ export const CAMERA_VERSION = 1
30
+
31
+ /** The depth every click zooms to, unless its target already fills the view. */
32
+ const DEPTH = 1.8
33
+
34
+ /** How much of the zoomed view a target may fill, which caps the depth for a big one. */
35
+ const FILL = 0.8
36
+
37
+ /** A click whose depth comes out under this zooms nowhere, being nearly the whole page. */
38
+ const MIN_ZOOM = 1.05
39
+
40
+ /** How long a zoom holds past its last click or keystroke. */
41
+ const HOLD_MS = 300
42
+
43
+ /** A click inside this share of the current view keeps the focus where it is. */
44
+ const STICKY = 0.7
45
+
46
+ /** The cursor stays inside this share of the zoomed view. */
47
+ const SAFE = 0.75
48
+
49
+ /**
50
+ * The cursor never passes this share of the rendered view. The dead zone above
51
+ * aims the camera, and the spring chasing it lags a long fast reach by more
52
+ * than the dead zone's margin, so the rendered view is pushed along with the
53
+ * cursor at this edge rather than letting it leave the frame.
54
+ */
55
+ const EDGE = 0.9
56
+
57
+ /** How long the click ring the edit draws takes to expand. */
58
+ const RING_MS = 450
59
+
60
+ export interface CameraFrame {
61
+ readonly ms: number
62
+ /** How far the view is zoomed, 1 being the whole page. */
63
+ readonly scale: number
64
+ /** The view's top-left corner in CSS pixels of the page. */
65
+ readonly x: number
66
+ readonly y: number
67
+ /** Where the edit draws the cursor, in CSS pixels of the page. */
68
+ readonly cursor: Point
69
+ /** How far a click's ring has expanded, 0 to 1, or null with none showing. */
70
+ readonly ring: number | null
71
+ }
72
+
73
+ export interface CameraTrack {
74
+ readonly version: typeof CAMERA_VERSION
75
+ readonly viewport: { readonly width: number; readonly height: number }
76
+ readonly fps: number
77
+ readonly frames: readonly CameraFrame[]
78
+ }
79
+
80
+ /** A click, or a typing burst read as a click on its field held to the last key. */
81
+ interface Aim {
82
+ readonly t: number
83
+ readonly until: number
84
+ readonly at: Point
85
+ readonly depth: number
86
+ }
87
+
88
+ interface Window {
89
+ start: number
90
+ end: number
91
+ readonly aims: Aim[]
92
+ }
93
+
94
+ export function cameraTrack(log: TakeLog): CameraTrack {
95
+ const { width, height } = log.viewport
96
+ const depthFor = (box: Box): number =>
97
+ Math.min(
98
+ DEPTH,
99
+ (width * FILL) / Math.max(box.width, 1),
100
+ (height * FILL) / Math.max(box.height, 1),
101
+ )
102
+
103
+ const aims: Aim[] = log.events
104
+ .flatMap((event): Aim[] => {
105
+ if (event.kind === 'click') {
106
+ return [
107
+ {
108
+ t: event.t,
109
+ until: event.t,
110
+ at: event.at,
111
+ depth: depthFor(event.box),
112
+ },
113
+ ]
114
+ }
115
+ if (event.kind === 'type') {
116
+ const box = event.box
117
+ return [
118
+ {
119
+ t: event.t,
120
+ until: event.t + event.ms,
121
+ at: { x: box.x + box.width / 2, y: box.y + box.height / 2 },
122
+ depth: depthFor(box),
123
+ },
124
+ ]
125
+ }
126
+ return []
127
+ })
128
+ .filter((aim) => aim.depth >= MIN_ZOOM)
129
+ .sort((a, b) => a.t - b.t)
130
+
131
+ const scrolls = log.events.filter((event) => event.kind === 'scroll')
132
+ // Read off every click rather than off the aims, which drop a click too large
133
+ // to zoom and add a typing burst that is no press.
134
+ const clicks = log.events.filter((event) => event.kind === 'click')
135
+ const windows = zoomWindows(aims, scrolls)
136
+
137
+ const targetAt = (ms: number) => {
138
+ let best = { strength: 0, scale: 1, x: width / 2, y: height / 2 }
139
+ for (const window of windows) {
140
+ const strength = computeRegionStrength(
141
+ { startMs: window.aims[0]?.t ?? window.start, endMs: window.end },
142
+ ms,
143
+ )
144
+ if (strength <= best.strength) continue
145
+
146
+ const focus = focusAt(window.aims, ms, width, height)
147
+ const half = {
148
+ x: width / (2 * focus.depth),
149
+ y: height / (2 * focus.depth),
150
+ }
151
+ const cursor = cursorAt(log.events, ms)
152
+ const x = clamp(
153
+ focus.x,
154
+ cursor.x - half.x * SAFE,
155
+ cursor.x + half.x * SAFE,
156
+ )
157
+ const y = clamp(
158
+ focus.y,
159
+ cursor.y - half.y * SAFE,
160
+ cursor.y + half.y * SAFE,
161
+ )
162
+
163
+ best = {
164
+ strength,
165
+ scale: lerp(1, focus.depth, strength),
166
+ x: lerp(width / 2, x, strength),
167
+ y: lerp(height / 2, y, strength),
168
+ }
169
+ }
170
+ return best
171
+ }
172
+
173
+ const frameMs = 1000 / log.fps
174
+ const springs: Record<'scale' | 'x' | 'y', SpringState> = {
175
+ scale: createSpringState(1),
176
+ x: createSpringState(width / 2),
177
+ y: createSpringState(height / 2),
178
+ }
179
+
180
+ const frames: CameraFrame[] = []
181
+ for (let index = 0; index < log.frames; index += 1) {
182
+ const ms = index * frameMs
183
+ const target = targetAt(ms)
184
+ const scale = Math.max(1, stepSpring(springs.scale, target.scale, frameMs))
185
+ const half = { x: width / (2 * scale), y: height / (2 * scale) }
186
+ const cursor = cursorAt(log.events, ms)
187
+ // Written back so the spring carries on from where the view was pushed.
188
+ springs.x.value = clamp(
189
+ stepSpring(springs.x, target.x, frameMs),
190
+ cursor.x - half.x * EDGE,
191
+ cursor.x + half.x * EDGE,
192
+ )
193
+ springs.y.value = clamp(
194
+ stepSpring(springs.y, target.y, frameMs),
195
+ cursor.y - half.y * EDGE,
196
+ cursor.y + half.y * EDGE,
197
+ )
198
+
199
+ const ring = clicks.find(
200
+ (click) => ms >= click.t && ms - click.t <= RING_MS,
201
+ )
202
+ frames.push({
203
+ ms: round(ms, 2),
204
+ scale: round(scale, 4),
205
+ x: round(clamp(springs.x.value, half.x, width - half.x) - half.x, 2),
206
+ y: round(clamp(springs.y.value, half.y, height - half.y) - half.y, 2),
207
+ cursor: roundPoint(cursor),
208
+ ring: ring ? round((ms - ring.t) / RING_MS, 3) : null,
209
+ })
210
+ }
211
+
212
+ return {
213
+ version: CAMERA_VERSION,
214
+ viewport: log.viewport,
215
+ fps: log.fps,
216
+ frames,
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Groups aims into zooms. One holds from its first aim to the last plus the
222
+ * hold, a scroll ends it since the content moves under the camera, and the next
223
+ * aim joins it while the gap stays under OpenScreen's chained-pan gap.
224
+ */
225
+ function zoomWindows(
226
+ aims: readonly Aim[],
227
+ scrolls: readonly LogEvent[],
228
+ ): Window[] {
229
+ const windows: Window[] = []
230
+ for (const aim of aims) {
231
+ let end = aim.until + HOLD_MS
232
+ const cut = scrolls.find((scroll) => scroll.t > aim.t && scroll.t < end)
233
+ if (cut) end = cut.t
234
+
235
+ const last = windows.at(-1)
236
+ const scrolledBetween =
237
+ last !== undefined &&
238
+ scrolls.some((scroll) => scroll.t >= last.end && scroll.t <= aim.t)
239
+ if (
240
+ last &&
241
+ !scrolledBetween &&
242
+ aim.t - last.end < CHAINED_ZOOM_PAN_GAP_MS
243
+ ) {
244
+ last.end = end
245
+ last.aims.push(aim)
246
+ } else {
247
+ windows.push({ start: aim.t, end, aims: [aim] })
248
+ }
249
+ }
250
+ return windows
251
+ }
252
+
253
+ /**
254
+ * Where a zoom aims at `ms`. The focus stays on a stop until an aim leaves the
255
+ * inner `STICKY` share of the view around it, which becomes the next stop, and
256
+ * it pans to each stop on OpenScreen's connected-pan curve, arriving as the
257
+ * click lands.
258
+ */
259
+ function focusAt(
260
+ aims: readonly Aim[],
261
+ ms: number,
262
+ width: number,
263
+ height: number,
264
+ ): Point & { depth: number } {
265
+ const [first, ...rest] = aims
266
+ if (!first) return { x: width / 2, y: height / 2, depth: 1 }
267
+
268
+ const stops = [first]
269
+ for (const aim of rest) {
270
+ const stop = stops.at(-1) ?? first
271
+ const inside =
272
+ Math.abs(aim.at.x - stop.at.x) < (width / (2 * stop.depth)) * STICKY &&
273
+ Math.abs(aim.at.y - stop.at.y) < (height / (2 * stop.depth)) * STICKY
274
+ if (!inside) stops.push(aim)
275
+ }
276
+
277
+ let focus = { x: first.at.x, y: first.at.y, depth: first.depth }
278
+ for (let i = 1; i < stops.length; i += 1) {
279
+ const from = stops[i - 1] ?? first
280
+ const to = stops[i] ?? first
281
+ const panStart = to.t - CONNECTED_ZOOM_PAN_DURATION_MS
282
+ if (ms >= to.t) {
283
+ focus = { x: to.at.x, y: to.at.y, depth: to.depth }
284
+ } else if (ms > panStart) {
285
+ const k = easeConnectedPan(
286
+ (ms - panStart) / CONNECTED_ZOOM_PAN_DURATION_MS,
287
+ )
288
+ focus = {
289
+ x: lerp(from.at.x, to.at.x, k),
290
+ y: lerp(from.at.y, to.at.y, k),
291
+ depth: lerp(from.depth, to.depth, k),
292
+ }
293
+ }
294
+ }
295
+ return focus
296
+ }
297
+
298
+ function clamp(value: number, low: number, high: number): number {
299
+ return Math.min(Math.max(value, low), high)
300
+ }
301
+
302
+ function round(value: number, places: number): number {
303
+ const factor = 10 ** places
304
+ return Math.round(value * factor) / factor
305
+ }
306
+
307
+ function roundPoint(point: Point): Point {
308
+ return { x: round(point.x, 1), y: round(point.y, 1) }
309
+ }
@@ -0,0 +1,170 @@
1
+ import { type ChildProcess, spawn } from 'node:child_process'
2
+
3
+ /**
4
+ * Joins a take's frames into an H.264 clip through the system FFmpeg. Canon
5
+ * ships no encoder, so FFmpeg is a setup step a target runs, and its absence is
6
+ * refused up front rather than after a take has been filmed.
7
+ */
8
+
9
+ export class FfmpegMissing extends Error {}
10
+
11
+ /** H.264 in yuv420p plays in every browser and editor HyperFrames reads from. */
12
+ const OUTPUT = [
13
+ '-c:v',
14
+ 'libx264',
15
+ '-preset',
16
+ 'medium',
17
+ '-crf',
18
+ '16',
19
+ '-pix_fmt',
20
+ 'yuv420p',
21
+ '-movflags',
22
+ '+faststart',
23
+ ]
24
+
25
+ /** Resolves when `ffmpeg` runs, and throws `FfmpegMissing` when it does not. */
26
+ export async function requireFfmpeg(): Promise<void> {
27
+ const proc = spawn('ffmpeg', ['-version'], { stdio: 'ignore' })
28
+ try {
29
+ await finished(proc)
30
+ } catch (error) {
31
+ throw new FfmpegMissing(
32
+ `ffmpeg did not run: ${error instanceof Error ? error.message : String(error)}`,
33
+ )
34
+ }
35
+ }
36
+
37
+ export interface FrameEncoder {
38
+ /** Appends one JPEG frame, waiting while FFmpeg catches up. */
39
+ write(jpeg: Buffer): Promise<void>
40
+ /** Ends the stream and resolves once the clip is written. */
41
+ close(): Promise<void>
42
+ /** Stops FFmpeg and leaves whatever it wrote. */
43
+ abort(): void
44
+ }
45
+
46
+ /**
47
+ * Streams JPEG frames into one clip at `fps`, so a 4K take never lands on disk
48
+ * as a folder of stills.
49
+ */
50
+ export function openFrameEncoder(out: string, fps: number): FrameEncoder {
51
+ const proc = spawn(
52
+ 'ffmpeg',
53
+ [
54
+ '-y',
55
+ '-loglevel',
56
+ 'error',
57
+ '-f',
58
+ 'image2pipe',
59
+ '-framerate',
60
+ String(fps),
61
+ '-c:v',
62
+ 'mjpeg',
63
+ '-i',
64
+ '-',
65
+ // A JPEG is full range and H.264 is read as TV range, so without this the
66
+ // levels pass through unscaled and a player crushes the blacks and whites.
67
+ '-vf',
68
+ 'scale=in_range=full:out_range=tv',
69
+ ...OUTPUT,
70
+ out,
71
+ ],
72
+ { stdio: ['pipe', 'ignore', 'pipe'] },
73
+ )
74
+ const done = finished(proc)
75
+ // Held so a failure while frames are still being written surfaces at close
76
+ // rather than as an unhandled rejection.
77
+ done.catch(() => {})
78
+
79
+ return {
80
+ write: (jpeg) =>
81
+ new Promise((resolve, reject) => {
82
+ const stdin = proc.stdin
83
+ if (!stdin || stdin.destroyed) {
84
+ reject(new Error('ffmpeg stopped reading frames'))
85
+ return
86
+ }
87
+ if (stdin.write(jpeg)) resolve()
88
+ else stdin.once('drain', resolve)
89
+ }),
90
+ close: async () => {
91
+ proc.stdin?.end()
92
+ await done
93
+ },
94
+ abort: () => {
95
+ proc.kill('SIGKILL')
96
+ },
97
+ }
98
+ }
99
+
100
+ /** Re-encodes a recorded clip to the same H.264 output at a constant `fps`. */
101
+ export async function transcode(
102
+ input: string,
103
+ out: string,
104
+ fps: number,
105
+ ): Promise<void> {
106
+ const proc = spawn(
107
+ 'ffmpeg',
108
+ [
109
+ '-y',
110
+ '-loglevel',
111
+ 'error',
112
+ '-i',
113
+ input,
114
+ '-r',
115
+ String(fps),
116
+ ...OUTPUT,
117
+ out,
118
+ ],
119
+ { stdio: ['ignore', 'ignore', 'pipe'] },
120
+ )
121
+ await finished(proc)
122
+ }
123
+
124
+ /**
125
+ * Counts a clip's frames, which a real-time take cannot know in advance and a
126
+ * camera track has to match one for one.
127
+ */
128
+ export async function probeFrames(clip: string): Promise<number> {
129
+ const proc = spawn(
130
+ 'ffprobe',
131
+ [
132
+ '-v',
133
+ 'error',
134
+ '-select_streams',
135
+ 'v:0',
136
+ '-count_packets',
137
+ '-show_entries',
138
+ 'stream=nb_read_packets',
139
+ '-of',
140
+ 'csv=p=0',
141
+ clip,
142
+ ],
143
+ { stdio: ['ignore', 'pipe', 'pipe'] },
144
+ )
145
+ let stdout = ''
146
+ proc.stdout?.on('data', (chunk: Buffer) => {
147
+ stdout += chunk.toString()
148
+ })
149
+ await finished(proc)
150
+ const frames = Number.parseInt(stdout.trim(), 10)
151
+ if (!Number.isFinite(frames)) {
152
+ throw new Error(`ffprobe read no frame count from ${clip}`)
153
+ }
154
+ return frames
155
+ }
156
+
157
+ function finished(proc: ChildProcess): Promise<void> {
158
+ let stderr = ''
159
+ proc.stderr?.on('data', (chunk: Buffer) => {
160
+ stderr += chunk.toString()
161
+ })
162
+ return new Promise((resolve, reject) => {
163
+ proc.on('error', reject)
164
+ proc.on('close', (code) => {
165
+ if (code === 0) resolve()
166
+ else
167
+ reject(new Error(`ffmpeg exited ${code}: ${stderr.trim().slice(-400)}`))
168
+ })
169
+ })
170
+ }
@@ -0,0 +1,206 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs'
2
+ import { join, resolve } from 'node:path'
3
+ import { isBrowserMissing, isServerUnreachable } from '@/browser/engine'
4
+ import { cameraTrack } from '@/take/camera'
5
+ import { FfmpegMissing, requireFfmpeg } from '@/take/encode'
6
+ import {
7
+ FPS,
8
+ LOG_VERSION,
9
+ type LogEvent,
10
+ type TakeLog,
11
+ VIEWPORT,
12
+ } from '@/take/log'
13
+ import { TargetMissing } from '@/take/perform'
14
+ import type { Beat, Route, TakePlan } from '@/take/plan'
15
+ import { filmScreencast } from '@/take/screencast'
16
+ import { ShellMissing, findShell } from '@/take/shell'
17
+ import { StepperStalled, filmStepped } from '@/take/stepper'
18
+
19
+ /**
20
+ * Films every beat of a plan into its own folder and writes the record the
21
+ * edit reads. Each beat opens the page fresh, so a beat starts from the state
22
+ * its setup reaches rather than wherever the last one left the page.
23
+ */
24
+
25
+ export const TAKE_VERSION = 1
26
+
27
+ export type TakeRefusal =
28
+ | 'ffmpeg-missing'
29
+ | 'browser-missing'
30
+ | 'server-unreachable'
31
+ | 'target-missing'
32
+ | 'take-failed'
33
+
34
+ export interface FilmedBeat {
35
+ readonly name: string
36
+ /** The beat's folder, relative to the take's. */
37
+ readonly dir: string
38
+ readonly requested: Route
39
+ readonly route: Route
40
+ /** Why a `step` beat was filmed by screencast instead, when it was. */
41
+ readonly fallback?: string
42
+ readonly frames: number
43
+ readonly seconds: number
44
+ readonly clip: string
45
+ readonly log: string
46
+ readonly camera: string
47
+ }
48
+
49
+ export type TakeResult =
50
+ | {
51
+ readonly status: 'filmed'
52
+ readonly out: string
53
+ readonly beats: readonly FilmedBeat[]
54
+ readonly durationMs: number
55
+ }
56
+ | {
57
+ readonly status: 'failed'
58
+ readonly reason: TakeRefusal
59
+ readonly message: string
60
+ readonly beat?: string
61
+ }
62
+
63
+ export interface TakeRequest {
64
+ readonly url: string
65
+ readonly plan: TakePlan
66
+ readonly out: string
67
+ readonly env?: NodeJS.ProcessEnv
68
+ }
69
+
70
+ const REACH_TIMEOUT_MS = 5000
71
+
72
+ export async function take(request: TakeRequest): Promise<TakeResult> {
73
+ const started = Date.now()
74
+ const out = resolve(request.out)
75
+
76
+ try {
77
+ await requireFfmpeg()
78
+ } catch (error) {
79
+ if (error instanceof FfmpegMissing) return failed('ffmpeg-missing', error)
80
+ throw error
81
+ }
82
+
83
+ let executable = ''
84
+ if (request.plan.beats.some((beat) => beat.route === 'step')) {
85
+ try {
86
+ executable = findShell(request.env)
87
+ } catch (error) {
88
+ if (error instanceof ShellMissing) return failed('browser-missing', error)
89
+ throw error
90
+ }
91
+ }
92
+
93
+ // Probes the origin rather than the address, since an address can carry a
94
+ // single-use ticket that a probe would spend before the browser opens it.
95
+ try {
96
+ await fetch(new URL(request.url).origin, {
97
+ signal: AbortSignal.timeout(REACH_TIMEOUT_MS),
98
+ })
99
+ } catch (error) {
100
+ return failed('server-unreachable', error)
101
+ }
102
+
103
+ mkdirSync(out, { recursive: true })
104
+ const beats: FilmedBeat[] = []
105
+ for (const [index, beat] of request.plan.beats.entries()) {
106
+ const dir = `${String(index + 1).padStart(2, '0')}-${slug(beat.name)}`
107
+ mkdirSync(join(out, dir), { recursive: true })
108
+ try {
109
+ beats.push(await filmBeat(request.url, beat, out, dir, executable))
110
+ } catch (error) {
111
+ return failed(classify(error), error, beat.name)
112
+ }
113
+ }
114
+
115
+ writeFileSync(
116
+ join(out, 'take.json'),
117
+ `${JSON.stringify({ version: TAKE_VERSION, url: request.url, beats }, null, 2)}\n`,
118
+ )
119
+ return { status: 'filmed', out, beats, durationMs: Date.now() - started }
120
+ }
121
+
122
+ async function filmBeat(
123
+ url: string,
124
+ beat: Beat,
125
+ out: string,
126
+ dir: string,
127
+ executable: string,
128
+ ): Promise<FilmedBeat> {
129
+ const clip = join(dir, 'clip.mp4')
130
+ const target = join(out, clip)
131
+
132
+ let route: Route = beat.route
133
+ let fallback: string | undefined
134
+ let filmed: { frames: number; events: readonly LogEvent[] }
135
+ if (beat.route === 'step') {
136
+ try {
137
+ filmed = await filmStepped({ executable, url, beat, clip: target })
138
+ } catch (error) {
139
+ if (!(error instanceof StepperStalled)) throw error
140
+ route = 'screencast'
141
+ fallback = error.message
142
+ filmed = await filmScreencast({ url, beat, clip: target })
143
+ }
144
+ } else {
145
+ filmed = await filmScreencast({ url, beat, clip: target })
146
+ }
147
+
148
+ const log: TakeLog = {
149
+ version: LOG_VERSION,
150
+ viewport: VIEWPORT,
151
+ fps: FPS,
152
+ route,
153
+ frames: filmed.frames,
154
+ events: filmed.events,
155
+ }
156
+ const logPath = join(dir, 'log.json')
157
+ writeFileSync(join(out, logPath), `${JSON.stringify(log, null, 2)}\n`)
158
+ // One line, since it carries a row per frame and nobody reads it by eye.
159
+ const cameraPath = join(dir, 'camera.json')
160
+ writeFileSync(join(out, cameraPath), `${JSON.stringify(cameraTrack(log))}\n`)
161
+
162
+ return {
163
+ name: beat.name,
164
+ dir,
165
+ requested: beat.route,
166
+ route,
167
+ ...(fallback ? { fallback } : {}),
168
+ frames: filmed.frames,
169
+ seconds: Number((filmed.frames / FPS).toFixed(2)),
170
+ clip,
171
+ log: logPath,
172
+ camera: cameraPath,
173
+ }
174
+ }
175
+
176
+ function classify(error: unknown): TakeRefusal {
177
+ if (error instanceof TargetMissing) return 'target-missing'
178
+ if (error instanceof ShellMissing || isBrowserMissing(error)) {
179
+ return 'browser-missing'
180
+ }
181
+ if (isServerUnreachable(error)) return 'server-unreachable'
182
+ return 'take-failed'
183
+ }
184
+
185
+ function failed(
186
+ reason: TakeRefusal,
187
+ error: unknown,
188
+ beat?: string,
189
+ ): TakeResult {
190
+ return {
191
+ status: 'failed',
192
+ reason,
193
+ message: error instanceof Error ? error.message : String(error),
194
+ ...(beat ? { beat } : {}),
195
+ }
196
+ }
197
+
198
+ function slug(name: string): string {
199
+ return (
200
+ name
201
+ .toLowerCase()
202
+ .replace(/[^a-z0-9]+/g, '-')
203
+ .replace(/^-|-$/g, '')
204
+ .slice(0, 40) || 'beat'
205
+ )
206
+ }