@erclx/canon 5.66.0 → 5.68.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 +23 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/app-drive/REQUIREMENT.md +35 -0
- package/claude/skills/app-drive/SKILL.md +112 -0
- package/claude/skills/app-drive/references/config.md +38 -0
- package/claude/skills/app-drive/references/drive-record.md +70 -0
- package/claude/skills/app-drive/references/isolate.md +46 -0
- package/claude/skills/canon-cli/SKILL.md +2 -0
- package/claude/skills/create-skill/references/skill.md +1 -1
- package/claude/skills/plan-feature/references/plan.md +1 -1
- package/claude/skills/plan-intake/references/intake.md +12 -2
- package/claude/skills/pr-evidence/REQUIREMENT.md +1 -1
- package/claude/skills/pr-evidence/SKILL.md +7 -1
- package/claude/skills/pr-evidence/references/evidence.md +33 -3
- package/claude/skills/review-craft/references/ui.md +30 -10
- package/claude/skills/review-ui/SKILL.md +5 -4
- package/claude/skills/review-ui/references/drive-copy.md +4 -0
- package/claude/skills/review-ui/references/driving.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +15 -2
- package/claude/skills/role-planner/SKILL.md +2 -2
- package/claude/skills/role-worker/SKILL.md +4 -6
- package/claude/skills/session-map/REQUIREMENT.md +1 -1
- package/claude/skills/session-map/SKILL.md +8 -1
- package/claude/skills/session-map/references/session.md +19 -5
- package/claude/skills/ui-record/REQUIREMENT.md +36 -0
- package/claude/skills/ui-record/SKILL.md +114 -0
- package/claude/skills/ui-record/references/config.md +40 -0
- package/claude/skills/ui-record/references/handoff.md +67 -0
- package/claude/skills/ui-record/references/pr-clip.md +84 -0
- package/claude/skills/ui-record/references/take-craft.md +99 -0
- package/docs/agents/records.md +2 -0
- package/docs/workflow/parallel-features.md +1 -1
- package/docs/workflow/skill-map-on-request.md +1 -0
- package/docs/workflow/skill-map.md +1 -0
- package/docs/workflow/visual-design-workflow.md +2 -1
- package/package.json +5 -2
- package/src/audits/catalog.ts +5 -1
- package/src/browser/agent-cli.ts +68 -0
- package/src/claude/cases/workflow.ts +20 -0
- package/src/cli/help.ts +3 -0
- package/src/cli.ts +4 -0
- package/src/commands/browser.ts +102 -0
- package/src/commands/intake.ts +181 -1
- package/src/commands/pr.ts +252 -85
- package/src/commands/records.ts +18 -1
- package/src/commands/take.ts +302 -0
- package/src/context/requirements.ts +1 -11
- package/src/git/status.ts +73 -0
- package/src/intake/drive-check.ts +355 -0
- package/src/intake/folder.ts +1 -1
- package/src/markdown/scan.ts +10 -0
- package/src/pr/drive-copy.ts +97 -84
- package/src/pr/evidence.ts +208 -78
- package/src/pr/frames.ts +42 -4
- package/src/pr/pixels.ts +44 -0
- package/src/records/backup.ts +28 -4
- package/src/records/session-map.ts +75 -0
- package/src/records/validate.ts +64 -8
- package/src/take/camera.ts +309 -0
- package/src/take/encode.ts +207 -0
- package/src/take/film.ts +211 -0
- package/src/take/fixtures/fade.html +49 -0
- package/src/take/fixtures/fetch.html +38 -0
- package/src/take/log.ts +109 -0
- package/src/take/perform.ts +200 -0
- package/src/take/plan.ts +193 -0
- package/src/take/pointer.ts +168 -0
- package/src/take/reel.ts +215 -0
- package/src/take/screencast.ts +97 -0
- package/src/take/shell.ts +287 -0
- package/src/take/stepper.ts +255 -0
- package/src/take/zoom/NOTICE.md +45 -0
- package/src/take/zoom/math-utils.ts +69 -0
- package/src/take/zoom/region-utils.ts +55 -0
- package/src/take/zoom/spring.ts +133 -0
- package/src/tasks/archive.ts +1 -3
- package/claude/skills/review-ui/scripts/pw.sh +0 -60
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Box,
|
|
3
|
+
FPS,
|
|
4
|
+
type LogEvent,
|
|
5
|
+
type Point,
|
|
6
|
+
along,
|
|
7
|
+
travelMs,
|
|
8
|
+
} from '@/take/log'
|
|
9
|
+
import type { TakeStep } from '@/take/plan'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Performs a beat's steps through a stage and logs what the edit needs. The
|
|
13
|
+
* choreography sits here once, and each capture route supplies the stage, being
|
|
14
|
+
* how an input reaches the page and how clip time passes, so a beat moves the
|
|
15
|
+
* same way whichever route filmed it.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const FRAME_MS = 1000 / FPS
|
|
19
|
+
|
|
20
|
+
/** The gap between keys, slow enough that a viewer reads each one land. */
|
|
21
|
+
const KEY_MS = 90
|
|
22
|
+
|
|
23
|
+
/** How long a scroll's wheel travel runs. */
|
|
24
|
+
const SCROLL_MS = 900
|
|
25
|
+
|
|
26
|
+
export type PointerInput =
|
|
27
|
+
| {
|
|
28
|
+
readonly type: 'move' | 'down' | 'up'
|
|
29
|
+
readonly x: number
|
|
30
|
+
readonly y: number
|
|
31
|
+
}
|
|
32
|
+
| {
|
|
33
|
+
readonly type: 'wheel'
|
|
34
|
+
readonly x: number
|
|
35
|
+
readonly y: number
|
|
36
|
+
readonly dy: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface Stage {
|
|
40
|
+
/** Milliseconds on the clip's own clock. */
|
|
41
|
+
now(): number
|
|
42
|
+
/** Lets `ms` of clip time pass. */
|
|
43
|
+
hold(ms: number): Promise<void>
|
|
44
|
+
/** Sends one pointer input and lets one frame pass. */
|
|
45
|
+
pointer(input: PointerInput): Promise<void>
|
|
46
|
+
/** Types one character into the focused field and lets one frame pass. */
|
|
47
|
+
key(text: string): Promise<void>
|
|
48
|
+
/** Evaluates an expression in the page. */
|
|
49
|
+
evaluate<T>(expression: string): Promise<T>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A target that resolved to nothing, which refuses rather than films a guess. */
|
|
53
|
+
export class TargetMissing extends Error {
|
|
54
|
+
constructor(readonly target: string) {
|
|
55
|
+
super(`nothing on the page matches ${target}`)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface Performance {
|
|
60
|
+
pointer: Point
|
|
61
|
+
/** Omitted for setup steps, which run before the clip starts. */
|
|
62
|
+
readonly log?: LogEvent[]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function performSteps(
|
|
66
|
+
stage: Stage,
|
|
67
|
+
steps: readonly TakeStep[],
|
|
68
|
+
state: Performance,
|
|
69
|
+
): Promise<void> {
|
|
70
|
+
for (const step of steps) {
|
|
71
|
+
switch (step.kind) {
|
|
72
|
+
case 'move':
|
|
73
|
+
await moveTo(
|
|
74
|
+
stage,
|
|
75
|
+
state,
|
|
76
|
+
'target' in step ? center(await boxOf(stage, step.target)) : step,
|
|
77
|
+
)
|
|
78
|
+
break
|
|
79
|
+
case 'click':
|
|
80
|
+
await click(stage, state, await boxOf(stage, step.target))
|
|
81
|
+
break
|
|
82
|
+
case 'fill': {
|
|
83
|
+
const box = await boxOf(stage, step.target)
|
|
84
|
+
await click(stage, state, box)
|
|
85
|
+
const start = stage.now()
|
|
86
|
+
for (const character of step.text) {
|
|
87
|
+
await stage.key(character)
|
|
88
|
+
await stage.hold(KEY_MS - FRAME_MS)
|
|
89
|
+
}
|
|
90
|
+
state.log?.push({
|
|
91
|
+
t: start,
|
|
92
|
+
kind: 'type',
|
|
93
|
+
at: state.pointer,
|
|
94
|
+
box,
|
|
95
|
+
chars: step.text.length,
|
|
96
|
+
ms: stage.now() - start,
|
|
97
|
+
})
|
|
98
|
+
break
|
|
99
|
+
}
|
|
100
|
+
case 'scroll': {
|
|
101
|
+
const at = center(await boxOf(stage, step.target))
|
|
102
|
+
await moveTo(stage, state, at)
|
|
103
|
+
const frames = Math.round(SCROLL_MS / FRAME_MS)
|
|
104
|
+
state.log?.push({
|
|
105
|
+
t: stage.now(),
|
|
106
|
+
kind: 'scroll',
|
|
107
|
+
at,
|
|
108
|
+
dx: 0,
|
|
109
|
+
dy: step.dy,
|
|
110
|
+
ms: frames * FRAME_MS,
|
|
111
|
+
})
|
|
112
|
+
let sent = 0
|
|
113
|
+
for (let frame = 1; frame <= frames; frame += 1) {
|
|
114
|
+
const want = along(
|
|
115
|
+
{ x: 0, y: 0 },
|
|
116
|
+
{ x: 0, y: step.dy },
|
|
117
|
+
frame / frames,
|
|
118
|
+
).y
|
|
119
|
+
await stage.pointer({ type: 'wheel', ...at, dy: want - sent })
|
|
120
|
+
sent = want
|
|
121
|
+
}
|
|
122
|
+
break
|
|
123
|
+
}
|
|
124
|
+
case 'wait':
|
|
125
|
+
await stage.hold(step.ms)
|
|
126
|
+
break
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function moveTo(
|
|
132
|
+
stage: Stage,
|
|
133
|
+
state: Performance,
|
|
134
|
+
to: Point,
|
|
135
|
+
): Promise<void> {
|
|
136
|
+
const from = state.pointer
|
|
137
|
+
if (Math.hypot(to.x - from.x, to.y - from.y) < 1) return
|
|
138
|
+
const frames = Math.max(1, Math.round(travelMs(from, to) / FRAME_MS))
|
|
139
|
+
state.log?.push({
|
|
140
|
+
t: stage.now(),
|
|
141
|
+
kind: 'move',
|
|
142
|
+
from,
|
|
143
|
+
to,
|
|
144
|
+
ms: frames * FRAME_MS,
|
|
145
|
+
})
|
|
146
|
+
for (let frame = 1; frame <= frames; frame += 1) {
|
|
147
|
+
state.pointer = along(from, to, frame / frames)
|
|
148
|
+
await stage.pointer({ type: 'move', ...state.pointer })
|
|
149
|
+
}
|
|
150
|
+
state.pointer = to
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function click(
|
|
154
|
+
stage: Stage,
|
|
155
|
+
state: Performance,
|
|
156
|
+
box: Box,
|
|
157
|
+
): Promise<void> {
|
|
158
|
+
const at = center(box)
|
|
159
|
+
await moveTo(stage, state, at)
|
|
160
|
+
state.log?.push({ t: stage.now(), kind: 'click', at, box })
|
|
161
|
+
await stage.pointer({ type: 'down', ...at })
|
|
162
|
+
await stage.pointer({ type: 'up', ...at })
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function center(box: Box): Point {
|
|
166
|
+
return { x: box.x + box.width / 2, y: box.y + box.height / 2 }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function boxOf(stage: Stage, target: string): Promise<Box> {
|
|
170
|
+
const box = await stage.evaluate<Box | null>(boxExpression(target))
|
|
171
|
+
if (!box) throw new TargetMissing(target)
|
|
172
|
+
return box
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Finds a target's box in CSS pixels, reading `text=<words>` as the innermost
|
|
177
|
+
* element whose whole text is those words and anything else as a CSS selector.
|
|
178
|
+
* It runs inside the page so both routes resolve a target the same way, and a
|
|
179
|
+
* match with no size counts as missing, since a click on it would land on
|
|
180
|
+
* whatever sits behind.
|
|
181
|
+
*/
|
|
182
|
+
export function boxExpression(target: string): string {
|
|
183
|
+
return `(() => {
|
|
184
|
+
const target = ${JSON.stringify(target)}
|
|
185
|
+
let element = null
|
|
186
|
+
if (target.startsWith('text=')) {
|
|
187
|
+
const want = target.slice(5).trim()
|
|
188
|
+
const matches = [...document.querySelectorAll('body *')].filter(
|
|
189
|
+
(node) => node.textContent.trim() === want,
|
|
190
|
+
)
|
|
191
|
+
element = matches.find((node) => !matches.some((other) => other !== node && node.contains(other))) ?? null
|
|
192
|
+
} else {
|
|
193
|
+
element = document.querySelector(target)
|
|
194
|
+
}
|
|
195
|
+
if (!element) return null
|
|
196
|
+
const rect = element.getBoundingClientRect()
|
|
197
|
+
if (rect.width === 0 || rect.height === 0) return null
|
|
198
|
+
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
|
|
199
|
+
})()`
|
|
200
|
+
}
|
package/src/take/plan.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The take plan: the beats a take films, each a list of steps. It reuses
|
|
3
|
+
* `canon drive`'s kind names for the steps the two share and adds `move`,
|
|
4
|
+
* since a take films the pointer's travel and a drive skips it. `tab` is left
|
|
5
|
+
* out because a take draws its cursor from the log, and focus moved by the
|
|
6
|
+
* keyboard leaves nothing in the log to draw.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** How a beat is filmed: frame stepping, or Playwright's real-time screencast. */
|
|
10
|
+
export const ROUTES = ['step', 'screencast'] as const
|
|
11
|
+
|
|
12
|
+
export type Route = (typeof ROUTES)[number]
|
|
13
|
+
|
|
14
|
+
export type TakeStep =
|
|
15
|
+
| { readonly kind: 'move'; readonly target: string }
|
|
16
|
+
| { readonly kind: 'move'; readonly x: number; readonly y: number }
|
|
17
|
+
| { readonly kind: 'click'; readonly target: string }
|
|
18
|
+
| { readonly kind: 'fill'; readonly target: string; readonly text: string }
|
|
19
|
+
| { readonly kind: 'scroll'; readonly target: string; readonly dy: number }
|
|
20
|
+
| { readonly kind: 'wait'; readonly ms: number }
|
|
21
|
+
|
|
22
|
+
export interface Beat {
|
|
23
|
+
readonly name: string
|
|
24
|
+
/**
|
|
25
|
+
* The route asked for. A `step` beat that frame stepping cannot drive falls
|
|
26
|
+
* back to `screencast` on its own, and the take's record says which ran.
|
|
27
|
+
*/
|
|
28
|
+
readonly route: Route
|
|
29
|
+
/** Runs after the page loads and before the first frame is kept. */
|
|
30
|
+
readonly setup: readonly TakeStep[]
|
|
31
|
+
readonly steps: readonly TakeStep[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface TakePlan {
|
|
35
|
+
readonly beats: readonly Beat[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type TakePlanRefusal =
|
|
39
|
+
| 'unreadable-plan'
|
|
40
|
+
| 'no-beats'
|
|
41
|
+
| 'bad-beat'
|
|
42
|
+
| 'no-steps'
|
|
43
|
+
| 'unknown-route'
|
|
44
|
+
| 'bad-step'
|
|
45
|
+
|
|
46
|
+
export type TakePlanRead =
|
|
47
|
+
| { readonly kind: 'read'; readonly plan: TakePlan }
|
|
48
|
+
| {
|
|
49
|
+
readonly kind: 'refused'
|
|
50
|
+
readonly reason: TakePlanRefusal
|
|
51
|
+
readonly detail: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
class Refusal extends Error {
|
|
55
|
+
constructor(
|
|
56
|
+
readonly reason: TakePlanRefusal,
|
|
57
|
+
detail: string,
|
|
58
|
+
) {
|
|
59
|
+
super(detail)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Reads a take plan out of a JSON document, the same form `canon drive` reads
|
|
65
|
+
* its run in, refusing the first thing a take could not perform.
|
|
66
|
+
*/
|
|
67
|
+
export function readTakePlan(source: string): TakePlanRead {
|
|
68
|
+
let document: unknown
|
|
69
|
+
try {
|
|
70
|
+
document = JSON.parse(source)
|
|
71
|
+
} catch (error) {
|
|
72
|
+
return {
|
|
73
|
+
kind: 'refused',
|
|
74
|
+
reason: 'unreadable-plan',
|
|
75
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
return { kind: 'read', plan: readPlan(document) }
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (!(error instanceof Refusal)) throw error
|
|
83
|
+
return { kind: 'refused', reason: error.reason, detail: error.message }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function readPlan(document: unknown): TakePlan {
|
|
88
|
+
if (!isRecord(document)) {
|
|
89
|
+
throw new Refusal('unreadable-plan', 'the document is not an object')
|
|
90
|
+
}
|
|
91
|
+
const { beats } = document
|
|
92
|
+
if (!Array.isArray(beats) || beats.length === 0) {
|
|
93
|
+
throw new Refusal(
|
|
94
|
+
'no-beats',
|
|
95
|
+
'beats carries no entry, so nothing is filmed',
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
return { beats: beats.map((entry, index) => readBeat(entry, index)) }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function readBeat(entry: unknown, index: number): Beat {
|
|
102
|
+
const at = `beat ${index + 1}`
|
|
103
|
+
if (!isRecord(entry)) throw new Refusal('bad-beat', `${at} is not an object`)
|
|
104
|
+
|
|
105
|
+
const name = typeof entry.name === 'string' ? entry.name : ''
|
|
106
|
+
if (!name) {
|
|
107
|
+
throw new Refusal(
|
|
108
|
+
'bad-beat',
|
|
109
|
+
`${at} carries no name, and its clip is named by it`,
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const route = entry.route ?? 'step'
|
|
114
|
+
if (!ROUTES.includes(route as Route)) {
|
|
115
|
+
throw new Refusal(
|
|
116
|
+
'unknown-route',
|
|
117
|
+
`${name} names route ${String(route)}. This build records on: ${ROUTES.join(', ')}.`,
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!Array.isArray(entry.steps) || entry.steps.length === 0) {
|
|
122
|
+
throw new Refusal(
|
|
123
|
+
'no-steps',
|
|
124
|
+
`${name} names no step, so its take would film nothing`,
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
if (entry.setup !== undefined && !Array.isArray(entry.setup)) {
|
|
128
|
+
throw new Refusal('bad-beat', `${name} carries a setup that is not a list`)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
name,
|
|
133
|
+
route: route as Route,
|
|
134
|
+
setup: (entry.setup ?? []).map((step: unknown, i: number) =>
|
|
135
|
+
readStep(step, `${name}, setup step ${i + 1}`),
|
|
136
|
+
),
|
|
137
|
+
steps: entry.steps.map((step: unknown, i: number) =>
|
|
138
|
+
readStep(step, `${name}, step ${i + 1}`),
|
|
139
|
+
),
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function readStep(entry: unknown, at: string): TakeStep {
|
|
144
|
+
if (!isRecord(entry)) throw new Refusal('bad-step', `${at} is not an object`)
|
|
145
|
+
|
|
146
|
+
const target = typeof entry.target === 'string' ? entry.target : undefined
|
|
147
|
+
const needTarget = (): string => {
|
|
148
|
+
if (target) return target
|
|
149
|
+
throw new Refusal('bad-step', `${at} is a ${entry.kind} with no target`)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
switch (entry.kind) {
|
|
153
|
+
case 'move':
|
|
154
|
+
if (target) return { kind: 'move', target }
|
|
155
|
+
if (isNumber(entry.x) && isNumber(entry.y)) {
|
|
156
|
+
return { kind: 'move', x: entry.x, y: entry.y }
|
|
157
|
+
}
|
|
158
|
+
throw new Refusal(
|
|
159
|
+
'bad-step',
|
|
160
|
+
`${at} is a move with neither a target nor an x and y`,
|
|
161
|
+
)
|
|
162
|
+
case 'click':
|
|
163
|
+
return { kind: 'click', target: needTarget() }
|
|
164
|
+
case 'fill':
|
|
165
|
+
if (typeof entry.text !== 'string') {
|
|
166
|
+
throw new Refusal('bad-step', `${at} is a fill with no text`)
|
|
167
|
+
}
|
|
168
|
+
return { kind: 'fill', target: needTarget(), text: entry.text }
|
|
169
|
+
case 'scroll':
|
|
170
|
+
if (!isNumber(entry.dy)) {
|
|
171
|
+
throw new Refusal('bad-step', `${at} is a scroll with no dy in pixels`)
|
|
172
|
+
}
|
|
173
|
+
return { kind: 'scroll', target: needTarget(), dy: entry.dy }
|
|
174
|
+
case 'wait':
|
|
175
|
+
if (!isNumber(entry.ms) || entry.ms < 0) {
|
|
176
|
+
throw new Refusal('bad-step', `${at} is a wait with no ms`)
|
|
177
|
+
}
|
|
178
|
+
return { kind: 'wait', ms: entry.ms }
|
|
179
|
+
default:
|
|
180
|
+
throw new Refusal(
|
|
181
|
+
'bad-step',
|
|
182
|
+
`${at} names kind ${String(entry.kind)}, which is not move, click, fill, scroll, or wait`,
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
188
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function isNumber(value: unknown): value is number {
|
|
192
|
+
return typeof value === 'number' && Number.isFinite(value)
|
|
193
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type { Point } from '@/take/log'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Draws the cursor arrow and the click ring onto one decoded frame, in the
|
|
5
|
+
* shape groundwork track `193-ui-record` rendered them: a dark arrow outlined
|
|
6
|
+
* in white, and a blue ring that grows from its tip and fades out. Both stay
|
|
7
|
+
* one size whatever the frame shows, so the pointer reads the same in every
|
|
8
|
+
* beat of a reel. Drawn in TypeScript rather than a browser, so the reel needs
|
|
9
|
+
* no page and no seek, and a test can read the pixels it leaves.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface Frame {
|
|
13
|
+
/** RGBA, four bytes a pixel, row by row. */
|
|
14
|
+
readonly data: Uint8Array
|
|
15
|
+
readonly width: number
|
|
16
|
+
readonly height: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The arrow, relative to its tip, from the groundwork's 24 px cursor path. */
|
|
20
|
+
const ARROW: readonly Point[] = [
|
|
21
|
+
{ x: 0, y: 0 },
|
|
22
|
+
{ x: 0, y: 17 },
|
|
23
|
+
{ x: 4.5, y: 12.5 },
|
|
24
|
+
{ x: 7.5, y: 19 },
|
|
25
|
+
{ x: 10, y: 18 },
|
|
26
|
+
{ x: 7, y: 11.5 },
|
|
27
|
+
{ x: 13.5, y: 11.5 },
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
const ARROW_FILL = [17, 17, 17] as const
|
|
31
|
+
const ARROW_STROKE = [255, 255, 255] as const
|
|
32
|
+
const STROKE_HALF = 0.75
|
|
33
|
+
|
|
34
|
+
/** The ring's outer radius at full size, and its stroke at that size. */
|
|
35
|
+
export const RING_RADIUS = 18
|
|
36
|
+
const RING_STROKE = 2
|
|
37
|
+
const RING_COLOR = [37, 99, 235] as const
|
|
38
|
+
/** The ring starts at this share of its size and grows by the expansion. */
|
|
39
|
+
const RING_START = 0.4
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Draws the pointer with its tip at `at`, in the frame's own pixels, and the
|
|
43
|
+
* ring around it when `ring` is a 0 to 1 expansion. The ring sits under the
|
|
44
|
+
* arrow, and anything past the frame's edge is clipped.
|
|
45
|
+
*/
|
|
46
|
+
export function drawPointer(
|
|
47
|
+
frame: Frame,
|
|
48
|
+
at: Point,
|
|
49
|
+
ring: number | null,
|
|
50
|
+
): void {
|
|
51
|
+
if (ring !== null) drawRing(frame, at, ring)
|
|
52
|
+
drawArrow(frame, at)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function drawRing(frame: Frame, at: Point, ring: number): void {
|
|
56
|
+
const scale = RING_START + ring
|
|
57
|
+
const stroke = RING_STROKE * scale
|
|
58
|
+
const middle = RING_RADIUS * scale - stroke / 2
|
|
59
|
+
const opacity = 1 - ring
|
|
60
|
+
if (opacity <= 0) return
|
|
61
|
+
|
|
62
|
+
const reach = RING_RADIUS * scale + 1
|
|
63
|
+
forEachPixel(frame, at, reach, reach, reach, reach, (px, py) => {
|
|
64
|
+
const distance = Math.hypot(px - at.x, py - at.y)
|
|
65
|
+
const coverage = clamp01(stroke / 2 + 0.5 - Math.abs(distance - middle))
|
|
66
|
+
return coverage > 0
|
|
67
|
+
? { color: RING_COLOR, alpha: coverage * opacity }
|
|
68
|
+
: null
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function drawArrow(frame: Frame, at: Point): void {
|
|
73
|
+
const reach = STROKE_HALF + 1
|
|
74
|
+
forEachPixel(frame, at, reach, 13.5 + reach, reach, 19 + reach, (px, py) => {
|
|
75
|
+
const d = signedDistance({ x: px - at.x, y: py - at.y })
|
|
76
|
+
// Coverage of the outlined shape, then of the fill inside the outline,
|
|
77
|
+
// each antialiased across one pixel.
|
|
78
|
+
const outer = clamp01(0.5 - (d - STROKE_HALF))
|
|
79
|
+
if (outer <= 0) return null
|
|
80
|
+
const inner = clamp01(0.5 - (d + STROKE_HALF))
|
|
81
|
+
return {
|
|
82
|
+
color: ARROW_STROKE,
|
|
83
|
+
alpha: outer,
|
|
84
|
+
over: ARROW_FILL,
|
|
85
|
+
overAlpha: inner,
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface Paint {
|
|
91
|
+
readonly color: readonly [number, number, number]
|
|
92
|
+
readonly alpha: number
|
|
93
|
+
/** A second color laid over the first, for the fill inside the outline. */
|
|
94
|
+
readonly over?: readonly [number, number, number]
|
|
95
|
+
readonly overAlpha?: number
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Calls `paint` for each pixel center inside the box around `at`, clipped to
|
|
100
|
+
* the frame, and blends whatever it returns onto that pixel.
|
|
101
|
+
*/
|
|
102
|
+
function forEachPixel(
|
|
103
|
+
frame: Frame,
|
|
104
|
+
at: Point,
|
|
105
|
+
left: number,
|
|
106
|
+
right: number,
|
|
107
|
+
up: number,
|
|
108
|
+
down: number,
|
|
109
|
+
paint: (x: number, y: number) => Paint | null,
|
|
110
|
+
): void {
|
|
111
|
+
const x0 = Math.max(0, Math.floor(at.x - left))
|
|
112
|
+
const x1 = Math.min(frame.width - 1, Math.ceil(at.x + right))
|
|
113
|
+
const y0 = Math.max(0, Math.floor(at.y - up))
|
|
114
|
+
const y1 = Math.min(frame.height - 1, Math.ceil(at.y + down))
|
|
115
|
+
for (let y = y0; y <= y1; y += 1) {
|
|
116
|
+
for (let x = x0; x <= x1; x += 1) {
|
|
117
|
+
const result = paint(x + 0.5, y + 0.5)
|
|
118
|
+
if (!result) continue
|
|
119
|
+
const i = (y * frame.width + x) * 4
|
|
120
|
+
blend(frame.data, i, result.color, result.alpha)
|
|
121
|
+
if (result.over && result.overAlpha) {
|
|
122
|
+
blend(frame.data, i, result.over, result.overAlpha)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function blend(
|
|
129
|
+
data: Uint8Array,
|
|
130
|
+
i: number,
|
|
131
|
+
color: readonly [number, number, number],
|
|
132
|
+
alpha: number,
|
|
133
|
+
): void {
|
|
134
|
+
for (let c = 0; c < 3; c += 1) {
|
|
135
|
+
const base = data[i + c] ?? 0
|
|
136
|
+
data[i + c] = Math.round(base + ((color[c] ?? 0) - base) * alpha)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Distance from `p` to the arrow's outline, negative inside it. */
|
|
141
|
+
function signedDistance(p: Point): number {
|
|
142
|
+
let nearest = Number.POSITIVE_INFINITY
|
|
143
|
+
let inside = false
|
|
144
|
+
for (let i = 0, j = ARROW.length - 1; i < ARROW.length; j = i, i += 1) {
|
|
145
|
+
const a = ARROW[j] ?? ARROW[0]
|
|
146
|
+
const b = ARROW[i] ?? ARROW[0]
|
|
147
|
+
if (!a || !b) continue
|
|
148
|
+
nearest = Math.min(nearest, segmentDistance(p, a, b))
|
|
149
|
+
if (
|
|
150
|
+
a.y > p.y !== b.y > p.y &&
|
|
151
|
+
p.x < ((b.x - a.x) * (p.y - a.y)) / (b.y - a.y) + a.x
|
|
152
|
+
) {
|
|
153
|
+
inside = !inside
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return inside ? -nearest : nearest
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function segmentDistance(p: Point, a: Point, b: Point): number {
|
|
160
|
+
const dx = b.x - a.x
|
|
161
|
+
const dy = b.y - a.y
|
|
162
|
+
const k = clamp01(((p.x - a.x) * dx + (p.y - a.y) * dy) / (dx * dx + dy * dy))
|
|
163
|
+
return Math.hypot(p.x - (a.x + k * dx), p.y - (a.y + k * dy))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function clamp01(value: number): number {
|
|
167
|
+
return Math.min(Math.max(value, 0), 1)
|
|
168
|
+
}
|