@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.
- package/CHANGELOG.md +33 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/auto-ship/references/ui-checklist.md +9 -1
- package/claude/skills/ci-workflow/REQUIREMENT.md +8 -2
- package/claude/skills/ci-workflow/SKILL.md +3 -0
- package/claude/skills/code-craft/REQUIREMENT.md +1 -1
- package/claude/skills/code-craft/SKILL.md +7 -0
- package/claude/skills/code-craft/references/adopted.md +8 -0
- package/claude/skills/pr-evidence/SKILL.md +6 -1
- package/claude/skills/pr-evidence/references/evidence.md +1 -0
- package/claude/skills/pr-evidence/references/reel.md +9 -1
- package/claude/skills/review-craft/references/ui.md +13 -1
- package/claude/skills/review-pr/references/body-variants.md +2 -0
- package/claude/skills/review-pr/references/close-out.md +2 -0
- package/claude/skills/review-pr/references/post.md +1 -0
- package/claude/skills/review-ui/REQUIREMENT.md +1 -0
- package/claude/skills/review-ui/SKILL.md +2 -2
- package/claude/skills/review-ui/references/driving.md +32 -4
- package/claude/skills/review-ui/references/post.md +6 -2
- package/claude/skills/role-orchestrator/SKILL.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-output.md +24 -1
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +3 -2
- package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +35 -4
- package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +1 -1
- package/claude/skills/role-worker/SKILL.md +1 -0
- package/claude/skills/test-craft/SKILL.md +1 -0
- package/claude/skills/ui-checklist/REQUIREMENT.md +1 -1
- package/claude/skills/ui-checklist/SKILL.md +2 -1
- package/claude/skills/ui-craft/references/capture.md +16 -0
- package/claude/skills/ui-film/REQUIREMENT.md +2 -2
- package/claude/skills/ui-film/SKILL.md +6 -1
- package/claude/skills/ui-film/references/compose-craft.md +68 -11
- package/claude/skills/ui-film/references/config.md +2 -2
- package/claude/skills/ui-film/references/handoff.md +1 -1
- package/claude/skills/ui-film/references/harness.md +71 -0
- package/claude/skills/ui-film/references/take-craft.md +42 -17
- package/docs/agents/targets.md +3 -1
- package/docs/workflow/operating-model.md +6 -5
- package/docs/workflow/visual-design-workflow.md +2 -1
- package/governance/ledger/skills/code-craft/SKILL.md +9 -0
- package/governance/ledger/skills/test-craft/SKILL.md +1 -0
- package/governance/ledger/skills/ui-craft/capture.md +18 -5
- package/package.json +5 -4
- package/src/commands/pr.ts +117 -31
- package/src/commands/take.ts +12 -1
- package/src/commands/targets.ts +11 -1
- package/src/design/components.ts +5 -107
- package/src/design/favicon.ts +21 -17
- package/src/design/render.ts +9 -27
- package/src/design/template.ts +42 -0
- package/src/design/templates/favicon.svg +17 -0
- package/src/design/templates/hand-drawn-figure.css +54 -0
- package/src/design/templates/preview.html +27 -0
- package/src/design/templates/scrollbar.css +29 -0
- package/src/design/templates/status.css +24 -0
- package/src/gate/embedded.ts +400 -0
- package/src/gate/stages.ts +8 -0
- package/src/pr/evidence.ts +43 -4
- package/src/pr/frames.ts +1 -6
- package/src/pr/lift.ts +77 -9
- package/src/pr/rest.ts +2 -0
- package/src/pr/review-scope.ts +28 -0
- package/src/pr/review-state.ts +5 -5
- package/src/pr/waiver.ts +45 -0
- package/src/take/camera.ts +337 -208
- package/src/take/film.ts +7 -1
- package/src/take/log.ts +43 -2
- package/src/take/perform.ts +85 -13
- package/src/take/plan.ts +26 -5
- package/src/take/reel.ts +30 -5
- package/src/take/screencast.ts +110 -3
- package/src/take/stepper.ts +24 -4
- package/src/targets/registry.ts +99 -14
- package/src/targets/resolve.ts +3 -1
- package/tooling/astro/configs/card.config.mjs +4 -0
- package/tooling/astro/configs/scripts/check-card-exclusion.sh +7 -2
- package/tooling/astro/reference.md +2 -1
- package/tooling/web/configs/.github/workflows/readme-screenshot.yml +3 -1
- package/tooling/web/configs/.github/workflows/verify.yml +5 -2
- package/tooling/web/configs/scripts/install-playwright-deps.sh +52 -0
- package/tooling/web/reference.md +5 -0
- package/src/commands/fixtures/gh.sh +0 -64
- package/src/take/fixtures/busy.html +0 -107
- package/src/take/fixtures/fade.html +0 -49
- package/src/take/fixtures/fetch.html +0 -38
- package/src/take/fixtures/input.html +0 -138
- package/src/take/zoom/NOTICE.md +0 -45
- package/src/take/zoom/math-utils.ts +0 -69
- package/src/take/zoom/region-utils.ts +0 -55
- 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
|
-
/**
|
|
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 =
|
|
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
|
+
}
|
package/src/take/perform.ts
CHANGED
|
@@ -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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
26
|
-
const
|
|
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
|
-
/**
|
|
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',
|
package/src/take/screencast.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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':
|
package/src/take/stepper.ts
CHANGED
|
@@ -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
|
|
190
|
+
const reply = await withinCap(
|
|
191
191
|
beginFrame(true).catch(() => undefined),
|
|
192
|
-
|
|
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
|
|
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
|
}
|