@erclx/canon 5.69.0 → 5.70.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 +17 -0
- package/README.md +33 -159
- package/assets/brand/mark.svg +17 -9
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/app-drive/SKILL.md +3 -2
- package/claude/skills/app-drive/references/drive-record.md +52 -10
- package/claude/skills/app-drive/references/isolate.md +1 -1
- package/claude/skills/design-pick/SKILL.md +1 -0
- package/claude/skills/ui-craft/references/copy.md +4 -1
- package/claude/skills/ui-film/REQUIREMENT.md +4 -2
- package/claude/skills/ui-film/SKILL.md +24 -11
- package/claude/skills/ui-film/references/compose-craft.md +81 -0
- package/claude/skills/ui-film/references/config.md +6 -1
- package/claude/skills/ui-film/references/handoff.md +13 -3
- package/claude/skills/ui-film/references/take-craft.md +27 -1
- package/docs/index.md +1 -0
- package/docs/migrations/by-hand.md +107 -0
- package/docs/migrations/by-verb.md +118 -0
- package/docs/migrations/index.md +14 -0
- package/docs/target/index.md +1 -2
- package/docs/target/projects.md +2 -2
- package/docs/target/sync.md +22 -2
- package/docs/workflow/ai-workflow.md +1 -1
- package/governance/ledger/skills/ui-craft/copy.md +8 -4
- package/package.json +3 -1
- package/scripts/core/design-sync.ts +204 -0
- package/scripts/core/regen-web-favicon.ts +27 -12
- package/scripts/core/regen-web-previews.ts +8 -1
- package/scripts/core/render-film.ts +179 -0
- package/src/capture/frame-values.ts +12 -12
- package/src/commands/records.ts +3 -2
- package/src/commands/sandbox.ts +9 -2
- package/src/commands/take.ts +159 -11
- package/src/design/favicon.ts +85 -13
- package/src/design/reference.ts +98 -0
- package/src/design/render.ts +16 -14
- package/src/design/tokens.ts +4 -3
- package/src/gate/stages.ts +6 -0
- package/src/init/steps.ts +2 -2
- package/src/migrate/status.ts +3 -3
- package/src/records/validate.ts +14 -2
- package/src/sandbox/expect.ts +32 -0
- package/src/sandbox/headless/run.ts +6 -3
- package/src/take/camera.ts +99 -10
- package/src/take/film.ts +35 -2
- package/src/take/fixtures/input.html +138 -0
- package/src/take/keys.ts +193 -0
- package/src/take/log.ts +94 -1
- package/src/take/perform.ts +165 -10
- package/src/take/plan.ts +101 -6
- package/src/take/screencast.ts +28 -3
- package/src/take/stepper.ts +122 -18
- package/docs/target/migrations.md +0 -205
package/src/take/log.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Modifier } from '@/take/keys'
|
|
2
|
+
import { ROUTES, type Route } from '@/take/plan'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* The click log a take writes beside its clip, and the pointer path both
|
|
@@ -43,6 +44,33 @@ export type LogEvent =
|
|
|
43
44
|
readonly kind: 'click'
|
|
44
45
|
readonly at: Point
|
|
45
46
|
readonly box: Box
|
|
47
|
+
/** The keys held through the press, absent when none is. */
|
|
48
|
+
readonly modifiers?: readonly Modifier[]
|
|
49
|
+
/** 2 for a double click, absent for a single one. */
|
|
50
|
+
readonly count?: number
|
|
51
|
+
}
|
|
52
|
+
| {
|
|
53
|
+
readonly t: number
|
|
54
|
+
readonly kind: 'drag'
|
|
55
|
+
/** Where the button went down and where it came up. */
|
|
56
|
+
readonly from: Point
|
|
57
|
+
readonly to: Point
|
|
58
|
+
/** The box pressed and the box it was dropped on. */
|
|
59
|
+
readonly box: Box
|
|
60
|
+
readonly drop: Box
|
|
61
|
+
readonly modifiers?: readonly Modifier[]
|
|
62
|
+
/**
|
|
63
|
+
* From press to release. The travel between is logged as `move` events,
|
|
64
|
+
* which the cursor is read off.
|
|
65
|
+
*/
|
|
66
|
+
readonly ms: number
|
|
67
|
+
}
|
|
68
|
+
| {
|
|
69
|
+
readonly t: number
|
|
70
|
+
readonly kind: 'key'
|
|
71
|
+
/** The combination as the plan wrote it, such as `Control+K`. */
|
|
72
|
+
readonly keys: string
|
|
73
|
+
readonly ms: number
|
|
46
74
|
}
|
|
47
75
|
| {
|
|
48
76
|
readonly t: number
|
|
@@ -68,10 +96,75 @@ export interface TakeLog {
|
|
|
68
96
|
readonly fps: number
|
|
69
97
|
/** The route that filmed the clip, which can differ from the one asked for. */
|
|
70
98
|
readonly route: Route
|
|
99
|
+
/**
|
|
100
|
+
* The clip's size in pixels, which sets how deep the camera may zoom before
|
|
101
|
+
* the clip goes soft. A log written before this field read it off `route`.
|
|
102
|
+
*/
|
|
103
|
+
readonly clip?: { readonly width: number; readonly height: number }
|
|
104
|
+
/** False when the beat turned the camera's zoom off. */
|
|
105
|
+
readonly push?: false
|
|
71
106
|
readonly frames: number
|
|
72
107
|
readonly events: readonly LogEvent[]
|
|
73
108
|
}
|
|
74
109
|
|
|
110
|
+
/** The size each route films at, the stepper at twice the viewport. */
|
|
111
|
+
export const CLIP_SIZES: Record<
|
|
112
|
+
Route,
|
|
113
|
+
{ readonly width: number; readonly height: number }
|
|
114
|
+
> = {
|
|
115
|
+
step: { width: VIEWPORT.width * 2, height: VIEWPORT.height * 2 },
|
|
116
|
+
screencast: VIEWPORT,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export type TakeLogRead =
|
|
120
|
+
| { readonly kind: 'read'; readonly log: TakeLog }
|
|
121
|
+
| { readonly kind: 'refused'; readonly detail: string }
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Reads a log back off disk, checking the fields the camera builds on rather
|
|
125
|
+
* than every event, since a take wrote the events and a reader only ever
|
|
126
|
+
* points at the wrong file.
|
|
127
|
+
*/
|
|
128
|
+
export function readTakeLog(source: string): TakeLogRead {
|
|
129
|
+
let document: unknown
|
|
130
|
+
try {
|
|
131
|
+
document = JSON.parse(source)
|
|
132
|
+
} catch (error) {
|
|
133
|
+
return {
|
|
134
|
+
kind: 'refused',
|
|
135
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (typeof document !== 'object' || document === null) {
|
|
139
|
+
return { kind: 'refused', detail: 'the document is not an object' }
|
|
140
|
+
}
|
|
141
|
+
const log = document as Partial<Record<keyof TakeLog, unknown>>
|
|
142
|
+
const missing = [
|
|
143
|
+
isSize(log.viewport) ? '' : 'viewport',
|
|
144
|
+
isCount(log.fps) && log.fps > 0 ? '' : 'fps',
|
|
145
|
+
ROUTES.includes(log.route as Route) ? '' : 'route',
|
|
146
|
+
isCount(log.frames) ? '' : 'frames',
|
|
147
|
+
Array.isArray(log.events) ? '' : 'events',
|
|
148
|
+
].filter(Boolean)
|
|
149
|
+
if (missing.length > 0) {
|
|
150
|
+
return {
|
|
151
|
+
kind: 'refused',
|
|
152
|
+
detail: `the document carries no valid ${missing.join(', ')}, so it is not a take's log.json`,
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { kind: 'read', log: document as TakeLog }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function isCount(value: unknown): value is number {
|
|
159
|
+
return Number.isInteger(value) && (value as number) >= 0
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function isSize(value: unknown): boolean {
|
|
163
|
+
if (typeof value !== 'object' || value === null) return false
|
|
164
|
+
const { width, height } = value as Record<string, unknown>
|
|
165
|
+
return isCount(width) && isCount(height)
|
|
166
|
+
}
|
|
167
|
+
|
|
75
168
|
/** Where the pointer rests before a take's first move. */
|
|
76
169
|
export const START: Point = { x: VIEWPORT.width / 2, y: VIEWPORT.height / 2 }
|
|
77
170
|
|
package/src/take/perform.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
along,
|
|
7
7
|
travelMs,
|
|
8
8
|
} from '@/take/log'
|
|
9
|
+
import { type Chord, type Modifier, isModifier, parseKeys } from '@/take/keys'
|
|
9
10
|
import type { TakeStep } from '@/take/plan'
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -23,11 +24,25 @@ const KEY_MS = 90
|
|
|
23
24
|
/** How long a scroll's wheel travel runs. */
|
|
24
25
|
const SCROLL_MS = 900
|
|
25
26
|
|
|
27
|
+
/** How long a drag holds still after its press, so the viewer sees the grab land. */
|
|
28
|
+
const GRAB_MS = 150
|
|
29
|
+
|
|
26
30
|
export type PointerInput =
|
|
27
31
|
| {
|
|
28
|
-
readonly type: 'move'
|
|
32
|
+
readonly type: 'move'
|
|
33
|
+
readonly x: number
|
|
34
|
+
readonly y: number
|
|
35
|
+
/** True while a drag holds the left button down. */
|
|
36
|
+
readonly held?: true
|
|
37
|
+
readonly modifiers?: readonly Modifier[]
|
|
38
|
+
}
|
|
39
|
+
| {
|
|
40
|
+
readonly type: 'down' | 'up'
|
|
29
41
|
readonly x: number
|
|
30
42
|
readonly y: number
|
|
43
|
+
/** Which press of a run this is, 2 being a double click's second. */
|
|
44
|
+
readonly clickCount: number
|
|
45
|
+
readonly modifiers?: readonly Modifier[]
|
|
31
46
|
}
|
|
32
47
|
| {
|
|
33
48
|
readonly type: 'wheel'
|
|
@@ -36,6 +51,14 @@ export type PointerInput =
|
|
|
36
51
|
readonly dy: number
|
|
37
52
|
}
|
|
38
53
|
|
|
54
|
+
/** One key going down or coming up. */
|
|
55
|
+
export interface KeyInput {
|
|
56
|
+
readonly type: 'down' | 'up'
|
|
57
|
+
readonly key: string
|
|
58
|
+
/** The modifiers held once this input lands, so a modifier's own down counts it. */
|
|
59
|
+
readonly modifiers: readonly Modifier[]
|
|
60
|
+
}
|
|
61
|
+
|
|
39
62
|
export interface Stage {
|
|
40
63
|
/** Milliseconds on the clip's own clock. */
|
|
41
64
|
now(): number
|
|
@@ -45,12 +68,17 @@ export interface Stage {
|
|
|
45
68
|
pointer(input: PointerInput): Promise<void>
|
|
46
69
|
/** Types one character into the focused field and lets one frame pass. */
|
|
47
70
|
key(text: string): Promise<void>
|
|
71
|
+
/** Sends one key down or up and lets one frame pass. */
|
|
72
|
+
press(input: KeyInput): Promise<void>
|
|
48
73
|
/** Evaluates an expression in the page. */
|
|
49
74
|
evaluate<T>(expression: string): Promise<T>
|
|
50
75
|
}
|
|
51
76
|
|
|
52
77
|
/** A target that resolved to nothing, which refuses rather than films a guess. */
|
|
53
78
|
export class TargetMissing extends Error {
|
|
79
|
+
/** The page as it stood when the lookup failed, as a JPEG, when the route caught one. */
|
|
80
|
+
still?: Buffer
|
|
81
|
+
|
|
54
82
|
constructor(readonly target: string) {
|
|
55
83
|
super(`nothing on the page matches ${target}`)
|
|
56
84
|
}
|
|
@@ -77,11 +105,40 @@ export async function performSteps(
|
|
|
77
105
|
)
|
|
78
106
|
break
|
|
79
107
|
case 'click':
|
|
80
|
-
|
|
108
|
+
case 'dblclick':
|
|
109
|
+
await click(
|
|
110
|
+
stage,
|
|
111
|
+
state,
|
|
112
|
+
await boxOf(stage, step.target),
|
|
113
|
+
step.modifiers ?? [],
|
|
114
|
+
step.kind === 'dblclick' ? 2 : 1,
|
|
115
|
+
)
|
|
81
116
|
break
|
|
117
|
+
case 'drag':
|
|
118
|
+
await drag(
|
|
119
|
+
stage,
|
|
120
|
+
state,
|
|
121
|
+
await boxOf(stage, step.from),
|
|
122
|
+
await boxOf(stage, step.to),
|
|
123
|
+
step.modifiers ?? [],
|
|
124
|
+
)
|
|
125
|
+
break
|
|
126
|
+
case 'key': {
|
|
127
|
+
const read = parseKeys(step.keys)
|
|
128
|
+
if (read.kind === 'refused') throw new Error(read.detail)
|
|
129
|
+
const start = stage.now()
|
|
130
|
+
await pressChord(stage, read.chord)
|
|
131
|
+
state.log?.push({
|
|
132
|
+
t: start,
|
|
133
|
+
kind: 'key',
|
|
134
|
+
keys: step.keys,
|
|
135
|
+
ms: stage.now() - start,
|
|
136
|
+
})
|
|
137
|
+
break
|
|
138
|
+
}
|
|
82
139
|
case 'fill': {
|
|
83
140
|
const box = await boxOf(stage, step.target)
|
|
84
|
-
await click(stage, state, box)
|
|
141
|
+
await click(stage, state, box, [], 1)
|
|
85
142
|
const start = stage.now()
|
|
86
143
|
for (const character of step.text) {
|
|
87
144
|
await stage.key(character)
|
|
@@ -132,6 +189,7 @@ async function moveTo(
|
|
|
132
189
|
stage: Stage,
|
|
133
190
|
state: Performance,
|
|
134
191
|
to: Point,
|
|
192
|
+
held?: { readonly modifiers: readonly Modifier[] },
|
|
135
193
|
): Promise<void> {
|
|
136
194
|
const from = state.pointer
|
|
137
195
|
if (Math.hypot(to.x - from.x, to.y - from.y) < 1) return
|
|
@@ -143,9 +201,12 @@ async function moveTo(
|
|
|
143
201
|
to,
|
|
144
202
|
ms: frames * FRAME_MS,
|
|
145
203
|
})
|
|
204
|
+
const pressed = held
|
|
205
|
+
? { held: true as const, ...withModifiers(held.modifiers) }
|
|
206
|
+
: {}
|
|
146
207
|
for (let frame = 1; frame <= frames; frame += 1) {
|
|
147
208
|
state.pointer = along(from, to, frame / frames)
|
|
148
|
-
await stage.pointer({ type: 'move', ...state.pointer })
|
|
209
|
+
await stage.pointer({ type: 'move', ...state.pointer, ...pressed })
|
|
149
210
|
}
|
|
150
211
|
state.pointer = to
|
|
151
212
|
}
|
|
@@ -154,12 +215,100 @@ async function click(
|
|
|
154
215
|
stage: Stage,
|
|
155
216
|
state: Performance,
|
|
156
217
|
box: Box,
|
|
218
|
+
modifiers: readonly Modifier[],
|
|
219
|
+
count: number,
|
|
157
220
|
): Promise<void> {
|
|
158
221
|
const at = center(box)
|
|
159
222
|
await moveTo(stage, state, at)
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
223
|
+
await holding(stage, modifiers, async () => {
|
|
224
|
+
state.log?.push({
|
|
225
|
+
t: stage.now(),
|
|
226
|
+
kind: 'click',
|
|
227
|
+
at,
|
|
228
|
+
box,
|
|
229
|
+
...withModifiers(modifiers),
|
|
230
|
+
...(count > 1 ? { count } : {}),
|
|
231
|
+
})
|
|
232
|
+
for (let clickCount = 1; clickCount <= count; clickCount += 1) {
|
|
233
|
+
const press = { ...at, clickCount, ...withModifiers(modifiers) }
|
|
234
|
+
await stage.pointer({ type: 'down', ...press })
|
|
235
|
+
await stage.pointer({ type: 'up', ...press })
|
|
236
|
+
}
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Presses on the source, holds a moment so the page reads a grab rather than
|
|
242
|
+
* a click, glides held to the target, and releases there. The drop target is
|
|
243
|
+
* read before the press, so a page that moves it once a drag starts is
|
|
244
|
+
* dropped on where it stood.
|
|
245
|
+
*/
|
|
246
|
+
async function drag(
|
|
247
|
+
stage: Stage,
|
|
248
|
+
state: Performance,
|
|
249
|
+
box: Box,
|
|
250
|
+
drop: Box,
|
|
251
|
+
modifiers: readonly Modifier[],
|
|
252
|
+
): Promise<void> {
|
|
253
|
+
const from = center(box)
|
|
254
|
+
const to = center(drop)
|
|
255
|
+
await moveTo(stage, state, from)
|
|
256
|
+
await holding(stage, modifiers, async () => {
|
|
257
|
+
const start = stage.now()
|
|
258
|
+
const press = { clickCount: 1, ...withModifiers(modifiers) }
|
|
259
|
+
await stage.pointer({ type: 'down', ...from, ...press })
|
|
260
|
+
await stage.hold(GRAB_MS)
|
|
261
|
+
await moveTo(stage, state, to, { modifiers })
|
|
262
|
+
await stage.pointer({ type: 'up', ...to, ...press })
|
|
263
|
+
state.log?.push({
|
|
264
|
+
t: start,
|
|
265
|
+
kind: 'drag',
|
|
266
|
+
from,
|
|
267
|
+
to,
|
|
268
|
+
box,
|
|
269
|
+
drop,
|
|
270
|
+
...withModifiers(modifiers),
|
|
271
|
+
ms: stage.now() - start,
|
|
272
|
+
})
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Presses each modifier in order, runs the inputs they hold, and releases them
|
|
278
|
+
* in reverse, so a page reads every input between as modified on both routes.
|
|
279
|
+
*/
|
|
280
|
+
async function holding(
|
|
281
|
+
stage: Stage,
|
|
282
|
+
modifiers: readonly Modifier[],
|
|
283
|
+
inputs: () => Promise<void>,
|
|
284
|
+
): Promise<void> {
|
|
285
|
+
for (const [i, key] of modifiers.entries()) {
|
|
286
|
+
await stage.press({
|
|
287
|
+
type: 'down',
|
|
288
|
+
key,
|
|
289
|
+
modifiers: modifiers.slice(0, i + 1),
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
await inputs()
|
|
293
|
+
for (const [i, key] of [...modifiers.entries()].reverse()) {
|
|
294
|
+
await stage.press({ type: 'up', key, modifiers: modifiers.slice(0, i) })
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Presses a chord's key inside its modifiers, counting a modifier key as held while down. */
|
|
299
|
+
async function pressChord(stage: Stage, chord: Chord): Promise<void> {
|
|
300
|
+
const { key, modifiers } = chord
|
|
301
|
+
await holding(stage, modifiers, async () => {
|
|
302
|
+
const down = isModifier(key) ? [...modifiers, key] : modifiers
|
|
303
|
+
await stage.press({ type: 'down', key, modifiers: down })
|
|
304
|
+
await stage.press({ type: 'up', key, modifiers })
|
|
305
|
+
})
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function withModifiers(modifiers: readonly Modifier[]): {
|
|
309
|
+
modifiers?: readonly Modifier[]
|
|
310
|
+
} {
|
|
311
|
+
return modifiers.length > 0 ? { modifiers } : {}
|
|
163
312
|
}
|
|
164
313
|
|
|
165
314
|
function center(box: Box): Point {
|
|
@@ -173,8 +322,10 @@ async function boxOf(stage: Stage, target: string): Promise<Box> {
|
|
|
173
322
|
}
|
|
174
323
|
|
|
175
324
|
/**
|
|
176
|
-
* Finds a target's box in CSS pixels, reading `text=<words>` as the
|
|
177
|
-
* element whose whole text is those words
|
|
325
|
+
* Finds a target's box in CSS pixels, reading `text=<words>` as the first
|
|
326
|
+
* innermost element with a size whose whole text is those words, so a hidden
|
|
327
|
+
* copy earlier in the page cannot shadow a visible one, and anything else as a
|
|
328
|
+
* CSS selector.
|
|
178
329
|
* It runs inside the page so both routes resolve a target the same way, and a
|
|
179
330
|
* match with no size counts as missing, since a click on it would land on
|
|
180
331
|
* whatever sits behind.
|
|
@@ -188,7 +339,11 @@ export function boxExpression(target: string): string {
|
|
|
188
339
|
const matches = [...document.querySelectorAll('body *')].filter(
|
|
189
340
|
(node) => node.textContent.trim() === want,
|
|
190
341
|
)
|
|
191
|
-
|
|
342
|
+
const innermost = matches.filter((node) => !matches.some((other) => other !== node && node.contains(other)))
|
|
343
|
+
element = innermost.find((node) => {
|
|
344
|
+
const rect = node.getBoundingClientRect()
|
|
345
|
+
return rect.width > 0 && rect.height > 0
|
|
346
|
+
}) ?? null
|
|
192
347
|
} else {
|
|
193
348
|
element = document.querySelector(target)
|
|
194
349
|
}
|
package/src/take/plan.ts
CHANGED
|
@@ -6,19 +6,44 @@
|
|
|
6
6
|
* keyboard leaves nothing in the log to draw.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { MODIFIERS, type Modifier, isModifier, parseKeys } from '@/take/keys'
|
|
10
|
+
|
|
9
11
|
/** How a beat is filmed: frame stepping, or Playwright's real-time screencast. */
|
|
10
12
|
export const ROUTES = ['step', 'screencast'] as const
|
|
11
13
|
|
|
12
14
|
export type Route = (typeof ROUTES)[number]
|
|
13
15
|
|
|
16
|
+
/** The keys held through a press, absent when none is. */
|
|
17
|
+
interface Held {
|
|
18
|
+
readonly modifiers?: readonly Modifier[]
|
|
19
|
+
}
|
|
20
|
+
|
|
14
21
|
export type TakeStep =
|
|
15
22
|
| { readonly kind: 'move'; readonly target: string }
|
|
16
23
|
| { readonly kind: 'move'; readonly x: number; readonly y: number }
|
|
17
|
-
| { readonly kind: 'click'; readonly target: string }
|
|
24
|
+
| ({ readonly kind: 'click'; readonly target: string } & Held)
|
|
25
|
+
| ({ readonly kind: 'dblclick'; readonly target: string } & Held)
|
|
26
|
+
| ({
|
|
27
|
+
readonly kind: 'drag'
|
|
28
|
+
readonly from: string
|
|
29
|
+
readonly to: string
|
|
30
|
+
} & Held)
|
|
31
|
+
| { readonly kind: 'key'; readonly keys: string }
|
|
18
32
|
| { readonly kind: 'fill'; readonly target: string; readonly text: string }
|
|
19
33
|
| { readonly kind: 'scroll'; readonly target: string; readonly dy: number }
|
|
20
34
|
| { readonly kind: 'wait'; readonly ms: number }
|
|
21
35
|
|
|
36
|
+
const KINDS = [
|
|
37
|
+
'move',
|
|
38
|
+
'click',
|
|
39
|
+
'dblclick',
|
|
40
|
+
'drag',
|
|
41
|
+
'key',
|
|
42
|
+
'fill',
|
|
43
|
+
'scroll',
|
|
44
|
+
'wait',
|
|
45
|
+
] as const
|
|
46
|
+
|
|
22
47
|
export interface Beat {
|
|
23
48
|
readonly name: string
|
|
24
49
|
/**
|
|
@@ -26,6 +51,11 @@ export interface Beat {
|
|
|
26
51
|
* back to `screencast` on its own, and the take's record says which ran.
|
|
27
52
|
*/
|
|
28
53
|
readonly route: Route
|
|
54
|
+
/**
|
|
55
|
+
* False turns the camera's zoom off for this beat, while its track still
|
|
56
|
+
* carries the cursor and the click ring. Absent means the zoom runs.
|
|
57
|
+
*/
|
|
58
|
+
readonly push?: false
|
|
29
59
|
/** Runs after the page loads and before the first frame is kept. */
|
|
30
60
|
readonly setup: readonly TakeStep[]
|
|
31
61
|
readonly steps: readonly TakeStep[]
|
|
@@ -89,16 +119,25 @@ function readPlan(document: unknown): TakePlan {
|
|
|
89
119
|
throw new Refusal('unreadable-plan', 'the document is not an object')
|
|
90
120
|
}
|
|
91
121
|
const { beats } = document
|
|
122
|
+
const route = document.route ?? 'step'
|
|
123
|
+
if (!ROUTES.includes(route as Route)) {
|
|
124
|
+
throw new Refusal(
|
|
125
|
+
'unknown-route',
|
|
126
|
+
`the plan names route ${String(route)}. This build records on: ${ROUTES.join(', ')}.`,
|
|
127
|
+
)
|
|
128
|
+
}
|
|
92
129
|
if (!Array.isArray(beats) || beats.length === 0) {
|
|
93
130
|
throw new Refusal(
|
|
94
131
|
'no-beats',
|
|
95
132
|
'beats carries no entry, so nothing is filmed',
|
|
96
133
|
)
|
|
97
134
|
}
|
|
98
|
-
return {
|
|
135
|
+
return {
|
|
136
|
+
beats: beats.map((entry, index) => readBeat(entry, index, route as Route)),
|
|
137
|
+
}
|
|
99
138
|
}
|
|
100
139
|
|
|
101
|
-
function readBeat(entry: unknown, index: number): Beat {
|
|
140
|
+
function readBeat(entry: unknown, index: number, fallback: Route): Beat {
|
|
102
141
|
const at = `beat ${index + 1}`
|
|
103
142
|
if (!isRecord(entry)) throw new Refusal('bad-beat', `${at} is not an object`)
|
|
104
143
|
|
|
@@ -110,7 +149,7 @@ function readBeat(entry: unknown, index: number): Beat {
|
|
|
110
149
|
)
|
|
111
150
|
}
|
|
112
151
|
|
|
113
|
-
const route = entry.route ??
|
|
152
|
+
const route = entry.route ?? fallback
|
|
114
153
|
if (!ROUTES.includes(route as Route)) {
|
|
115
154
|
throw new Refusal(
|
|
116
155
|
'unknown-route',
|
|
@@ -127,10 +166,17 @@ function readBeat(entry: unknown, index: number): Beat {
|
|
|
127
166
|
if (entry.setup !== undefined && !Array.isArray(entry.setup)) {
|
|
128
167
|
throw new Refusal('bad-beat', `${name} carries a setup that is not a list`)
|
|
129
168
|
}
|
|
169
|
+
if (entry.push !== undefined && typeof entry.push !== 'boolean') {
|
|
170
|
+
throw new Refusal(
|
|
171
|
+
'bad-beat',
|
|
172
|
+
`${name} carries a push that is not true or false`,
|
|
173
|
+
)
|
|
174
|
+
}
|
|
130
175
|
|
|
131
176
|
return {
|
|
132
177
|
name,
|
|
133
178
|
route: route as Route,
|
|
179
|
+
...(entry.push === false ? { push: false as const } : {}),
|
|
134
180
|
setup: (entry.setup ?? []).map((step: unknown, i: number) =>
|
|
135
181
|
readStep(step, `${name}, setup step ${i + 1}`),
|
|
136
182
|
),
|
|
@@ -160,7 +206,35 @@ function readStep(entry: unknown, at: string): TakeStep {
|
|
|
160
206
|
`${at} is a move with neither a target nor an x and y`,
|
|
161
207
|
)
|
|
162
208
|
case 'click':
|
|
163
|
-
|
|
209
|
+
case 'dblclick':
|
|
210
|
+
return {
|
|
211
|
+
kind: entry.kind,
|
|
212
|
+
target: needTarget(),
|
|
213
|
+
...readModifiers(entry.modifiers, at),
|
|
214
|
+
}
|
|
215
|
+
case 'drag':
|
|
216
|
+
if (typeof entry.from !== 'string' || !entry.from) {
|
|
217
|
+
throw new Refusal('bad-step', `${at} is a drag with no from target`)
|
|
218
|
+
}
|
|
219
|
+
if (typeof entry.to !== 'string' || !entry.to) {
|
|
220
|
+
throw new Refusal('bad-step', `${at} is a drag with no to target`)
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
kind: 'drag',
|
|
224
|
+
from: entry.from,
|
|
225
|
+
to: entry.to,
|
|
226
|
+
...readModifiers(entry.modifiers, at),
|
|
227
|
+
}
|
|
228
|
+
case 'key': {
|
|
229
|
+
if (typeof entry.keys !== 'string') {
|
|
230
|
+
throw new Refusal('bad-step', `${at} is a key with no keys to press`)
|
|
231
|
+
}
|
|
232
|
+
const read = parseKeys(entry.keys)
|
|
233
|
+
if (read.kind === 'refused') {
|
|
234
|
+
throw new Refusal('bad-step', `${at} is a key where ${read.detail}`)
|
|
235
|
+
}
|
|
236
|
+
return { kind: 'key', keys: entry.keys }
|
|
237
|
+
}
|
|
164
238
|
case 'fill':
|
|
165
239
|
if (typeof entry.text !== 'string') {
|
|
166
240
|
throw new Refusal('bad-step', `${at} is a fill with no text`)
|
|
@@ -179,9 +253,30 @@ function readStep(entry: unknown, at: string): TakeStep {
|
|
|
179
253
|
default:
|
|
180
254
|
throw new Refusal(
|
|
181
255
|
'bad-step',
|
|
182
|
-
`${at} names kind ${String(entry.kind)}, which is not
|
|
256
|
+
`${at} names kind ${String(entry.kind)}, which is not ${KINDS.join(', ')}`,
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function readModifiers(value: unknown, at: string): Held {
|
|
262
|
+
if (value === undefined) return {}
|
|
263
|
+
if (!Array.isArray(value)) {
|
|
264
|
+
throw new Refusal('bad-step', `${at} carries modifiers that are not a list`)
|
|
265
|
+
}
|
|
266
|
+
const modifiers: Modifier[] = []
|
|
267
|
+
for (const name of value) {
|
|
268
|
+
if (!isModifier(name)) {
|
|
269
|
+
throw new Refusal(
|
|
270
|
+
'bad-step',
|
|
271
|
+
`${at} holds ${String(name)}, which is not ${MODIFIERS.join(', ')}`,
|
|
183
272
|
)
|
|
273
|
+
}
|
|
274
|
+
if (modifiers.includes(name)) {
|
|
275
|
+
throw new Refusal('bad-step', `${at} holds ${name} twice`)
|
|
276
|
+
}
|
|
277
|
+
modifiers.push(name)
|
|
184
278
|
}
|
|
279
|
+
return modifiers.length > 0 ? { modifiers } : {}
|
|
185
280
|
}
|
|
186
281
|
|
|
187
282
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
package/src/take/screencast.ts
CHANGED
|
@@ -4,7 +4,12 @@ import { join } from 'node:path'
|
|
|
4
4
|
import { chromium } from 'playwright-core'
|
|
5
5
|
import { lastFrame, probeFrames, transcode } from '@/take/encode'
|
|
6
6
|
import { FPS, type LogEvent, START, VIEWPORT } from '@/take/log'
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
type PointerInput,
|
|
9
|
+
type Stage,
|
|
10
|
+
TargetMissing,
|
|
11
|
+
performSteps,
|
|
12
|
+
} from '@/take/perform'
|
|
8
13
|
import type { Beat } from '@/take/plan'
|
|
9
14
|
import { HOLD_MS } from '@/take/stepper'
|
|
10
15
|
|
|
@@ -19,6 +24,9 @@ const FRAME_MS = 1000 / FPS
|
|
|
19
24
|
|
|
20
25
|
const SETTLE_MS = 2500
|
|
21
26
|
|
|
27
|
+
/** How long a refusal waits on the screenshot it shows. */
|
|
28
|
+
const STILL_CAP_MS = 2000
|
|
29
|
+
|
|
22
30
|
export interface ScreencastTake {
|
|
23
31
|
readonly frames: number
|
|
24
32
|
readonly events: readonly LogEvent[]
|
|
@@ -33,8 +41,15 @@ export async function filmScreencast(request: {
|
|
|
33
41
|
}): Promise<ScreencastTake> {
|
|
34
42
|
const scratch = mkdtempSync(join(tmpdir(), 'canon-take-cast-'))
|
|
35
43
|
const browser = await chromium.launch()
|
|
44
|
+
let still: (() => Promise<Buffer | undefined>) | undefined
|
|
36
45
|
try {
|
|
37
46
|
const page = await browser.newPage({ viewport: VIEWPORT })
|
|
47
|
+
// Best effort, so a closed or crashed page leaves the refusal standing
|
|
48
|
+
// without a still rather than replacing it.
|
|
49
|
+
still = () =>
|
|
50
|
+
page
|
|
51
|
+
.screenshot({ type: 'jpeg', quality: 95, timeout: STILL_CAP_MS })
|
|
52
|
+
.catch(() => undefined)
|
|
38
53
|
await page.goto(request.url, { waitUntil: 'load' })
|
|
39
54
|
await page.waitForTimeout(SETTLE_MS)
|
|
40
55
|
|
|
@@ -51,6 +66,13 @@ export async function filmScreencast(request: {
|
|
|
51
66
|
await page.keyboard.insertText(text)
|
|
52
67
|
await page.waitForTimeout(FRAME_MS)
|
|
53
68
|
},
|
|
69
|
+
// Playwright's mouse reads the modifiers its keyboard holds, so a press
|
|
70
|
+
// here is all a modified click or drag needs on this route.
|
|
71
|
+
press: async (input) => {
|
|
72
|
+
if (input.type === 'down') await page.keyboard.down(input.key)
|
|
73
|
+
else await page.keyboard.up(input.key)
|
|
74
|
+
await page.waitForTimeout(FRAME_MS)
|
|
75
|
+
},
|
|
54
76
|
evaluate: (expression) => page.evaluate(expression),
|
|
55
77
|
})
|
|
56
78
|
|
|
@@ -75,6 +97,9 @@ export async function filmScreencast(request: {
|
|
|
75
97
|
await transcode(webm, request.clip, FPS)
|
|
76
98
|
const frames = await probeFrames(request.clip)
|
|
77
99
|
return { frames, events, still: await lastFrame(request.clip, frames) }
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error instanceof TargetMissing && still) error.still = await still()
|
|
102
|
+
throw error
|
|
78
103
|
} finally {
|
|
79
104
|
await browser.close()
|
|
80
105
|
rmSync(scratch, { recursive: true, force: true })
|
|
@@ -88,9 +113,9 @@ async function send(mouse: Mouse, input: PointerInput): Promise<void> {
|
|
|
88
113
|
case 'move':
|
|
89
114
|
return mouse.move(input.x, input.y)
|
|
90
115
|
case 'down':
|
|
91
|
-
return mouse.down()
|
|
116
|
+
return mouse.down({ clickCount: input.clickCount })
|
|
92
117
|
case 'up':
|
|
93
|
-
return mouse.up()
|
|
118
|
+
return mouse.up({ clickCount: input.clickCount })
|
|
94
119
|
case 'wheel':
|
|
95
120
|
return mouse.wheel(0, input.dy)
|
|
96
121
|
}
|