@erclx/canon 5.66.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 (66) hide show
  1. package/CHANGELOG.md +11 -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/plan-feature/references/plan.md +1 -1
  11. package/claude/skills/plan-intake/references/intake.md +12 -2
  12. package/claude/skills/review-ui/SKILL.md +5 -4
  13. package/claude/skills/review-ui/references/drive-copy.md +4 -0
  14. package/claude/skills/review-ui/references/driving.md +1 -1
  15. package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +15 -2
  16. package/claude/skills/role-planner/SKILL.md +2 -2
  17. package/claude/skills/role-worker/SKILL.md +4 -6
  18. package/claude/skills/session-map/REQUIREMENT.md +1 -1
  19. package/claude/skills/session-map/SKILL.md +8 -1
  20. package/claude/skills/session-map/references/session.md +19 -5
  21. package/claude/skills/ui-record/REQUIREMENT.md +34 -0
  22. package/claude/skills/ui-record/SKILL.md +103 -0
  23. package/claude/skills/ui-record/references/config.md +40 -0
  24. package/claude/skills/ui-record/references/handoff.md +48 -0
  25. package/claude/skills/ui-record/references/take-craft.md +92 -0
  26. package/docs/agents/records.md +1 -0
  27. package/docs/workflow/parallel-features.md +1 -1
  28. package/docs/workflow/skill-map-on-request.md +1 -0
  29. package/docs/workflow/skill-map.md +1 -0
  30. package/docs/workflow/visual-design-workflow.md +2 -1
  31. package/package.json +1 -1
  32. package/src/audits/catalog.ts +5 -1
  33. package/src/browser/agent-cli.ts +68 -0
  34. package/src/claude/cases/workflow.ts +20 -0
  35. package/src/cli/help.ts +3 -0
  36. package/src/cli.ts +4 -0
  37. package/src/commands/browser.ts +102 -0
  38. package/src/commands/intake.ts +181 -1
  39. package/src/commands/pr.ts +15 -6
  40. package/src/commands/records.ts +18 -1
  41. package/src/commands/take.ts +266 -0
  42. package/src/context/requirements.ts +1 -11
  43. package/src/git/status.ts +73 -0
  44. package/src/intake/drive-check.ts +355 -0
  45. package/src/intake/folder.ts +1 -1
  46. package/src/markdown/scan.ts +10 -0
  47. package/src/pr/drive-copy.ts +97 -84
  48. package/src/records/session-map.ts +75 -0
  49. package/src/records/validate.ts +64 -8
  50. package/src/take/camera.ts +309 -0
  51. package/src/take/encode.ts +170 -0
  52. package/src/take/film.ts +206 -0
  53. package/src/take/fixtures/fade.html +49 -0
  54. package/src/take/fixtures/fetch.html +38 -0
  55. package/src/take/log.ts +109 -0
  56. package/src/take/perform.ts +200 -0
  57. package/src/take/plan.ts +193 -0
  58. package/src/take/screencast.ts +94 -0
  59. package/src/take/shell.ts +287 -0
  60. package/src/take/stepper.ts +252 -0
  61. package/src/take/zoom/NOTICE.md +45 -0
  62. package/src/take/zoom/math-utils.ts +69 -0
  63. package/src/take/zoom/region-utils.ts +55 -0
  64. package/src/take/zoom/spring.ts +133 -0
  65. package/src/tasks/archive.ts +1 -3
  66. package/claude/skills/review-ui/scripts/pw.sh +0 -60
