@erclx/canon 5.68.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 +36 -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/REQUIREMENT.md +5 -5
- package/claude/skills/app-drive/SKILL.md +17 -6
- package/claude/skills/app-drive/references/drive-record.md +61 -8
- package/claude/skills/app-drive/references/isolate.md +1 -1
- package/claude/skills/canon-cli/SKILL.md +2 -2
- package/claude/skills/canon-feedback/REQUIREMENT.md +1 -1
- package/claude/skills/canon-feedback/SKILL.md +1 -1
- package/claude/skills/design-identity/REQUIREMENT.md +4 -4
- package/claude/skills/design-identity/SKILL.md +7 -7
- package/claude/skills/{pick-by-eye → design-pick}/REQUIREMENT.md +6 -6
- package/claude/skills/{pick-by-eye → design-pick}/SKILL.md +19 -10
- package/claude/skills/{pick-by-eye → design-pick}/references/board.md +17 -11
- package/claude/skills/design-pick/references/design-config.md +33 -0
- package/claude/skills/design-record/REQUIREMENT.md +2 -2
- package/claude/skills/design-record/SKILL.md +1 -1
- package/claude/skills/design-taste/REQUIREMENT.md +2 -2
- package/claude/skills/design-taste/SKILL.md +3 -3
- package/claude/skills/design-taste/references/preflight.md +1 -1
- package/claude/skills/git-issue/REQUIREMENT.md +1 -1
- package/claude/skills/git-issue/references/issue.md +1 -1
- package/claude/skills/plan-feature/SKILL.md +1 -1
- package/claude/skills/plan-feature/references/plan.md +21 -6
- package/claude/skills/plan-groundwork/SKILL.md +1 -1
- package/claude/skills/pr-evidence/references/evidence.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +3 -3
- package/claude/skills/role-orchestrator/references/orchestrator-parked.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-planning-wait.md +24 -0
- package/claude/skills/role-planner/SKILL.md +4 -2
- package/claude/skills/role-worker/SKILL.md +5 -6
- package/claude/skills/task-board/references/tasks.md +1 -1
- package/claude/skills/task-complete/references/receipt-sweep.md +1 -1
- package/claude/skills/{ux-audit → ui-audit}/REQUIREMENT.md +3 -3
- package/claude/skills/{ux-audit → ui-audit}/SKILL.md +7 -7
- package/claude/skills/ui-checklist/REQUIREMENT.md +1 -1
- package/claude/skills/ui-craft/SKILL.md +1 -1
- package/claude/skills/ui-craft/references/copy.md +4 -1
- package/claude/skills/{ui-record → ui-film}/REQUIREMENT.md +6 -4
- package/claude/skills/{ui-record → ui-film}/SKILL.md +26 -13
- package/claude/skills/ui-film/references/compose-craft.md +81 -0
- package/claude/skills/{ui-record → ui-film}/references/config.md +9 -4
- package/claude/skills/{ui-record → ui-film}/references/handoff.md +17 -7
- package/claude/skills/{ui-record → ui-film}/references/pr-clip.md +5 -5
- package/claude/skills/{ui-record → ui-film}/references/take-craft.md +28 -2
- package/claude/skills/{ux-measure → ui-measure}/REQUIREMENT.md +3 -3
- package/claude/skills/{ux-measure → ui-measure}/SKILL.md +6 -6
- package/docs/agents/audits.md +1 -1
- package/docs/agents/migrate.md +13 -13
- 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 +3 -3
- package/docs/target/sync.md +23 -3
- package/docs/workflow/ai-workflow.md +1 -1
- package/docs/workflow/skill-map-on-request.md +1 -2
- package/docs/workflow/skill-map.md +9 -10
- package/docs/workflow/visual-design-workflow.md +6 -1
- package/governance/ledger/claude/background.md +26 -0
- package/governance/ledger/skills/ui-craft/copy.md +8 -4
- package/governance/rules/claude/background.md +19 -0
- package/governance/stacks/base.toml +1 -0
- package/package.json +6 -4
- package/scripts/ci/install-playwright-deps.sh +52 -0
- 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/claude/cases/authoring.ts +2 -2
- package/src/claude/cases/misc.ts +1 -1
- package/src/claude/cases/setup.ts +0 -5
- package/src/claude/cases/workflow.ts +6 -6
- package/src/commands/fixtures/gh.sh +64 -0
- package/src/commands/gov.ts +33 -3
- package/src/commands/migrate.ts +12 -2
- package/src/commands/records.ts +3 -2
- package/src/commands/sandbox.ts +9 -2
- package/src/commands/take.ts +159 -11
- package/src/commands/tasks.ts +183 -0
- 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 +8 -2
- package/src/gov/audit.ts +48 -11
- package/src/gov/detect.ts +1 -1
- package/src/init/steps.ts +2 -2
- package/src/markdown/scope.ts +9 -14
- package/src/migrate/record-layout.ts +5 -3
- package/src/migrate/skill-names.ts +21 -4
- package/src/migrate/status.ts +3 -3
- package/src/records/validate.ts +15 -2
- package/src/sandbox/expect.ts +32 -0
- package/src/sandbox/headless/dispatch-shim.sh +15 -0
- package/src/sandbox/headless/dispatch.ts +12 -14
- package/src/sandbox/headless/run.ts +6 -3
- package/src/sandbox/stub-gh.sh +26 -0
- package/src/sandbox/stub-remote.ts +8 -29
- package/src/sync/engine.ts +1 -1
- package/src/take/camera.ts +100 -11
- package/src/take/film.ts +55 -4
- 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/pointer.ts +2 -1
- package/src/take/screencast.ts +28 -3
- package/src/take/stepper.ts +141 -23
- package/src/take/zoom/NOTICE.md +1 -1
- package/src/tasks/launch-wait.ts +160 -0
- package/tooling/claude/reference.md +1 -1
- package/claude/skills/canon-feedback-triage/REQUIREMENT.md +0 -34
- package/claude/skills/canon-feedback-triage/SKILL.md +0 -98
- package/claude/skills/plan-ready/REQUIREMENT.md +0 -35
- package/claude/skills/plan-ready/SKILL.md +0 -136
- package/claude/skills/plan-ready/references/assembly.md +0 -75
- package/claude/skills/plan-ready/references/ready.md +0 -128
- package/docs/target/migrations.md +0 -203
- /package/claude/skills/{pick-by-eye → design-pick}/references/live-arms.md +0 -0
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/pointer.ts
CHANGED
|
@@ -2,7 +2,8 @@ import type { Point } from '@/take/log'
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Draws the cursor arrow and the click ring onto one decoded frame, in the
|
|
5
|
-
* shape groundwork track `193-ui-record` rendered them:
|
|
5
|
+
* shape groundwork track `193-ui-record` rendered them (canon-keep-retired):
|
|
6
|
+
* a dark arrow outlined
|
|
6
7
|
* in white, and a blue ring that grows from its tip and fades out. Both stay
|
|
7
8
|
* one size whatever the frame shows, so the pointer reads the same in every
|
|
8
9
|
* beat of a reel. Drawn in TypeScript rather than a browser, so the reel needs
|
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
|
}
|
package/src/take/stepper.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { openFrameEncoder } from '@/take/encode'
|
|
2
|
+
import { describeKey, modifierBits } from '@/take/keys'
|
|
2
3
|
import { FPS, type LogEvent, START, VIEWPORT } from '@/take/log'
|
|
3
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
type KeyInput,
|
|
6
|
+
type PointerInput,
|
|
7
|
+
type Stage,
|
|
8
|
+
TargetMissing,
|
|
9
|
+
performSteps,
|
|
10
|
+
} from '@/take/perform'
|
|
4
11
|
import type { Beat } from '@/take/plan'
|
|
5
12
|
import { type ShellPage, openShell } from '@/take/shell'
|
|
6
13
|
|
|
@@ -36,10 +43,16 @@ const LOAD_CAP_MS = 15_000
|
|
|
36
43
|
const SETTLE_MS = 2500
|
|
37
44
|
const LOADING_FRAME_CAP_MS = 250
|
|
38
45
|
|
|
46
|
+
/** How long a refusal waits on the frame it shows, since the page may be wedged. */
|
|
47
|
+
const STILL_CAP_MS = 2000
|
|
48
|
+
|
|
39
49
|
/** A frame that took longer than `STALL_MS`, so the beat is filmed another way. */
|
|
40
50
|
export class StepperStalled extends Error {
|
|
41
|
-
constructor(
|
|
42
|
-
|
|
51
|
+
constructor(
|
|
52
|
+
readonly frame: number,
|
|
53
|
+
stallMs: number,
|
|
54
|
+
) {
|
|
55
|
+
super(`frame ${frame} took over ${stallMs / 1000} s under frame stepping`)
|
|
43
56
|
}
|
|
44
57
|
}
|
|
45
58
|
|
|
@@ -55,6 +68,8 @@ export interface SteppedRequest {
|
|
|
55
68
|
readonly url: string
|
|
56
69
|
readonly beat: Beat
|
|
57
70
|
readonly clip: string
|
|
71
|
+
/** How long one frame may take before the beat falls back, `STALL_MS` by default. */
|
|
72
|
+
readonly stallMs?: number
|
|
58
73
|
}
|
|
59
74
|
|
|
60
75
|
export async function filmStepped(
|
|
@@ -62,6 +77,8 @@ export async function filmStepped(
|
|
|
62
77
|
): Promise<SteppedTake> {
|
|
63
78
|
const page = await openShell(request.executable, VIEWPORT)
|
|
64
79
|
const encoder = openFrameEncoder(request.clip, FPS)
|
|
80
|
+
const stallMs = request.stallMs ?? STALL_MS
|
|
81
|
+
let still: (() => Promise<Buffer | undefined>) | undefined
|
|
65
82
|
try {
|
|
66
83
|
let ticks = await load(page, request.url)
|
|
67
84
|
const beginFrame = (screenshot: boolean) =>
|
|
@@ -75,9 +92,10 @@ export async function filmStepped(
|
|
|
75
92
|
: {}),
|
|
76
93
|
},
|
|
77
94
|
)
|
|
95
|
+
still = () => frameStill(beginFrame)
|
|
78
96
|
await pump(beginFrame, SETTLE_MS)
|
|
79
97
|
|
|
80
|
-
const live = liveStage(page, beginFrame)
|
|
98
|
+
const live = liveStage(page, beginFrame, stallMs)
|
|
81
99
|
await performSteps(live, request.beat.setup, { pointer: START })
|
|
82
100
|
if (request.beat.setup.length > 0) await live.hold(1000)
|
|
83
101
|
await live.pointer({ type: 'move', ...START })
|
|
@@ -106,6 +124,7 @@ export async function filmStepped(
|
|
|
106
124
|
const reply = await withinStall(
|
|
107
125
|
advanced.then(() => beginFrame(true)),
|
|
108
126
|
frames,
|
|
127
|
+
stallMs,
|
|
109
128
|
)
|
|
110
129
|
const data = reply.screenshotData
|
|
111
130
|
? Buffer.from(reply.screenshotData, 'base64')
|
|
@@ -121,16 +140,14 @@ export async function filmStepped(
|
|
|
121
140
|
hold: async (ms) => {
|
|
122
141
|
for (let i = 0; i < Math.round(ms / FRAME_MS); i += 1) await frame()
|
|
123
142
|
},
|
|
124
|
-
pointer:
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
key:
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
await sent
|
|
133
|
-
},
|
|
143
|
+
pointer: (input) =>
|
|
144
|
+
stepThen(
|
|
145
|
+
page.send('Input.dispatchMouseEvent', mouseEvent(input)),
|
|
146
|
+
frame,
|
|
147
|
+
),
|
|
148
|
+
key: (text) => stepThen(page.send('Input.insertText', { text }), frame),
|
|
149
|
+
press: (input) =>
|
|
150
|
+
stepThen(page.send('Input.dispatchKeyEvent', keyEvent(input)), frame),
|
|
134
151
|
evaluate: (expression) => page.evaluate(expression),
|
|
135
152
|
}
|
|
136
153
|
|
|
@@ -146,12 +163,31 @@ export async function filmStepped(
|
|
|
146
163
|
return { frames, events, still: last }
|
|
147
164
|
} catch (error) {
|
|
148
165
|
encoder.abort()
|
|
166
|
+
if (error instanceof TargetMissing && still) error.still = await still()
|
|
149
167
|
throw error
|
|
150
168
|
} finally {
|
|
151
169
|
page.close()
|
|
152
170
|
}
|
|
153
171
|
}
|
|
154
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Begins one frame with a screenshot for a refusal to show, through the same
|
|
175
|
+
* begin-frame call every kept frame uses, since a separate capture call can
|
|
176
|
+
* wait on a frame that never begins. Best effort, so a page that answers with
|
|
177
|
+
* nothing leaves the refusal standing without a still.
|
|
178
|
+
*/
|
|
179
|
+
async function frameStill(
|
|
180
|
+
beginFrame: (screenshot: boolean) => Promise<{ screenshotData?: string }>,
|
|
181
|
+
): Promise<Buffer | undefined> {
|
|
182
|
+
const reply = await Promise.race([
|
|
183
|
+
beginFrame(true).catch(() => undefined),
|
|
184
|
+
sleep(STILL_CAP_MS),
|
|
185
|
+
])
|
|
186
|
+
return reply?.screenshotData
|
|
187
|
+
? Buffer.from(reply.screenshotData, 'base64')
|
|
188
|
+
: undefined
|
|
189
|
+
}
|
|
190
|
+
|
|
155
191
|
/**
|
|
156
192
|
* Navigates and resolves with Chromium's monotonic clock in milliseconds, read
|
|
157
193
|
* off the load event's own timestamp. The runtime's clock cannot stand in for
|
|
@@ -201,6 +237,7 @@ async function pump(
|
|
|
201
237
|
function liveStage(
|
|
202
238
|
page: ShellPage,
|
|
203
239
|
beginFrame: (screenshot: boolean) => Promise<unknown>,
|
|
240
|
+
stallMs: number,
|
|
204
241
|
): Stage {
|
|
205
242
|
const started = Date.now()
|
|
206
243
|
return {
|
|
@@ -209,40 +246,121 @@ function liveStage(
|
|
|
209
246
|
pointer: (input) => {
|
|
210
247
|
const sent = page.send('Input.dispatchMouseEvent', mouseEvent(input))
|
|
211
248
|
return withinStall(
|
|
212
|
-
beginFrame(false)
|
|
249
|
+
Promise.all([beginFrame(false), sent]),
|
|
213
250
|
0,
|
|
251
|
+
stallMs,
|
|
214
252
|
).then(() => {})
|
|
215
253
|
},
|
|
216
254
|
key: (text) => {
|
|
217
255
|
const sent = page.send('Input.insertText', { text })
|
|
218
256
|
return withinStall(
|
|
219
|
-
beginFrame(false)
|
|
257
|
+
Promise.all([beginFrame(false), sent]),
|
|
220
258
|
0,
|
|
259
|
+
stallMs,
|
|
260
|
+
).then(() => {})
|
|
261
|
+
},
|
|
262
|
+
press: (input) => {
|
|
263
|
+
const sent = page.send('Input.dispatchKeyEvent', keyEvent(input))
|
|
264
|
+
return withinStall(
|
|
265
|
+
Promise.all([beginFrame(false), sent]),
|
|
266
|
+
0,
|
|
267
|
+
stallMs,
|
|
221
268
|
).then(() => {})
|
|
222
269
|
},
|
|
223
270
|
evaluate: (expression) => page.evaluate(expression),
|
|
224
271
|
}
|
|
225
272
|
}
|
|
226
273
|
|
|
274
|
+
/**
|
|
275
|
+
* A pointer input as CDP takes it. `buttons` carries the held button through a
|
|
276
|
+
* drag's moves, or the page reads them as a hover, and `modifiers` carries the
|
|
277
|
+
* held keys as a bit field beside the key events that pressed them.
|
|
278
|
+
*/
|
|
227
279
|
function mouseEvent(input: PointerInput): Record<string, unknown> {
|
|
228
280
|
const at = { x: input.x, y: input.y }
|
|
281
|
+
if (input.type === 'wheel') {
|
|
282
|
+
return { type: 'mouseWheel', ...at, deltaX: 0, deltaY: input.dy }
|
|
283
|
+
}
|
|
284
|
+
const modifiers = modifierBits(input.modifiers ?? [])
|
|
229
285
|
switch (input.type) {
|
|
230
286
|
case 'move':
|
|
231
|
-
return
|
|
287
|
+
return input.held
|
|
288
|
+
? { type: 'mouseMoved', ...at, button: 'left', buttons: 1, modifiers }
|
|
289
|
+
: { type: 'mouseMoved', ...at, button: 'none', buttons: 0, modifiers }
|
|
232
290
|
case 'down':
|
|
233
|
-
return { type: 'mousePressed', ...at, button: 'left', clickCount: 1 }
|
|
234
291
|
case 'up':
|
|
235
|
-
return {
|
|
236
|
-
|
|
237
|
-
|
|
292
|
+
return {
|
|
293
|
+
type: input.type === 'down' ? 'mousePressed' : 'mouseReleased',
|
|
294
|
+
...at,
|
|
295
|
+
button: 'left',
|
|
296
|
+
buttons: input.type === 'down' ? 1 : 0,
|
|
297
|
+
clickCount: input.clickCount,
|
|
298
|
+
modifiers,
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* A key input as CDP takes it. A key that types sends `keyDown` with its text
|
|
305
|
+
* only when no modifier but Shift is held, as Chromium's own keyboard does, so
|
|
306
|
+
* `Control+K` in a focused field inserts no K.
|
|
307
|
+
*/
|
|
308
|
+
function keyEvent(input: KeyInput): Record<string, unknown> {
|
|
309
|
+
const descriptor = describeKey(input.key, {
|
|
310
|
+
shift: input.modifiers.includes('Shift'),
|
|
311
|
+
})
|
|
312
|
+
if (!descriptor) throw new Error(`no key descriptor for ${input.key}`)
|
|
313
|
+
const event = {
|
|
314
|
+
key: descriptor.key,
|
|
315
|
+
code: descriptor.code,
|
|
316
|
+
windowsVirtualKeyCode: descriptor.keyCode,
|
|
317
|
+
nativeVirtualKeyCode: descriptor.keyCode,
|
|
318
|
+
location: descriptor.location,
|
|
319
|
+
modifiers: modifierBits(input.modifiers),
|
|
320
|
+
}
|
|
321
|
+
if (input.type === 'up') return { type: 'keyUp', ...event }
|
|
322
|
+
const types =
|
|
323
|
+
descriptor.text !== undefined &&
|
|
324
|
+
input.modifiers.every((modifier) => modifier === 'Shift')
|
|
325
|
+
return types
|
|
326
|
+
? {
|
|
327
|
+
type: 'keyDown',
|
|
328
|
+
...event,
|
|
329
|
+
text: descriptor.text,
|
|
330
|
+
unmodifiedText: descriptor.text,
|
|
331
|
+
}
|
|
332
|
+
: { type: 'rawKeyDown', ...event }
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Steps a frame, then awaits the input sent ahead of it. When the step fails,
|
|
337
|
+
* the input is left settling against a shell about to close, so its rejection
|
|
338
|
+
* is handled here rather than surfacing as an unhandled one.
|
|
339
|
+
*/
|
|
340
|
+
async function stepThen(
|
|
341
|
+
sent: Promise<unknown>,
|
|
342
|
+
step: () => Promise<void>,
|
|
343
|
+
): Promise<void> {
|
|
344
|
+
try {
|
|
345
|
+
await step()
|
|
346
|
+
} catch (error) {
|
|
347
|
+
sent.catch(() => {})
|
|
348
|
+
throw error
|
|
238
349
|
}
|
|
350
|
+
await sent
|
|
239
351
|
}
|
|
240
352
|
|
|
241
|
-
async function withinStall<T>(
|
|
353
|
+
async function withinStall<T>(
|
|
354
|
+
work: Promise<T>,
|
|
355
|
+
frame: number,
|
|
356
|
+
stallMs: number,
|
|
357
|
+
): Promise<T> {
|
|
242
358
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
243
359
|
const stalled = new Promise<never>((_, fail) => {
|
|
244
|
-
timer = setTimeout(() => fail(new StepperStalled(frame)),
|
|
360
|
+
timer = setTimeout(() => fail(new StepperStalled(frame, stallMs)), stallMs)
|
|
245
361
|
})
|
|
362
|
+
// A stall leaves the work pending until the shell closes and rejects it.
|
|
363
|
+
work.catch(() => {})
|
|
246
364
|
try {
|
|
247
365
|
return await Promise.race([work, stalled])
|
|
248
366
|
} finally {
|
package/src/take/zoom/NOTICE.md
CHANGED
|
@@ -15,7 +15,7 @@ The files in this folder carry the zoom timing of [OpenScreen](https://github.co
|
|
|
15
15
|
- The file names are kebab-case, as this repository names every TypeScript file, and the code is reformatted to its style
|
|
16
16
|
- `math-utils.ts` keeps `clamp01`, `cubicBezier`, and `easeOutScreenStudio`, and drops the easings nothing here calls
|
|
17
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.
|
|
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`. <!-- canon-keep-retired --> The zoom-in window keeps OpenScreen's 1522.575 ms.
|
|
19
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
20
|
|
|
21
21
|
## License
|