@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.
- package/CHANGELOG.md +26 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/app-drive/REQUIREMENT.md +35 -0
- package/claude/skills/app-drive/SKILL.md +112 -0
- package/claude/skills/app-drive/references/config.md +38 -0
- package/claude/skills/app-drive/references/drive-record.md +70 -0
- package/claude/skills/app-drive/references/isolate.md +46 -0
- package/claude/skills/canon-cli/SKILL.md +2 -0
- package/claude/skills/create-skill/references/skill.md +1 -1
- package/claude/skills/git-pr/references/labels.md +6 -2
- package/claude/skills/git-pr-close/REQUIREMENT.md +29 -0
- package/claude/skills/git-pr-close/SKILL.md +55 -0
- package/claude/skills/git-pr-close/references/close.md +54 -0
- package/claude/skills/plan-feature/references/plan.md +1 -1
- package/claude/skills/plan-intake/references/intake.md +12 -2
- package/claude/skills/pr-evidence/SKILL.md +2 -2
- package/claude/skills/pr-evidence/references/evidence.md +3 -1
- package/claude/skills/review-ui/SKILL.md +5 -4
- package/claude/skills/review-ui/references/drive-copy.md +4 -0
- package/claude/skills/review-ui/references/driving.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +15 -2
- package/claude/skills/role-planner/SKILL.md +2 -2
- package/claude/skills/role-worker/SKILL.md +6 -8
- package/claude/skills/session-map/REQUIREMENT.md +1 -1
- package/claude/skills/session-map/SKILL.md +8 -1
- package/claude/skills/session-map/references/session.md +19 -5
- package/claude/skills/ui-record/REQUIREMENT.md +34 -0
- package/claude/skills/ui-record/SKILL.md +103 -0
- package/claude/skills/ui-record/references/config.md +40 -0
- package/claude/skills/ui-record/references/handoff.md +48 -0
- package/claude/skills/ui-record/references/take-craft.md +92 -0
- package/docs/agents/records.md +1 -0
- package/docs/workflow/parallel-features.md +1 -1
- package/docs/workflow/skill-map-on-request.md +1 -0
- package/docs/workflow/skill-map.md +2 -0
- package/docs/workflow/visual-design-workflow.md +2 -1
- package/package.json +1 -1
- package/src/audits/catalog.ts +5 -1
- package/src/browser/agent-cli.ts +68 -0
- package/src/claude/cases/git.ts +4 -0
- package/src/claude/cases/workflow.ts +20 -0
- package/src/cli/help.ts +3 -0
- package/src/cli.ts +4 -0
- package/src/commands/browser.ts +102 -0
- package/src/commands/intake.ts +181 -1
- package/src/commands/labels.ts +152 -4
- package/src/commands/pr.ts +278 -8
- package/src/commands/records.ts +18 -1
- package/src/commands/take.ts +266 -0
- package/src/context/requirements.ts +1 -11
- package/src/git/status.ts +73 -0
- package/src/intake/drive-check.ts +355 -0
- package/src/intake/folder.ts +1 -1
- package/src/labels/apply.ts +10 -5
- package/src/labels/sync.ts +17 -0
- package/src/markdown/scan.ts +10 -0
- package/src/pr/close.ts +152 -0
- package/src/pr/drive-copy.ts +97 -84
- package/src/pr/health.ts +56 -0
- package/src/pr/local.ts +49 -8
- package/src/pr/rest.ts +10 -2
- package/src/records/session-map.ts +75 -0
- package/src/records/validate.ts +64 -8
- package/src/sandbox/replay/run.ts +30 -5
- package/src/take/camera.ts +309 -0
- package/src/take/encode.ts +170 -0
- package/src/take/film.ts +206 -0
- package/src/take/fixtures/fade.html +49 -0
- package/src/take/fixtures/fetch.html +38 -0
- package/src/take/log.ts +109 -0
- package/src/take/perform.ts +200 -0
- package/src/take/plan.ts +193 -0
- package/src/take/screencast.ts +94 -0
- package/src/take/shell.ts +287 -0
- package/src/take/stepper.ts +252 -0
- package/src/take/zoom/NOTICE.md +45 -0
- package/src/take/zoom/math-utils.ts +69 -0
- package/src/take/zoom/region-utils.ts +55 -0
- package/src/take/zoom/spring.ts +133 -0
- package/src/tasks/archive.ts +1 -3
- package/src/tooling/verify-stack.ts +4 -5
- package/tooling/base/reference.md +1 -0
- package/tooling/base/seeds/canon/config/pr-labels.toml +41 -0
- 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
|
+
}
|
package/src/take/film.ts
ADDED
|
@@ -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
|
+
}
|