@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,287 @@
1
+ import { type ChildProcess, spawn } from 'node:child_process'
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
3
+ import { createRequire } from 'node:module'
4
+ import { homedir, tmpdir } from 'node:os'
5
+ import { dirname, join } from 'node:path'
6
+
7
+ /**
8
+ * Launches chrome-headless-shell under begin-frame control and speaks raw CDP
9
+ * to one page in it. Playwright cannot ask for begin-frame control on a page it
10
+ * opens, and only the headless shell honors the flag, so this bypasses the
11
+ * engine and finds the shell Playwright installed beside Chromium.
12
+ *
13
+ * `playwright-core` exposes no path for the shell, so the path is rebuilt from
14
+ * its `browsers.json` revision and its cache layout. A Playwright upgrade that
15
+ * moves that layout surfaces here as `browser-missing` rather than as a crash.
16
+ */
17
+
18
+ export class ShellMissing extends Error {}
19
+
20
+ /** Each platform's folder and binary inside the shell's revision folder. */
21
+ const SHELL_DIRS: Record<string, readonly [string, string]> = {
22
+ 'linux-x64': ['chrome-headless-shell-linux64', 'chrome-headless-shell'],
23
+ 'linux-arm64': ['chrome-headless-shell-linux-arm64', 'chrome-headless-shell'],
24
+ 'darwin-x64': ['chrome-headless-shell-mac-x64', 'chrome-headless-shell'],
25
+ 'darwin-arm64': ['chrome-headless-shell-mac-arm64', 'chrome-headless-shell'],
26
+ }
27
+
28
+ const FLAGS = [
29
+ '--enable-begin-frame-control',
30
+ '--run-all-compositor-stages-before-draw',
31
+ '--disable-threaded-animation',
32
+ '--disable-threaded-scrolling',
33
+ '--disable-checker-imaging',
34
+ '--hide-scrollbars',
35
+ '--force-device-scale-factor=2',
36
+ '--mute-audio',
37
+ '--no-first-run',
38
+ ]
39
+
40
+ /** Resolves the installed shell, throwing `ShellMissing` naming where it looked. */
41
+ export function findShell(env: NodeJS.ProcessEnv = process.env): string {
42
+ const require = createRequire(import.meta.url)
43
+ const engineRoot = dirname(require.resolve('playwright-core/package.json'))
44
+ const manifest = JSON.parse(
45
+ readFileSync(join(engineRoot, 'browsers.json'), 'utf8'),
46
+ ) as { browsers: { name: string; revision: string }[] }
47
+ const revision = manifest.browsers.find(
48
+ (browser) => browser.name === 'chromium-headless-shell',
49
+ )?.revision
50
+ const dirs = SHELL_DIRS[`${process.platform}-${process.arch}`]
51
+ if (!revision || !dirs) {
52
+ throw new ShellMissing(
53
+ `no chrome-headless-shell for ${process.platform}-${process.arch} in playwright-core's browsers.json`,
54
+ )
55
+ }
56
+
57
+ const path = join(
58
+ registryRoot(env, engineRoot),
59
+ `chromium_headless_shell-${revision}`,
60
+ ...dirs,
61
+ )
62
+ if (!existsSync(path)) {
63
+ throw new ShellMissing(
64
+ `chrome-headless-shell executable doesn't exist at ${path}`,
65
+ )
66
+ }
67
+ return path
68
+ }
69
+
70
+ /** Where Playwright keeps its browsers, read the way its own registry reads it. */
71
+ function registryRoot(env: NodeJS.ProcessEnv, engineRoot: string): string {
72
+ const pinned = env.PLAYWRIGHT_BROWSERS_PATH
73
+ if (pinned === '0') return join(engineRoot, '.local-browsers')
74
+ if (pinned) return pinned
75
+ if (process.platform === 'darwin') {
76
+ return join(homedir(), 'Library', 'Caches', 'ms-playwright')
77
+ }
78
+ return join(env.XDG_CACHE_HOME || join(homedir(), '.cache'), 'ms-playwright')
79
+ }
80
+
81
+ type Listener = (params: Record<string, unknown>) => void
82
+
83
+ export interface ShellPage {
84
+ send<T = Record<string, unknown>>(
85
+ method: string,
86
+ params?: Record<string, unknown>,
87
+ ): Promise<T>
88
+ /** Calls `listener` on every event of `method` from this page. */
89
+ on(method: string, listener: Listener): void
90
+ /** Runs an expression in the page and returns its value. */
91
+ evaluate<T>(expression: string): Promise<T>
92
+ /** Kills the shell and removes its profile, safe to call more than once. */
93
+ close(): void
94
+ }
95
+
96
+ /**
97
+ * Opens one page at `width` by `height` with begin-frame control. The launch
98
+ * owns its profile folder and kills the shell on process exit, so a killed take
99
+ * leaves no shell running and no profile behind.
100
+ */
101
+ export async function openShell(
102
+ executable: string,
103
+ size: { readonly width: number; readonly height: number },
104
+ ): Promise<ShellPage> {
105
+ const profile = mkdtempSync(join(tmpdir(), 'canon-take-'))
106
+ const proc = spawn(
107
+ executable,
108
+ [
109
+ '--remote-debugging-port=0',
110
+ `--user-data-dir=${profile}`,
111
+ ...FLAGS,
112
+ 'about:blank',
113
+ ],
114
+ { stdio: ['ignore', 'ignore', 'pipe'] },
115
+ )
116
+
117
+ let closed = false
118
+ const close = (): void => {
119
+ if (closed) return
120
+ closed = true
121
+ process.off('exit', close)
122
+ process.off('SIGINT', interrupt)
123
+ process.off('SIGTERM', interrupt)
124
+ socket?.close()
125
+ proc.kill('SIGKILL')
126
+ rmSync(profile, { recursive: true, force: true })
127
+ }
128
+ // A signal skips the exit event, so it closes the shell and then re-raises
129
+ // itself with the default handler restored, ending the process as it would have.
130
+ const interrupt = (signal: NodeJS.Signals): void => {
131
+ close()
132
+ process.kill(process.pid, signal)
133
+ }
134
+ process.on('exit', close)
135
+ process.once('SIGINT', interrupt)
136
+ process.once('SIGTERM', interrupt)
137
+
138
+ let socket: WebSocket | undefined
139
+ try {
140
+ socket = new WebSocket(await devtoolsUrl(proc))
141
+ await new Promise((done, fail) => {
142
+ socket?.addEventListener('open', done, { once: true })
143
+ socket?.addEventListener('error', fail, { once: true })
144
+ })
145
+ return await attach(socket, size, close)
146
+ } catch (error) {
147
+ close()
148
+ throw error
149
+ }
150
+ }
151
+
152
+ function devtoolsUrl(proc: ChildProcess): Promise<string> {
153
+ return new Promise((done, fail) => {
154
+ let output = ''
155
+ proc.stderr?.on('data', (chunk: Buffer) => {
156
+ output += chunk.toString()
157
+ const match = output.match(/DevTools listening on (ws:\S+)/)
158
+ if (match?.[1]) done(match[1])
159
+ })
160
+ proc.on('error', fail)
161
+ proc.on('exit', (code) =>
162
+ fail(new Error(`the shell exited ${code}: ${output.slice(-400)}`)),
163
+ )
164
+ })
165
+ }
166
+
167
+ async function attach(
168
+ socket: WebSocket,
169
+ size: { readonly width: number; readonly height: number },
170
+ close: () => void,
171
+ ): Promise<ShellPage> {
172
+ let nextId = 0
173
+ const pending = new Map<
174
+ number,
175
+ { resolve: (value: never) => void; reject: (error: Error) => void }
176
+ >()
177
+ const listeners: { method: string; sessionId: string; listener: Listener }[] =
178
+ []
179
+
180
+ socket.addEventListener('message', (event) => {
181
+ const message = JSON.parse(String(event.data)) as {
182
+ id?: number
183
+ method?: string
184
+ sessionId?: string
185
+ params?: Record<string, unknown>
186
+ result?: never
187
+ error?: { message: string; data?: string }
188
+ }
189
+ if (message.id !== undefined) {
190
+ const waiter = pending.get(message.id)
191
+ if (!waiter) return
192
+ pending.delete(message.id)
193
+ if (message.error) {
194
+ waiter.reject(
195
+ new Error(
196
+ `${message.error.message} ${message.error.data ?? ''}`.trim(),
197
+ ),
198
+ )
199
+ } else {
200
+ waiter.resolve(message.result as never)
201
+ }
202
+ return
203
+ }
204
+ for (const entry of listeners) {
205
+ if (
206
+ entry.method === message.method &&
207
+ entry.sessionId === message.sessionId
208
+ ) {
209
+ entry.listener(message.params ?? {})
210
+ }
211
+ }
212
+ })
213
+ socket.addEventListener('close', () => {
214
+ for (const waiter of pending.values()) {
215
+ waiter.reject(new Error('the shell closed its connection'))
216
+ }
217
+ pending.clear()
218
+ })
219
+
220
+ const raw = <T>(
221
+ method: string,
222
+ params: Record<string, unknown> = {},
223
+ sessionId?: string,
224
+ ): Promise<T> =>
225
+ new Promise<T>((resolve, reject) => {
226
+ nextId += 1
227
+ pending.set(nextId, {
228
+ resolve: resolve as (value: never) => void,
229
+ reject,
230
+ })
231
+ socket.send(
232
+ JSON.stringify({
233
+ id: nextId,
234
+ method,
235
+ params,
236
+ ...(sessionId ? { sessionId } : {}),
237
+ }),
238
+ )
239
+ })
240
+
241
+ const { browserContextId } = await raw<{ browserContextId: string }>(
242
+ 'Target.createBrowserContext',
243
+ )
244
+ const { targetId } = await raw<{ targetId: string }>('Target.createTarget', {
245
+ url: 'about:blank',
246
+ browserContextId,
247
+ enableBeginFrameControl: true,
248
+ width: size.width,
249
+ height: size.height,
250
+ })
251
+ const { sessionId } = await raw<{ sessionId: string }>(
252
+ 'Target.attachToTarget',
253
+ { targetId, flatten: true },
254
+ )
255
+
256
+ const send = <T>(method: string, params?: Record<string, unknown>) =>
257
+ raw<T>(method, params, sessionId)
258
+
259
+ await send('Page.enable')
260
+ await send('Runtime.enable')
261
+
262
+ return {
263
+ send,
264
+ on: (method, listener) => listeners.push({ method, sessionId, listener }),
265
+ evaluate: async <T>(expression: string): Promise<T> => {
266
+ const reply = await send<{
267
+ result: { value: T }
268
+ exceptionDetails?: {
269
+ text: string
270
+ exception?: { description?: string }
271
+ }
272
+ }>('Runtime.evaluate', {
273
+ expression,
274
+ awaitPromise: true,
275
+ returnByValue: true,
276
+ })
277
+ if (reply.exceptionDetails) {
278
+ throw new Error(
279
+ reply.exceptionDetails.exception?.description ??
280
+ reply.exceptionDetails.text,
281
+ )
282
+ }
283
+ return reply.result.value
284
+ },
285
+ close,
286
+ }
287
+ }
@@ -0,0 +1,252 @@
1
+ import { openFrameEncoder } from '@/take/encode'
2
+ import { FPS, type LogEvent, START, VIEWPORT } from '@/take/log'
3
+ import { type PointerInput, type Stage, performSteps } from '@/take/perform'
4
+ import type { Beat } from '@/take/plan'
5
+ import { type ShellPage, openShell } from '@/take/shell'
6
+
7
+ /**
8
+ * Films a beat by frame stepping. The page's clock is virtual and advances one
9
+ * 60 fps frame per step, so every transition, animation, and timer moves
10
+ * exactly one frame between screenshots however long a frame takes to render,
11
+ * and each screenshot comes back at twice the viewport, 3840x2160.
12
+ *
13
+ * Three orderings hold a take together, and each one broken deadlocks or
14
+ * freezes it rather than failing loudly:
15
+ *
16
+ * - An input is sent, a frame is stepped, and only then is the input awaited,
17
+ * since under begin-frame control an input is acknowledged by a frame
18
+ * - Frame time continues Chromium's monotonic clock, since a frame stamped on
19
+ * any other base leaves every CSS animation at its first frame
20
+ * - No frame is begun before the load event, since one sent mid-navigation can
21
+ * go unanswered, and each loading frame is capped so a lost one cannot hang
22
+ */
23
+
24
+ const FRAME_MS = 1000 / FPS
25
+
26
+ /** Budgets of 17, 17, and 16 ms average one 60 fps frame exactly. */
27
+ const BUDGETS = [17, 17, 16] as const
28
+
29
+ /** A frame slower than this means the page will not step, so the beat falls back. */
30
+ export const STALL_MS = 2000
31
+
32
+ /** The still the edit can cut on, before the beat's first action and after its last. */
33
+ export const HOLD_MS = 600
34
+
35
+ const LOAD_CAP_MS = 15_000
36
+ const SETTLE_MS = 2500
37
+ const LOADING_FRAME_CAP_MS = 250
38
+
39
+ /** A frame that took longer than `STALL_MS`, so the beat is filmed another way. */
40
+ export class StepperStalled extends Error {
41
+ constructor(readonly frame: number) {
42
+ super(`frame ${frame} took over ${STALL_MS / 1000} s under frame stepping`)
43
+ }
44
+ }
45
+
46
+ export interface SteppedTake {
47
+ readonly frames: number
48
+ readonly events: readonly LogEvent[]
49
+ }
50
+
51
+ export interface SteppedRequest {
52
+ readonly executable: string
53
+ readonly url: string
54
+ readonly beat: Beat
55
+ readonly clip: string
56
+ }
57
+
58
+ export async function filmStepped(
59
+ request: SteppedRequest,
60
+ ): Promise<SteppedTake> {
61
+ const page = await openShell(request.executable, VIEWPORT)
62
+ const encoder = openFrameEncoder(request.clip, FPS)
63
+ try {
64
+ let ticks = await load(page, request.url)
65
+ const beginFrame = (screenshot: boolean) =>
66
+ page.send<{ screenshotData?: string }>(
67
+ 'HeadlessExperimental.beginFrame',
68
+ {
69
+ frameTimeTicks: (ticks += FRAME_MS),
70
+ interval: FRAME_MS,
71
+ ...(screenshot
72
+ ? { screenshot: { format: 'jpeg', quality: 95 } }
73
+ : {}),
74
+ },
75
+ )
76
+ await pump(beginFrame, SETTLE_MS)
77
+
78
+ const live = liveStage(page, beginFrame)
79
+ await performSteps(live, request.beat.setup, { pointer: START })
80
+ if (request.beat.setup.length > 0) await live.hold(1000)
81
+ await live.pointer({ type: 'move', ...START })
82
+
83
+ await page.send('Emulation.setVirtualTimePolicy', { policy: 'pause' })
84
+
85
+ const events: LogEvent[] = []
86
+ let frames = 0
87
+ let budget = 0
88
+ let last: Buffer | undefined
89
+ const expired: (() => void)[] = []
90
+ page.on('Emulation.virtualTimeBudgetExpired', () => expired.shift()?.())
91
+
92
+ const frame = async (): Promise<void> => {
93
+ const advanced = new Promise<void>((done) => expired.push(done))
94
+ // Plain advance rather than pausing on pending fetches, which held the
95
+ // clock through every page switch that loads its view, past the stall
96
+ // limit. A refused advance never expires its budget, which the stall
97
+ // reports.
98
+ page
99
+ .send('Emulation.setVirtualTimePolicy', {
100
+ policy: 'advance',
101
+ budget: BUDGETS[budget++ % BUDGETS.length],
102
+ })
103
+ .catch(() => {})
104
+ const reply = await withinStall(
105
+ advanced.then(() => beginFrame(true)),
106
+ frames,
107
+ )
108
+ const data = reply.screenshotData
109
+ ? Buffer.from(reply.screenshotData, 'base64')
110
+ : last
111
+ if (!data) throw new Error('the first frame carried no screenshot')
112
+ last = data
113
+ await encoder.write(data)
114
+ frames += 1
115
+ }
116
+
117
+ const recording: Stage = {
118
+ now: () => frames * FRAME_MS,
119
+ hold: async (ms) => {
120
+ for (let i = 0; i < Math.round(ms / FRAME_MS); i += 1) await frame()
121
+ },
122
+ pointer: async (input) => {
123
+ const sent = page.send('Input.dispatchMouseEvent', mouseEvent(input))
124
+ await frame()
125
+ await sent
126
+ },
127
+ key: async (text) => {
128
+ const sent = page.send('Input.insertText', { text })
129
+ await frame()
130
+ await sent
131
+ },
132
+ evaluate: (expression) => page.evaluate(expression),
133
+ }
134
+
135
+ await recording.hold(HOLD_MS)
136
+ await performSteps(recording, request.beat.steps, {
137
+ pointer: START,
138
+ log: events,
139
+ })
140
+ await recording.hold(HOLD_MS)
141
+
142
+ await encoder.close()
143
+ return { frames, events }
144
+ } catch (error) {
145
+ encoder.abort()
146
+ throw error
147
+ } finally {
148
+ page.close()
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Navigates and resolves with Chromium's monotonic clock in milliseconds, read
154
+ * off the load event's own timestamp. The runtime's clock cannot stand in for
155
+ * it: Node's `hrtime` shares the base on Linux, Bun's counts from its own
156
+ * start, and a frame stamped on the wrong base freezes every animation.
157
+ */
158
+ async function load(page: ShellPage, url: string): Promise<number> {
159
+ const loaded = new Promise<number>((done) =>
160
+ page.on('Page.loadEventFired', ({ timestamp }) => {
161
+ if (typeof timestamp === 'number') done(timestamp * 1000)
162
+ }),
163
+ )
164
+ const navigated = await page.send<{ errorText?: string }>('Page.navigate', {
165
+ url,
166
+ })
167
+ if (navigated.errorText) {
168
+ throw new Error(`${navigated.errorText} at ${url}`)
169
+ }
170
+ const timestamp = await Promise.race([loaded, sleep(LOAD_CAP_MS)])
171
+ if (typeof timestamp !== 'number') {
172
+ throw new Error(
173
+ `${url} fired no load event within ${LOAD_CAP_MS / 1000} s, so no frame clock could be read`,
174
+ )
175
+ }
176
+ return timestamp
177
+ }
178
+
179
+ /** Begins frames in real time so loading, fonts, and layout settle. */
180
+ async function pump(
181
+ beginFrame: (screenshot: boolean) => Promise<unknown>,
182
+ ms: number,
183
+ ): Promise<void> {
184
+ const end = Date.now() + ms
185
+ while (Date.now() < end) {
186
+ await Promise.race([
187
+ beginFrame(false).catch(() => {}),
188
+ sleep(LOADING_FRAME_CAP_MS),
189
+ ])
190
+ await sleep(12)
191
+ }
192
+ }
193
+
194
+ /**
195
+ * The stage setup runs on, before virtual time is paused. Its frames are begun
196
+ * without screenshots, since nothing before the first held frame is kept.
197
+ */
198
+ function liveStage(
199
+ page: ShellPage,
200
+ beginFrame: (screenshot: boolean) => Promise<unknown>,
201
+ ): Stage {
202
+ const started = Date.now()
203
+ return {
204
+ now: () => Date.now() - started,
205
+ hold: (ms) => pump(beginFrame, ms),
206
+ pointer: (input) => {
207
+ const sent = page.send('Input.dispatchMouseEvent', mouseEvent(input))
208
+ return withinStall(
209
+ beginFrame(false).then(() => sent),
210
+ 0,
211
+ ).then(() => {})
212
+ },
213
+ key: (text) => {
214
+ const sent = page.send('Input.insertText', { text })
215
+ return withinStall(
216
+ beginFrame(false).then(() => sent),
217
+ 0,
218
+ ).then(() => {})
219
+ },
220
+ evaluate: (expression) => page.evaluate(expression),
221
+ }
222
+ }
223
+
224
+ function mouseEvent(input: PointerInput): Record<string, unknown> {
225
+ const at = { x: input.x, y: input.y }
226
+ switch (input.type) {
227
+ case 'move':
228
+ return { type: 'mouseMoved', ...at, button: 'none' }
229
+ case 'down':
230
+ return { type: 'mousePressed', ...at, button: 'left', clickCount: 1 }
231
+ case 'up':
232
+ return { type: 'mouseReleased', ...at, button: 'left', clickCount: 1 }
233
+ case 'wheel':
234
+ return { type: 'mouseWheel', ...at, deltaX: 0, deltaY: input.dy }
235
+ }
236
+ }
237
+
238
+ async function withinStall<T>(work: Promise<T>, frame: number): Promise<T> {
239
+ let timer: ReturnType<typeof setTimeout> | undefined
240
+ const stalled = new Promise<never>((_, fail) => {
241
+ timer = setTimeout(() => fail(new StepperStalled(frame)), STALL_MS)
242
+ })
243
+ try {
244
+ return await Promise.race([work, stalled])
245
+ } finally {
246
+ clearTimeout(timer)
247
+ }
248
+ }
249
+
250
+ function sleep(ms: number): Promise<void> {
251
+ return new Promise((done) => setTimeout(done, ms))
252
+ }
@@ -0,0 +1,45 @@
1
+ # Vendored zoom notice
2
+
3
+ The files in this folder carry the zoom timing of [OpenScreen](https://github.com/siddharthvaddem/openscreen) at commit `ca8104c279da0d4b4481ec992ff103949e22c772`, under the MIT license below.
4
+
5
+ ## What came from where
6
+
7
+ | File | Source in OpenScreen, under `src/components/video-editor/videoPlayback/` |
8
+ | ----------------- | -------------------------------------------------------------------------------------------------------- |
9
+ | `math-utils.ts` | `mathUtils.ts` |
10
+ | `region-utils.ts` | `zoomRegionUtils.ts`, with `TRANSITION_WINDOW_MS` and `ZOOM_IN_TRANSITION_WINDOW_MS` from `constants.ts` |
11
+ | `spring.ts` | `motionSmoothing.ts` and `zoomSpring.ts` |
12
+
13
+ ## What changed
14
+
15
+ - The file names are kebab-case, as this repository names every TypeScript file, and the code is reformatted to its style
16
+ - `math-utils.ts` keeps `clamp01`, `cubicBezier`, and `easeOutScreenStudio`, and drops the easings nothing here calls
17
+ - `region-utils.ts` keeps `computeRegionStrength`, the connected-pan easing, and the window and gap constants. It drops the dominant-region search, its cache, the 3D rotation, and the cursor-follow focus, since the camera here aims at clicks and holds its focus by its own rule.
18
+ - The zoom-out window is 700 ms rather than 1015.05 ms, and the connected pan runs 600 ms rather than 1000 ms, both on the operator's eye in groundwork track `193-ui-record`. The zoom-in window keeps OpenScreen's 1522.575 ms.
19
+ - `spring.ts` keeps the zoom spring's stiffness 320, damping 40, mass 0.92, its rest thresholds, the delta clamp, and the overshoot clamp from `stepAxis`. OpenScreen steps that spring through the `motion` package, and this solves it in closed form instead, so no dependency comes with it.
20
+
21
+ ## License
22
+
23
+ ```plaintext
24
+ MIT License
25
+
26
+ Copyright (c) 2025 Siddharth Vaddem
27
+
28
+ Permission is hereby granted, free of charge, to any person obtaining a copy
29
+ of this software and associated documentation files (the "Software"), to deal
30
+ in the Software without restriction, including without limitation the rights
31
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
32
+ copies of the Software, and to permit persons to whom the Software is
33
+ furnished to do so, subject to the following conditions:
34
+
35
+ The above copyright notice and this permission notice shall be included in all
36
+ copies or substantial portions of the Software.
37
+
38
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
39
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
40
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
41
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
42
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
43
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
44
+ SOFTWARE.
45
+ ```
@@ -0,0 +1,69 @@
1
+ // Vendored from OpenScreen at ca8104c, MIT. See NOTICE.md beside this file.
2
+ // Source: src/components/video-editor/videoPlayback/mathUtils.ts
3
+
4
+ export function clamp01(value: number) {
5
+ return Math.max(0, Math.min(1, value))
6
+ }
7
+
8
+ function sampleCubicBezier(a1: number, a2: number, t: number) {
9
+ const oneMinusT = 1 - t
10
+ return (
11
+ 3 * a1 * oneMinusT * oneMinusT * t + 3 * a2 * oneMinusT * t * t + t * t * t
12
+ )
13
+ }
14
+
15
+ function sampleCubicBezierDerivative(a1: number, a2: number, t: number) {
16
+ const oneMinusT = 1 - t
17
+ return (
18
+ 3 * a1 * oneMinusT * oneMinusT +
19
+ 6 * (a2 - a1) * oneMinusT * t +
20
+ 3 * (1 - a2) * t * t
21
+ )
22
+ }
23
+
24
+ export function cubicBezier(
25
+ x1: number,
26
+ y1: number,
27
+ x2: number,
28
+ y2: number,
29
+ t: number,
30
+ ) {
31
+ const targetX = clamp01(t)
32
+ let solvedT = targetX
33
+
34
+ for (let i = 0; i < 8; i += 1) {
35
+ const currentX = sampleCubicBezier(x1, x2, solvedT) - targetX
36
+ const currentDerivative = sampleCubicBezierDerivative(x1, x2, solvedT)
37
+
38
+ if (Math.abs(currentX) < 1e-6 || Math.abs(currentDerivative) < 1e-6) {
39
+ break
40
+ }
41
+
42
+ solvedT -= currentX / currentDerivative
43
+ }
44
+
45
+ let lower = 0
46
+ let upper = 1
47
+ solvedT = clamp01(solvedT)
48
+
49
+ for (let i = 0; i < 10; i += 1) {
50
+ const currentX = sampleCubicBezier(x1, x2, solvedT)
51
+ if (Math.abs(currentX - targetX) < 1e-6) {
52
+ break
53
+ }
54
+
55
+ if (currentX < targetX) {
56
+ lower = solvedT
57
+ } else {
58
+ upper = solvedT
59
+ }
60
+
61
+ solvedT = (lower + upper) / 2
62
+ }
63
+
64
+ return sampleCubicBezier(y1, y2, solvedT)
65
+ }
66
+
67
+ export function easeOutScreenStudio(t: number) {
68
+ return cubicBezier(0.16, 1, 0.3, 1, t)
69
+ }