@erclx/canon 5.76.0 → 5.78.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 (90) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/auto-ship/references/ui-checklist.md +9 -1
  4. package/claude/skills/ci-workflow/REQUIREMENT.md +8 -2
  5. package/claude/skills/ci-workflow/SKILL.md +3 -0
  6. package/claude/skills/code-craft/REQUIREMENT.md +1 -1
  7. package/claude/skills/code-craft/SKILL.md +7 -0
  8. package/claude/skills/code-craft/references/adopted.md +8 -0
  9. package/claude/skills/pr-evidence/SKILL.md +6 -1
  10. package/claude/skills/pr-evidence/references/evidence.md +1 -0
  11. package/claude/skills/pr-evidence/references/reel.md +9 -1
  12. package/claude/skills/review-craft/references/ui.md +13 -1
  13. package/claude/skills/review-pr/references/body-variants.md +2 -0
  14. package/claude/skills/review-pr/references/close-out.md +2 -0
  15. package/claude/skills/review-pr/references/post.md +1 -0
  16. package/claude/skills/review-ui/REQUIREMENT.md +1 -0
  17. package/claude/skills/review-ui/SKILL.md +2 -2
  18. package/claude/skills/review-ui/references/driving.md +32 -4
  19. package/claude/skills/review-ui/references/post.md +6 -2
  20. package/claude/skills/role-orchestrator/SKILL.md +2 -2
  21. package/claude/skills/role-orchestrator/references/orchestrator-output.md +24 -1
  22. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +3 -2
  23. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +35 -4
  24. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +1 -1
  25. package/claude/skills/role-worker/SKILL.md +1 -0
  26. package/claude/skills/test-craft/SKILL.md +1 -0
  27. package/claude/skills/ui-checklist/REQUIREMENT.md +1 -1
  28. package/claude/skills/ui-checklist/SKILL.md +2 -1
  29. package/claude/skills/ui-craft/references/capture.md +16 -0
  30. package/claude/skills/ui-film/REQUIREMENT.md +2 -2
  31. package/claude/skills/ui-film/SKILL.md +6 -1
  32. package/claude/skills/ui-film/references/compose-craft.md +68 -11
  33. package/claude/skills/ui-film/references/config.md +2 -2
  34. package/claude/skills/ui-film/references/handoff.md +1 -1
  35. package/claude/skills/ui-film/references/harness.md +71 -0
  36. package/claude/skills/ui-film/references/take-craft.md +42 -17
  37. package/docs/agents/targets.md +3 -1
  38. package/docs/workflow/operating-model.md +6 -5
  39. package/docs/workflow/visual-design-workflow.md +2 -1
  40. package/governance/ledger/skills/code-craft/SKILL.md +9 -0
  41. package/governance/ledger/skills/test-craft/SKILL.md +1 -0
  42. package/governance/ledger/skills/ui-craft/capture.md +18 -5
  43. package/package.json +5 -4
  44. package/src/commands/pr.ts +117 -31
  45. package/src/commands/take.ts +12 -1
  46. package/src/commands/targets.ts +11 -1
  47. package/src/design/components.ts +5 -107
  48. package/src/design/favicon.ts +21 -17
  49. package/src/design/render.ts +9 -27
  50. package/src/design/template.ts +42 -0
  51. package/src/design/templates/favicon.svg +17 -0
  52. package/src/design/templates/hand-drawn-figure.css +54 -0
  53. package/src/design/templates/preview.html +27 -0
  54. package/src/design/templates/scrollbar.css +29 -0
  55. package/src/design/templates/status.css +24 -0
  56. package/src/gate/embedded.ts +400 -0
  57. package/src/gate/stages.ts +8 -0
  58. package/src/pr/evidence.ts +43 -4
  59. package/src/pr/frames.ts +1 -6
  60. package/src/pr/lift.ts +77 -9
  61. package/src/pr/rest.ts +2 -0
  62. package/src/pr/review-scope.ts +28 -0
  63. package/src/pr/review-state.ts +5 -5
  64. package/src/pr/waiver.ts +45 -0
  65. package/src/take/camera.ts +337 -208
  66. package/src/take/film.ts +7 -1
  67. package/src/take/log.ts +43 -2
  68. package/src/take/perform.ts +85 -13
  69. package/src/take/plan.ts +26 -5
  70. package/src/take/reel.ts +30 -5
  71. package/src/take/screencast.ts +110 -3
  72. package/src/take/stepper.ts +24 -4
  73. package/src/targets/registry.ts +99 -14
  74. package/src/targets/resolve.ts +3 -1
  75. package/tooling/astro/configs/card.config.mjs +4 -0
  76. package/tooling/astro/configs/scripts/check-card-exclusion.sh +7 -2
  77. package/tooling/astro/reference.md +2 -1
  78. package/tooling/web/configs/.github/workflows/readme-screenshot.yml +3 -1
  79. package/tooling/web/configs/.github/workflows/verify.yml +5 -2
  80. package/tooling/web/configs/scripts/install-playwright-deps.sh +52 -0
  81. package/tooling/web/reference.md +5 -0
  82. package/src/commands/fixtures/gh.sh +0 -64
  83. package/src/take/fixtures/busy.html +0 -107
  84. package/src/take/fixtures/fade.html +0 -49
  85. package/src/take/fixtures/fetch.html +0 -38
  86. package/src/take/fixtures/input.html +0 -138
  87. package/src/take/zoom/NOTICE.md +0 -45
  88. package/src/take/zoom/math-utils.ts +0 -69
  89. package/src/take/zoom/region-utils.ts +0 -55
  90. package/src/take/zoom/spring.ts +0 -133