@@ -0,0 +1,75 @@
1
+ import { bodyLines, wordCount } from '@/markdown/scan'
2
+
3
+ /** The pre-compaction handoff takes one file per session, so its stems vary. */
4
+ export const SESSION_MAP_PREFIX = 'session-'
5
+
6
+ /**
7
+ * The most words a session map holds, which `session.md` states as
8
+ * `at most 350 words`. It lives here rather than being read off the standard
9
+ * because a consumer project loads skills from the plugin cache and carries no
10
+ * `session.md` to read, so a test pins the two to one number instead.
11
+ */
12
+ export const SESSION_MAP_WORD_CAP = 350
13
+
14
+ /**
15
+ * The closing block a role mandates, read by the resuming session rather than
16
+ * written from what this one learned, so it is left out of the count by name.
17
+ */
18
+ const RESUME_HEADING = '## Resume'
19
+
20
+ const TITLE = /^#[ \t]+\S/
21
+ const H2 = /^##[ \t]+\S/
22
+
23
+ export interface SessionMapLength {
24
+ /** The map's filename inside the tasks folder. */
25
+ readonly record: string
26
+ readonly words: number
27
+ readonly cap: number
28
+ readonly over: boolean
29
+ }
30
+
31
+ export function isSessionMap(name: string): boolean {
32
+ return name.startsWith(SESSION_MAP_PREFIX) && name.endsWith('.md')
33
+ }
34
+
35
+ /**
36
+ * Counts what a resuming reader has to read: the body below the title, with
37
+ * frontmatter and a `## Resume` section left out. A fenced block counts the way
38
+ * it does in the requirements record, since a reader reads it too, and a
39
+ * heading inside one neither opens nor closes a section.
40
+ */
41
+ export function sessionMapWords(source: string): number {
42
+ const kept: string[] = []
43
+ let titled = false
44
+ let resuming = false
45
+
46
+ for (const line of bodyLines(source)) {
47
+ const text = line.text.trim()
48
+
49
+ if (!line.fenced && H2.test(text)) resuming = text === RESUME_HEADING
50
+ if (resuming) continue
51
+
52
+ if (!titled && !line.fenced && TITLE.test(text)) {
53
+ titled = true
54
+ continue
55
+ }
56
+
57
+ kept.push(line.text)
58
+ }
59
+
60
+ return wordCount(kept.join('\n'))
61
+ }
62
+
63
+ export function measureSessionMap(
64
+ record: string,
65
+ source: string,
66
+ ): SessionMapLength {
67
+ const words = sessionMapWords(source)
68
+
69
+ return {
70
+ record,
71
+ words,
72
+ cap: SESSION_MAP_WORD_CAP,
73
+ over: words > SESSION_MAP_WORD_CAP,
74
+ }
75
+ }
@@ -3,6 +3,11 @@ import { readdir, readFile } from 'node:fs/promises'
3
3
  import { join } from 'node:path'
4
4
  import { parseFrontmatter, readField } from '@/indexes/frontmatter'
5
5
  import { linesOutsideFences } from '@/markdown/scan'
6
+ import {
7
+ isSessionMap,
8
+ measureSessionMap,
9
+ type SessionMapLength,
10
+ } from '@/records/session-map'
6
11
  import {
7
12
  intakeStructure,
8
13
  type StructureEntry,
@@ -13,7 +18,12 @@ import {
13
18
  recordDirs as resolveRecordDirs,
14
19
  } from '@/roots/record'
15
20
 
16
- export const RECORD_KINDS = ['plans', 'groundwork', 'intake'] as const
21
+ export const RECORD_KINDS = [
22
+ 'plans',
23
+ 'groundwork',
24
+ 'intake',
25
+ 'sessions',
26
+ ] as const
17
27
 
18
28
  export type RecordKind = (typeof RECORD_KINDS)[number]
19
29
 
@@ -79,6 +89,12 @@ export interface ValidateReport {
79
89
  readonly records: number
80
90
  readonly findings: readonly Finding[]
81
91
  readonly structure: readonly StructureFinding[]
92
+ /**
93
+ * Each session map's words against its cap, carried by the sessions kind
94
+ * alone. It never sets the exit, since a map refused near a compaction loses
95
+ * the reasoning it was written to save.
96
+ */
97
+ readonly length?: readonly SessionMapLength[]
82
98
  }
83
99
 
84
100
  export interface ValidateRefused {
@@ -89,9 +105,20 @@ export interface ValidateRefused {
89
105
 
90
106
  export type ValidateOutcome = ValidateReport | ValidateRefused
91
107
 
108
+ /**
109
+ * The record folder each kind reads. Session maps sit on the task board beside
110
+ * the tasks rather than in a folder of their own.
111
+ */
112
+ const KIND_FOLDER: Readonly<Record<RecordKind, string>> = {
113
+ plans: 'plans',
114
+ groundwork: 'groundwork',
115
+ intake: 'intake',
116
+ sessions: 'tasks',
117
+ }
118
+
92
119
  /** Every folder a kind would accept, whether or not it is on disk. */
93
120
  export function recordDirs(root: string, kind: RecordKind): string[] {
94
- return resolveRecordDirs(root, kind)
121
+ return resolveRecordDirs(root, KIND_FOLDER[kind])
95
122
  }
96
123
 
97
124
  /**
@@ -100,7 +127,7 @@ export function recordDirs(root: string, kind: RecordKind): string[] {
100
127
  * the location the kind would be written to.
101
128
  */
102
129
  export function recordsDir(root: string, kind: RecordKind): string {
103
- return resolveRecordDir(root, kind)
130
+ return resolveRecordDir(root, KIND_FOLDER[kind])
104
131
  }
105
132
 
106
133
  export function isRecordKind(value: string): value is RecordKind {
@@ -758,7 +785,7 @@ async function checkDump(dir: string, slug: string): Promise<Finding[]> {
758
785
  */
759
786
  const FOLDER_CHECK: Readonly<
760
787
  Record<
761
- Exclude<RecordKind, 'plans'>,
788
+ Exclude<RecordKind, 'plans' | 'sessions'>,
762
789
  (dir: string, slug: string) => Promise<Finding[]>
763
790
  >
764
791
  > = {
@@ -836,6 +863,37 @@ async function validateFiles(
836
863
  }
837
864
  }
838
865
 
866
+ /**
867
+ * The walk for session maps, which measures length alone. Their shape is not
868
+ * checked, so `findings` stays empty and the exit stays clean whatever the
869
+ * count reads.
870
+ */
871
+ async function validateSessionMaps(
872
+ dir: string,
873
+ files: readonly string[],
874
+ ): Promise<ValidateReport> {
875
+ const length = await Promise.all(
876
+ files.map(async (file) =>
877
+ measureSessionMap(file, await readFile(join(dir, file), 'utf8')),
878
+ ),
879
+ )
880
+
881
+ return {
882
+ ok: true,
883
+ kind: 'sessions',
884
+ records: files.length,
885
+ findings: [],
886
+ structure: [],
887
+ length,
888
+ }
889
+ }
890
+
891
+ async function listRecords(dir: string, kind: RecordKind): Promise<string[]> {
892
+ if (kind === 'plans') return listMarkdown(dir)
893
+ if (kind === 'sessions') return (await listMarkdown(dir)).filter(isSessionMap)
894
+ return listFolders(dir)
895
+ }
896
+
839
897
  /**
840
898
  * Narrows a listing to the one record a caller named, by a plan's stem or a
841
899
  * folder's slug, so a writing skill reads its own record and not every old one.
@@ -867,16 +925,14 @@ export async function validateRecords(
867
925
  )
868
926
  }
869
927
 
870
- const names = narrow(
871
- kind === 'plans' ? await listMarkdown(dir) : await listFolders(dir),
872
- record,
873
- )
928
+ const names = narrow(await listRecords(dir, kind), record)
874
929
 
875
930
  if (record !== undefined && names.length === 0) {
876
931
  return refuse('no-record', `No ${kind} record named ${record} in ${dir}.`)
877
932
  }
878
933
 
879
934
  if (kind === 'plans') return validateFiles(dir, kind, names, checkPlan)
935
+ if (kind === 'sessions') return validateSessionMaps(dir, names)
880
936
 
881
937
  const check = FOLDER_CHECK[kind]
882
938
  const perFolder = await Promise.all(
@@ -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
+ }