package/src/take/log.ts CHANGED
@@ -31,6 +31,11 @@ export interface Box {
31
31
  readonly height: number
32
32
  }
33
33
 
34
+ /** A position the page was sent, at the clip time it was sent. */
35
+ export interface SentPoint extends Point {
36
+ readonly t: number
37
+ }
38
+
34
39
  export type LogEvent =
35
40
  | {
36
41
  readonly t: number
@@ -38,6 +43,15 @@ export type LogEvent =
38
43
  readonly from: Point
39
44
  readonly to: Point
40
45
  readonly ms: number
46
+ /**
47
+ * Every position the glide sent, in order. A reader draws the cursor off
48
+ * these rather than off the eased curve, since a page slower than a frame
49
+ * skips points the curve would still draw. Absent in a log written
50
+ * before the field and in a harness log, where the curve stands in.
51
+ */
52
+ readonly points?: readonly SentPoint[]
53
+ /** How long the camera holds aimed at `to` once the glide ends. */
54
+ readonly hold?: number
41
55
  }
42
56
  | {
43
57
  readonly t: number
@@ -103,6 +117,12 @@ export interface TakeLog {
103
117
  readonly clip?: { readonly width: number; readonly height: number }
104
118
  /** False when the beat turned the camera's zoom off. */
105
119
  readonly push?: false
120
+ /**
121
+ * Milliseconds a screencast take shifted every event by, being how long a
122
+ * move took to show in the clip. Absent on the stepped route, which needs
123
+ * no shift, and where the page gave no frame to calibrate on.
124
+ */
125
+ readonly lag?: number
106
126
  readonly frames: number
107
127
  readonly events: readonly LogEvent[]
108
128
  }
@@ -190,13 +210,34 @@ export function along(from: Point, to: Point, progress: number): Point {
190
210
  return { x: from.x + (to.x - from.x) * k, y: from.y + (to.y - from.y) * k }
191
211
  }
192
212
 
193
- /** The pointer's position at a time on the clip's clock, read off the moves. */
213
+ /**
214
+ * The pointer's position at a time on the clip's clock, read off the points
215
+ * each move sent, or off its eased curve when it carries none.
216
+ */
194
217
  export function cursorAt(events: readonly LogEvent[], ms: number): Point {
195
218
  let position = START
196
219
  for (const event of events) {
197
220
  if (event.kind !== 'move') continue
198
221
  if (ms < event.t) break
199
- position = along(event.from, event.to, (ms - event.t) / event.ms)
222
+ position = event.points?.length
223
+ ? sentAt(event.from, event.points, ms)
224
+ : along(event.from, event.to, (ms - event.t) / event.ms)
200
225
  }
201
226
  return position
202
227
  }
228
+
229
+ function sentAt(from: Point, points: readonly SentPoint[], ms: number): Point {
230
+ let before: SentPoint | undefined
231
+ for (const point of points) {
232
+ if (point.t > ms) {
233
+ if (!before) return from
234
+ const k = (ms - before.t) / (point.t - before.t)
235
+ return {
236
+ x: before.x + (point.x - before.x) * k,
237
+ y: before.y + (point.y - before.y) * k,
238
+ }
239
+ }
240
+ before = point
241
+ }
242
+ return before ? { x: before.x, y: before.y } : from
243
+ }
@@ -3,6 +3,7 @@ import {
3
3
  FPS,
4
4
  type LogEvent,
5
5
  type Point,
6
+ type SentPoint,
6
7
  VIEWPORT,
7
8
  along,
8
9
  travelMs,
@@ -28,6 +29,16 @@ const SCROLL_MS = 900
28
29
  /** How long a drag holds still after its press, so the viewer sees the grab land. */
29
30
  const GRAB_MS = 150
30
31
 
32
+ /**
33
+ * The pacing the operator settled by eye in groundwork track
34
+ * `210-film-craft-and-soundtrack`: a rest on the target before a press, a hold
35
+ * on its result before the pointer leaves, and a pause between clicking a
36
+ * field and its first key, so the camera lands before anything changes.
37
+ */
38
+ const REST_MS = 300
39
+ const RESULT_MS = 500
40
+ const TYPE_MS = 400
41
+
31
42
  export type PointerInput =
32
43
  | {
33
44
  readonly type: 'move'
@@ -73,6 +84,12 @@ export interface Stage {
73
84
  press(input: KeyInput): Promise<void>
74
85
  /** Evaluates an expression in the page. */
75
86
  evaluate<T>(expression: string): Promise<T>
87
+ /**
88
+ * Waits for the page's next frame before a glide stamps a point, on a route
89
+ * whose clock runs apart from the page's frames. A stage that steps those
90
+ * frames itself leaves it out.
91
+ */
92
+ align?(): Promise<void>
76
93
  }
77
94
 
78
95
  /** A target the pointer cannot reach, which refuses rather than films a guess. */
@@ -125,13 +142,22 @@ export async function performSteps(
125
142
  ): Promise<void> {
126
143
  for (const step of steps) {
127
144
  switch (step.kind) {
128
- case 'move':
129
- await moveTo(
130
- stage,
131
- state,
132
- 'target' in step ? center(await boxOf(stage, step.target)) : step,
133
- )
145
+ case 'move': {
146
+ const to =
147
+ 'target' in step
148
+ ? center(await boxOf(stage, step.target))
149
+ : { x: step.x, y: step.y }
150
+ if (step.hold === undefined) {
151
+ await moveTo(stage, state, to)
152
+ break
153
+ }
154
+ const glide =
155
+ (await moveTo(stage, state, to, { unlogged: true })) ??
156
+ stillAt(stage, state.pointer)
157
+ await stage.hold(step.hold)
158
+ state.log?.push({ ...glide, hold: step.hold })
134
159
  break
160
+ }
135
161
  case 'click':
136
162
  case 'dblclick':
137
163
  await click(
@@ -141,6 +167,7 @@ export async function performSteps(
141
167
  step.modifiers ?? [],
142
168
  step.kind === 'dblclick' ? 2 : 1,
143
169
  )
170
+ await pause(stage, state, RESULT_MS)
144
171
  break
145
172
  case 'drag':
146
173
  await drag(
@@ -167,6 +194,7 @@ export async function performSteps(
167
194
  case 'fill': {
168
195
  const box = await boxOf(stage, step.target)
169
196
  await click(stage, state, box, [], 1)
197
+ await pause(stage, state, TYPE_MS)
170
198
  const start = stage.now()
171
199
  for (const character of step.text) {
172
200
  await stage.key(character)
@@ -180,6 +208,7 @@ export async function performSteps(
180
208
  chars: step.text.length,
181
209
  ms: stage.now() - start,
182
210
  })
211
+ await pause(stage, state, RESULT_MS)
183
212
  break
184
213
  }
185
214
  case 'scroll': {
@@ -214,25 +243,53 @@ export async function performSteps(
214
243
  }
215
244
  }
216
245
 
246
+ type Glide = Extract<LogEvent, { kind: 'move' }>
247
+
248
+ /**
249
+ * Glides the pointer to `to` and returns the glide, or undefined when the
250
+ * pointer is already there. The glide is logged unless `unlogged`, which a
251
+ * caller sets to log it with more fields itself.
252
+ */
217
253
  async function moveTo(
218
254
  stage: Stage,
219
255
  state: Performance,
220
256
  to: Point,
221
- held?: { readonly modifiers: readonly Modifier[] },
222
- ): Promise<void> {
257
+ options: {
258
+ readonly held?: { readonly modifiers: readonly Modifier[] }
259
+ readonly unlogged?: true
260
+ } = {},
261
+ ): Promise<Glide | undefined> {
223
262
  const from = state.pointer
224
- if (Math.hypot(to.x - from.x, to.y - from.y) < 1) return
263
+ if (Math.hypot(to.x - from.x, to.y - from.y) < 1) return undefined
225
264
  const frames = Math.max(1, Math.round(travelMs(from, to) / FRAME_MS))
226
- const pressed = held
227
- ? { held: true as const, ...withModifiers(held.modifiers) }
265
+ const pressed = options.held
266
+ ? { held: true as const, ...withModifiers(options.held.modifiers) }
228
267
  : {}
229
268
  const start = stage.now()
269
+ const points: SentPoint[] = []
230
270
  await paced(stage, frames, async (frame) => {
231
271
  state.pointer = along(from, to, frame / frames)
272
+ await stage.align?.()
273
+ points.push({ t: stage.now(), ...state.pointer })
232
274
  await stage.pointer({ type: 'move', ...state.pointer, ...pressed })
233
275
  })
234
276
  state.pointer = to
235
- state.log?.push({ t: start, kind: 'move', from, to, ms: stage.now() - start })
277
+ const glide: Glide = {
278
+ t: start,
279
+ kind: 'move',
280
+ from,
281
+ to,
282
+ ms: stage.now() - start,
283
+ points,
284
+ }
285
+ if (!options.unlogged) state.log?.push(glide)
286
+ return glide
287
+ }
288
+
289
+ /** A glide of no length, for a hold aimed where the pointer already rests. */
290
+ function stillAt(stage: Stage, at: Point): Glide {
291
+ const t = stage.now()
292
+ return { t, kind: 'move', from: at, to: at, ms: 0, points: [{ t, ...at }] }
236
293
  }
237
294
 
238
295
  /**
@@ -263,6 +320,7 @@ async function click(
263
320
  ): Promise<void> {
264
321
  const at = center(box)
265
322
  await moveTo(stage, state, at)
323
+ await pause(stage, state, REST_MS)
266
324
  await holding(stage, modifiers, async () => {
267
325
  state.log?.push({
268
326
  t: stage.now(),
@@ -280,6 +338,18 @@ async function click(
280
338
  })
281
339
  }
282
340
 
341
+ /**
342
+ * Lets `ms` of clip time pass while a beat is filmed, and none in setup, which
343
+ * no viewer sees.
344
+ */
345
+ async function pause(
346
+ stage: Stage,
347
+ state: Performance,
348
+ ms: number,
349
+ ): Promise<void> {
350
+ if (state.log) await stage.hold(ms)
351
+ }
352
+
283
353
  /**
284
354
  * Presses on the source, holds a moment so the page reads a grab rather than
285
355
  * a click, glides held to the target, and releases there. The drop target is
@@ -296,12 +366,13 @@ async function drag(
296
366
  const from = center(box)
297
367
  const to = center(drop)
298
368
  await moveTo(stage, state, from)
369
+ await pause(stage, state, REST_MS)
299
370
  await holding(stage, modifiers, async () => {
300
371
  const start = stage.now()
301
372
  const press = { clickCount: 1, ...withModifiers(modifiers) }
302
373
  await stage.pointer({ type: 'down', ...from, ...press })
303
374
  await stage.hold(GRAB_MS)
304
- await moveTo(stage, state, to, { modifiers })
375
+ await moveTo(stage, state, to, { held: { modifiers } })
305
376
  await stage.pointer({ type: 'up', ...to, ...press })
306
377
  state.log?.push({
307
378
  t: start,
@@ -314,6 +385,7 @@ async function drag(
314
385
  ms: stage.now() - start,
315
386
  })
316
387
  })
388
+ await pause(stage, state, RESULT_MS)
317
389
  }
318
390
 
319
391
  /**
package/src/take/plan.ts CHANGED
@@ -18,9 +18,17 @@ interface Held {
18
18
  readonly modifiers?: readonly Modifier[]
19
19
  }
20
20
 
21
+ /**
22
+ * A move's milliseconds at its end with the camera aimed there, absent when the
23
+ * move aims the camera nowhere.
24
+ */
25
+ interface Aimed {
26
+ readonly hold?: number
27
+ }
28
+
21
29
  export type TakeStep =
22
- | { readonly kind: 'move'; readonly target: string }
23
- | { readonly kind: 'move'; readonly x: number; readonly y: number }
30
+ | ({ readonly kind: 'move'; readonly target: string } & Aimed)
31
+ | ({ readonly kind: 'move'; readonly x: number; readonly y: number } & Aimed)
24
32
  | ({ readonly kind: 'click'; readonly target: string } & Held)
25
33
  | ({ readonly kind: 'dblclick'; readonly target: string } & Held)
26
34
  | ({
@@ -196,15 +204,17 @@ function readStep(entry: unknown, at: string): TakeStep {
196
204
  }
197
205
 
198
206
  switch (entry.kind) {
199
- case 'move':
200
- if (target) return { kind: 'move', target }
207
+ case 'move': {
208
+ const aimed = readHold(entry, at)
209
+ if (target) return { kind: 'move', target, ...aimed }
201
210
  if (isNumber(entry.x) && isNumber(entry.y)) {
202
- return { kind: 'move', x: entry.x, y: entry.y }
211
+ return { kind: 'move', x: entry.x, y: entry.y, ...aimed }
203
212
  }
204
213
  throw new Refusal(
205
214
  'bad-step',
206
215
  `${at} is a move with neither a target nor an x and y`,
207
216
  )
217
+ }
208
218
  case 'click':
209
219
  case 'dblclick':
210
220
  return {
@@ -258,6 +268,17 @@ function readStep(entry: unknown, at: string): TakeStep {
258
268
  }
259
269
  }
260
270
 
271
+ function readHold(entry: Record<string, unknown>, at: string): Aimed {
272
+ if (!('hold' in entry)) return {}
273
+ if (!isNumber(entry.hold) || entry.hold < 0) {
274
+ throw new Refusal(
275
+ 'bad-step',
276
+ `${at} is a move whose hold is not a non-negative number of ms`,
277
+ )
278
+ }
279
+ return { hold: entry.hold }
280
+ }
281
+
261
282
  function readModifiers(value: unknown, at: string): Held {
262
283
  if (value === undefined) return {}
263
284
  if (!Array.isArray(value)) {
package/src/take/reel.ts CHANGED
@@ -22,8 +22,15 @@ export const REEL_EVERY = 3
22
22
 
23
23
  export const REEL_FPS = FPS / REEL_EVERY
24
24
 
25
- /** WebP quality, measured at about 1.4 MB for a 9 s reel at this width. */
26
- const QUALITY = 60
25
+ /** The most GitHub renders inline as an image in a comment. */
26
+ export const REEL_MAX_BYTES = 10_000_000
27
+
28
+ /**
29
+ * Quality of the lossy fallback, measured at about 1.4 MB for a 9 s reel at
30
+ * this width. It skips frames whose change is faint, so a fade-out can leave a
31
+ * ghost, and the fallback exists only to keep a long reel under the cap.
32
+ */
33
+ const LOSSY_QUALITY = 60
27
34
 
28
35
  export interface ReelFrame extends Frame {
29
36
  /** Which beat of the take, in plan order. */
@@ -37,6 +44,8 @@ export interface Reel {
37
44
  readonly frames: number
38
45
  readonly seconds: number
39
46
  readonly bytes: number
47
+ /** False when the reel passed the cap lossless and was encoded again lossy. */
48
+ readonly lossless?: boolean
40
49
  }
41
50
 
42
51
  /**
@@ -75,11 +84,28 @@ export async function* reelFrames(
75
84
  }
76
85
  }
77
86
 
78
- /** Renders the reel of a filmed take to `file`, an animated WebP. */
87
+ /**
88
+ * Renders the reel of a filmed take to `file`, an animated WebP. It encodes
89
+ * lossless, since the lossy encoder skips a frame whose change is faint and
90
+ * never repaints the last copy of an element that faded out. A reel past
91
+ * `maxBytes` is encoded again lossy so a long reel still posts.
92
+ */
79
93
  export async function renderReel(
80
94
  out: string,
81
95
  beats: readonly FilmedBeat[],
82
96
  file: string,
97
+ maxBytes: number = REEL_MAX_BYTES,
98
+ ): Promise<Reel> {
99
+ const lossless = await encode(out, beats, file, true)
100
+ if (lossless.bytes <= maxBytes) return { ...lossless, lossless: true }
101
+ return { ...(await encode(out, beats, file, false)), lossless: false }
102
+ }
103
+
104
+ async function encode(
105
+ out: string,
106
+ beats: readonly FilmedBeat[],
107
+ file: string,
108
+ lossless: boolean,
83
109
  ): Promise<Reel> {
84
110
  const first = beats[0]
85
111
  if (!first) throw new Error('a reel needs at least one filmed beat')
@@ -105,8 +131,7 @@ export async function renderReel(
105
131
  '-',
106
132
  '-c:v',
107
133
  'libwebp_anim',
108
- '-quality',
109
- String(QUALITY),
134
+ ...(lossless ? ['-lossless', '1'] : ['-quality', String(LOSSY_QUALITY)]),
110
135
  '-loop',
111
136
  '0',
112
137
  '-f',
@@ -11,7 +11,7 @@ import {
11
11
  performSteps,
12
12
  } from '@/take/perform'
13
13
  import type { Beat } from '@/take/plan'
14
- import { HOLD_MS } from '@/take/stepper'
14
+ import { HOLD_MS, withinCap } from '@/take/stepper'
15
15
 
16
16
  /**
17
17
  * Films a beat in real time through Playwright's screencast, for a page frame
@@ -27,9 +27,21 @@ const SETTLE_MS = 2500
27
27
  /** How long a refusal waits on the screenshot it shows. */
28
28
  const STILL_CAP_MS = 2000
29
29
 
30
+ /**
31
+ * How long a move waits on the page's next animation frame before it goes
32
+ * anyway, since a page whose frames stall is the stepper's case and this route
33
+ * must still film.
34
+ */
35
+ const ALIGN_CAP_MS = 100
36
+
37
+ /** How long the calibration waits on the page to paint its move. */
38
+ const CALIBRATE_CAP_MS = 500
39
+
30
40
  export interface ScreencastTake {
31
41
  readonly frames: number
32
42
  readonly events: readonly LogEvent[]
43
+ /** What `events` were shifted by, absent when the page gave no frame to read. */
44
+ readonly lag?: number
33
45
  /** The clip's closing frame as a JPEG. */
34
46
  readonly still: Buffer
35
47
  }
@@ -56,7 +68,11 @@ export async function filmScreencast(request: {
56
68
  const stageOn = (clock: () => number): Stage => ({
57
69
  now: clock,
58
70
  hold: (ms) => page.waitForTimeout(ms),
71
+ align: () => nextFrame(page, ALIGN_CAP_MS).then(() => undefined),
59
72
  pointer: async (input) => {
73
+ // A move waited on its frame already, so padding it would land the
74
+ // next one a frame late.
75
+ if (input.type === 'move') return send(page.mouse, input)
60
76
  const sent = clock()
61
77
  await send(page.mouse, input)
62
78
  const spent = clock() - sent
@@ -79,7 +95,7 @@ export async function filmScreencast(request: {
79
95
  const live = stageOn(() => performance.now())
80
96
  await performSteps(live, request.beat.setup, { pointer: START })
81
97
  if (request.beat.setup.length > 0) await live.hold(1000)
82
- await page.mouse.move(START.x, START.y)
98
+ const lag = await calibrate(page)
83
99
 
84
100
  const webm = join(scratch, 'take.webm')
85
101
  const events: LogEvent[] = []
@@ -97,7 +113,14 @@ export async function filmScreencast(request: {
97
113
 
98
114
  await transcode(webm, request.clip, FPS, filmedMs)
99
115
  const frames = await probeFrames(request.clip)
100
- return { frames, events, still: await lastFrame(request.clip, frames) }
116
+ const closing = await lastFrame(request.clip, frames)
117
+ if (lag === undefined) return { frames, events, still: closing }
118
+ return {
119
+ frames,
120
+ events: events.map((event) => shifted(event, lag)),
121
+ lag: Number(lag.toFixed(1)),
122
+ still: closing,
123
+ }
101
124
  } catch (error) {
102
125
  if (error instanceof TargetRefused && still) error.still = await still()
103
126
  throw error
@@ -107,8 +130,92 @@ export async function filmScreencast(request: {
107
130
  }
108
131
  }
109
132
 
133
+ type Page = import('playwright-core').Page
110
134
  type Mouse = import('playwright-core').Mouse
111
135
 
136
+ /**
137
+ * The page time of its next animation frame, or undefined when none comes
138
+ * within `capMs`, as on a hidden page, or the page closed first.
139
+ */
140
+ function nextFrame(page: Page, capMs: number): Promise<number | undefined> {
141
+ return withinCap(
142
+ page
143
+ .evaluate(
144
+ () => new Promise<number>((done) => requestAnimationFrame(done)),
145
+ )
146
+ .catch(() => undefined),
147
+ capMs,
148
+ )
149
+ }
150
+
151
+ /** Where the calibration's armed listener hands back the frame it painted in. */
152
+ const PAINTED = '__canonTakePainted'
153
+
154
+ /**
155
+ * How long a move takes to show in the clip, measured by sending one aligned
156
+ * move to `START` and reading the animation frame after its handler against
157
+ * the frame it was aligned to. Both readings are page time, so no clock mapping
158
+ * is needed. The recorder captures a frame once it is presented, one page
159
+ * frame after that frame begins, so the lag counts one page frame past the
160
+ * readings, read off two consecutive animation frames rather than assumed from
161
+ * the clip's rate. That held a dragged box within 7 px on every run where the
162
+ * readings alone left 10 to 15. Undefined when the page gives no frame to read,
163
+ * and the pointer rests on `START` either way.
164
+ */
165
+ async function calibrate(page: Page): Promise<number | undefined> {
166
+ await page.mouse.move(START.x - 1, START.y)
167
+ const interval = await withinCap(
168
+ page
169
+ .evaluate(
170
+ () =>
171
+ new Promise<number>((done) =>
172
+ requestAnimationFrame((first) =>
173
+ requestAnimationFrame((second) => done(second - first)),
174
+ ),
175
+ ),
176
+ )
177
+ .catch(() => undefined),
178
+ 2 * ALIGN_CAP_MS,
179
+ )
180
+ await page.evaluate((key) => {
181
+ const slot = window as unknown as Record<string, unknown>
182
+ slot[key] = new Promise<number>((done) => {
183
+ addEventListener('pointermove', () => requestAnimationFrame(done), {
184
+ once: true,
185
+ capture: true,
186
+ })
187
+ })
188
+ }, PAINTED)
189
+ const aligned = await nextFrame(page, ALIGN_CAP_MS)
190
+ await page.mouse.move(START.x, START.y)
191
+ const painted = await withinCap(
192
+ page
193
+ .evaluate((key) => {
194
+ const slot = window as unknown as Record<string, unknown>
195
+ const armed = slot[key] as Promise<number>
196
+ delete slot[key]
197
+ return armed
198
+ }, PAINTED)
199
+ .catch(() => undefined),
200
+ CALIBRATE_CAP_MS,
201
+ )
202
+ if (interval === undefined || aligned === undefined) return undefined
203
+ if (painted === undefined) return undefined
204
+ return Math.max(0, painted - aligned) + interval
205
+ }
206
+
207
+ /** An event moved `lag` later on the clip, with every point it sent. */
208
+ function shifted(event: LogEvent, lag: number): LogEvent {
209
+ if (event.kind === 'move' && event.points) {
210
+ return {
211
+ ...event,
212
+ t: event.t + lag,
213
+ points: event.points.map((point) => ({ ...point, t: point.t + lag })),
214
+ }
215
+ }
216
+ return { ...event, t: event.t + lag }
217
+ }
218
+
112
219
  async function send(mouse: Mouse, input: PointerInput): Promise<void> {
113
220
  switch (input.type) {
114
221
  case 'move':
@@ -187,10 +187,10 @@ export async function filmStepped(
187
187
  async function frameStill(
188
188
  beginFrame: (screenshot: boolean) => Promise<{ screenshotData?: string }>,
189
189
  ): Promise<Buffer | undefined> {
190
- const reply = await Promise.race([
190
+ const reply = await withinCap(
191
191
  beginFrame(true).catch(() => undefined),
192
- sleep(STILL_CAP_MS),
193
- ])
192
+ STILL_CAP_MS,
193
+ )
194
194
  return reply?.screenshotData
195
195
  ? Buffer.from(reply.screenshotData, 'base64')
196
196
  : undefined
@@ -214,7 +214,7 @@ async function load(page: ShellPage, url: string): Promise<number> {
214
214
  if (navigated.errorText) {
215
215
  throw new Error(`${navigated.errorText} at ${url}`)
216
216
  }
217
- const timestamp = await Promise.race([loaded, sleep(LOAD_CAP_MS)])
217
+ const timestamp = await withinCap(loaded, LOAD_CAP_MS)
218
218
  if (typeof timestamp !== 'number') {
219
219
  throw new Error(
220
220
  `${url} fired no load event within ${LOAD_CAP_MS / 1000} s, so no frame clock could be read`,
@@ -381,6 +381,26 @@ async function withinStall<T>(
381
381
  }
382
382
  }
383
383
 
384
+ /**
385
+ * Races `work` against a cap and resolves empty when the cap wins. The timer
386
+ * is cleared once the race settles, since a pending one keeps the process alive
387
+ * for the whole cap after filming ends.
388
+ */
389
+ export async function withinCap<T>(
390
+ work: Promise<T>,
391
+ ms: number,
392
+ ): Promise<T | undefined> {
393
+ let timer: ReturnType<typeof setTimeout> | undefined
394
+ const capped = new Promise<undefined>((done) => {
395
+ timer = setTimeout(() => done(undefined), ms)
396
+ })
397
+ try {
398
+ return await Promise.race([work, capped])
399
+ } finally {
400
+ clearTimeout(timer)
401
+ }
402
+ }
403
+
384
404
  function sleep(ms: number): Promise<void> {
385
405
  return new Promise((done) => setTimeout(done, ms))
386
406
  }