@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/camera.ts
CHANGED
|
@@ -5,26 +5,17 @@ import {
|
|
|
5
5
|
type Point,
|
|
6
6
|
type TakeLog,
|
|
7
7
|
cursorAt,
|
|
8
|
+
easeInOutCubic,
|
|
8
9
|
} from '@/take/log'
|
|
9
|
-
import {
|
|
10
|
-
type SpringState,
|
|
11
|
-
createSpringState,
|
|
12
|
-
stepSpring,
|
|
13
|
-
} from '@/take/zoom/spring'
|
|
14
|
-
import {
|
|
15
|
-
CHAINED_ZOOM_PAN_GAP_MS,
|
|
16
|
-
CONNECTED_ZOOM_PAN_DURATION_MS,
|
|
17
|
-
computeRegionStrength,
|
|
18
|
-
easeConnectedPan,
|
|
19
|
-
lerp,
|
|
20
|
-
} from '@/take/zoom/region-utils'
|
|
21
10
|
|
|
22
11
|
/**
|
|
23
|
-
* Turns a take's log into one camera per frame,
|
|
24
|
-
* track `
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
12
|
+
* Turns a take's log into one camera per frame, on the motion the operator
|
|
13
|
+
* settled by eye in groundwork track `210-film-craft-and-soundtrack`. A zoom in
|
|
14
|
+
* rides the glide that reaches its target and lands as the cursor arrives, a
|
|
15
|
+
* zoom out rides the glide that leaves it, and the view scales about the
|
|
16
|
+
* target so it holds still on screen. The view pans only at a steady scale,
|
|
17
|
+
* along the glide between two targets. HyperFrames renders the track as it
|
|
18
|
+
* stands, so it carries no curve a renderer would have to agree on.
|
|
28
19
|
*/
|
|
29
20
|
|
|
30
21
|
export const CAMERA_VERSION = 1
|
|
@@ -45,22 +36,20 @@ const FILL = 0.8
|
|
|
45
36
|
/** A click whose depth comes out under this zooms nowhere, being nearly the whole page. */
|
|
46
37
|
const MIN_ZOOM = 1.05
|
|
47
38
|
|
|
48
|
-
/** How long a zoom
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
/** A click inside this share of the current view keeps the focus where it is. */
|
|
52
|
-
const STICKY = 0.7
|
|
53
|
-
|
|
54
|
-
/** The cursor stays inside this share of the zoomed view. */
|
|
55
|
-
const SAFE = 0.75
|
|
39
|
+
/** How long a zoom or a pan runs when no glide carries it. */
|
|
40
|
+
const EASE_MS = 600
|
|
56
41
|
|
|
57
42
|
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* than the dead zone's margin, so the rendered view is pushed along with the
|
|
61
|
-
* cursor at this edge rather than letting it leave the frame.
|
|
43
|
+
* How long a zoom holds past its last aim when no glide leaves it, matching
|
|
44
|
+
* the hold a take films on a press's result.
|
|
62
45
|
*/
|
|
63
|
-
const
|
|
46
|
+
const HOLD_MS = 500
|
|
47
|
+
|
|
48
|
+
/** An aim inside this share of the held view around its stop keeps the view where it is. */
|
|
49
|
+
const STICKY = 0.7
|
|
50
|
+
|
|
51
|
+
/** And it stays at least this share of the view clear of the view's edge. */
|
|
52
|
+
const MARGIN = 0.05
|
|
64
53
|
|
|
65
54
|
/** How long the click ring the edit draws takes to expand. */
|
|
66
55
|
const RING_MS = 450
|
|
@@ -92,18 +81,34 @@ export interface CameraTrack {
|
|
|
92
81
|
readonly frames: readonly CameraFrame[]
|
|
93
82
|
}
|
|
94
83
|
|
|
95
|
-
/**
|
|
84
|
+
/**
|
|
85
|
+
* What the camera frames: a press, a typing burst, or a move's hold, from the
|
|
86
|
+
* moment it starts to the moment the pointer may leave.
|
|
87
|
+
*/
|
|
96
88
|
interface Aim {
|
|
97
89
|
readonly t: number
|
|
98
90
|
readonly until: number
|
|
99
91
|
readonly at: Point
|
|
92
|
+
/** What the view has to keep in frame, being both ends of a drag. */
|
|
93
|
+
readonly box: Box
|
|
100
94
|
readonly depth: number
|
|
101
95
|
}
|
|
102
96
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
readonly
|
|
97
|
+
/** A stretch of clip time, eased from its start to its end. */
|
|
98
|
+
interface Span {
|
|
99
|
+
readonly start: number
|
|
100
|
+
readonly end: number
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* One zoom: in onto its first stop, a pan at its depth to each later stop,
|
|
105
|
+
* and out from its last.
|
|
106
|
+
*/
|
|
107
|
+
interface Zoom {
|
|
108
|
+
readonly depth: number
|
|
109
|
+
readonly into: Span
|
|
110
|
+
readonly stops: readonly { readonly at: Point; readonly pan: Span }[]
|
|
111
|
+
readonly out: Span
|
|
107
112
|
}
|
|
108
113
|
|
|
109
114
|
export interface CameraOptions {
|
|
@@ -129,48 +134,14 @@ export function cameraTrack(
|
|
|
129
134
|
(height * FILL) / Math.max(box.height, 1),
|
|
130
135
|
)
|
|
131
136
|
|
|
132
|
-
const aims
|
|
133
|
-
.flatMap((event): Aim[] =>
|
|
134
|
-
if (event.kind === 'click') {
|
|
135
|
-
return [
|
|
136
|
-
{
|
|
137
|
-
t: event.t,
|
|
138
|
-
until: event.t,
|
|
139
|
-
at: event.at,
|
|
140
|
-
depth: depthFor(event.box),
|
|
141
|
-
},
|
|
142
|
-
]
|
|
143
|
-
}
|
|
144
|
-
if (event.kind === 'drag') {
|
|
145
|
-
// One aim over both ends, held to the release, so a short drag stays
|
|
146
|
-
// framed whole and a long one asks for a depth under MIN_ZOOM.
|
|
147
|
-
const box = union(event.box, event.drop)
|
|
148
|
-
return [
|
|
149
|
-
{
|
|
150
|
-
t: event.t,
|
|
151
|
-
until: event.t + event.ms,
|
|
152
|
-
at: { x: box.x + box.width / 2, y: box.y + box.height / 2 },
|
|
153
|
-
depth: depthFor(box),
|
|
154
|
-
},
|
|
155
|
-
]
|
|
156
|
-
}
|
|
157
|
-
if (event.kind === 'type') {
|
|
158
|
-
const box = event.box
|
|
159
|
-
return [
|
|
160
|
-
{
|
|
161
|
-
t: event.t,
|
|
162
|
-
until: event.t + event.ms,
|
|
163
|
-
at: { x: box.x + box.width / 2, y: box.y + box.height / 2 },
|
|
164
|
-
depth: depthFor(box),
|
|
165
|
-
},
|
|
166
|
-
]
|
|
167
|
-
}
|
|
168
|
-
return []
|
|
169
|
-
})
|
|
137
|
+
const aims = (log.push === false ? [] : log.events)
|
|
138
|
+
.flatMap((event): Aim[] => aimsOf(event, depthFor, deepest))
|
|
170
139
|
.filter((aim) => aim.depth >= MIN_ZOOM)
|
|
171
140
|
.sort((a, b) => a.t - b.t)
|
|
172
141
|
|
|
173
|
-
const
|
|
142
|
+
const end = (log.frames * 1000) / log.fps
|
|
143
|
+
const zooms = zoomsOf(aims, log.events, { width, height }, end)
|
|
144
|
+
|
|
174
145
|
// Read off every press rather than off the aims, which drop a click too large
|
|
175
146
|
// to zoom and add a typing burst that is no press.
|
|
176
147
|
const presses = log.events.flatMap((event) =>
|
|
@@ -191,77 +162,19 @@ export function cameraTrack(
|
|
|
191
162
|
}
|
|
192
163
|
return null
|
|
193
164
|
}
|
|
194
|
-
const windows = zoomWindows(aims, scrolls)
|
|
195
|
-
|
|
196
|
-
const targetAt = (ms: number) => {
|
|
197
|
-
let best = { strength: 0, scale: 1, x: width / 2, y: height / 2 }
|
|
198
|
-
for (const window of windows) {
|
|
199
|
-
const strength = computeRegionStrength(
|
|
200
|
-
{ startMs: window.aims[0]?.t ?? window.start, endMs: window.end },
|
|
201
|
-
ms,
|
|
202
|
-
)
|
|
203
|
-
if (strength <= best.strength) continue
|
|
204
|
-
|
|
205
|
-
const focus = focusAt(window.aims, ms, width, height)
|
|
206
|
-
const half = {
|
|
207
|
-
x: width / (2 * focus.depth),
|
|
208
|
-
y: height / (2 * focus.depth),
|
|
209
|
-
}
|
|
210
|
-
const cursor = cursorAt(log.events, ms)
|
|
211
|
-
const x = clamp(
|
|
212
|
-
focus.x,
|
|
213
|
-
cursor.x - half.x * SAFE,
|
|
214
|
-
cursor.x + half.x * SAFE,
|
|
215
|
-
)
|
|
216
|
-
const y = clamp(
|
|
217
|
-
focus.y,
|
|
218
|
-
cursor.y - half.y * SAFE,
|
|
219
|
-
cursor.y + half.y * SAFE,
|
|
220
|
-
)
|
|
221
|
-
|
|
222
|
-
best = {
|
|
223
|
-
strength,
|
|
224
|
-
scale: lerp(1, focus.depth, strength),
|
|
225
|
-
x: lerp(width / 2, x, strength),
|
|
226
|
-
y: lerp(height / 2, y, strength),
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
return best
|
|
230
|
-
}
|
|
231
165
|
|
|
232
166
|
const frameMs = 1000 / log.fps
|
|
233
|
-
const springs: Record<'scale' | 'x' | 'y', SpringState> = {
|
|
234
|
-
scale: createSpringState(1),
|
|
235
|
-
x: createSpringState(width / 2),
|
|
236
|
-
y: createSpringState(height / 2),
|
|
237
|
-
}
|
|
238
|
-
|
|
239
167
|
const frames: CameraFrame[] = []
|
|
240
168
|
for (let index = 0; index < log.frames; index += 1) {
|
|
241
169
|
const ms = index * frameMs
|
|
242
|
-
const
|
|
243
|
-
const scale = Math.max(1, stepSpring(springs.scale, target.scale, frameMs))
|
|
244
|
-
const half = { x: width / (2 * scale), y: height / (2 * scale) }
|
|
245
|
-
const cursor = cursorAt(log.events, ms)
|
|
246
|
-
// Written back so the spring carries on from where the view was pushed.
|
|
247
|
-
springs.x.value = clamp(
|
|
248
|
-
stepSpring(springs.x, target.x, frameMs),
|
|
249
|
-
cursor.x - half.x * EDGE,
|
|
250
|
-
cursor.x + half.x * EDGE,
|
|
251
|
-
)
|
|
252
|
-
springs.y.value = clamp(
|
|
253
|
-
stepSpring(springs.y, target.y, frameMs),
|
|
254
|
-
cursor.y - half.y * EDGE,
|
|
255
|
-
cursor.y + half.y * EDGE,
|
|
256
|
-
)
|
|
257
|
-
|
|
170
|
+
const { scale, anchor } = viewAt(zooms, ms)
|
|
258
171
|
const ring = ringAt(ms)
|
|
259
172
|
frames.push({
|
|
260
173
|
ms: round(ms, 2),
|
|
261
174
|
scale: round(scale, 4),
|
|
262
|
-
x: round(
|
|
263
|
-
y: round(
|
|
264
|
-
cursor: roundPoint(
|
|
175
|
+
x: round(originFor(anchor.x, scale, width), 2),
|
|
176
|
+
y: round(originFor(anchor.y, scale, height), 2),
|
|
177
|
+
cursor: roundPoint(cursorAt(log.events, ms)),
|
|
265
178
|
ring: ring === null ? null : round(ring, 3),
|
|
266
179
|
})
|
|
267
180
|
}
|
|
@@ -280,97 +193,313 @@ export function readRate(value: string): number | undefined {
|
|
|
280
193
|
return Number.isFinite(rate) && rate > 0 ? rate : undefined
|
|
281
194
|
}
|
|
282
195
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
196
|
+
function aimsOf(
|
|
197
|
+
event: LogEvent,
|
|
198
|
+
depthFor: (box: Box) => number,
|
|
199
|
+
deepest: number,
|
|
200
|
+
): Aim[] {
|
|
201
|
+
switch (event.kind) {
|
|
202
|
+
case 'click':
|
|
203
|
+
return [
|
|
204
|
+
{
|
|
205
|
+
t: event.t,
|
|
206
|
+
until: event.t,
|
|
207
|
+
at: event.at,
|
|
208
|
+
box: event.box,
|
|
209
|
+
depth: depthFor(event.box),
|
|
210
|
+
},
|
|
211
|
+
]
|
|
212
|
+
case 'drag': {
|
|
213
|
+
// One aim over both ends, held to the release, so a short drag stays
|
|
214
|
+
// framed whole and a long one asks for a depth under MIN_ZOOM.
|
|
215
|
+
const box = union(event.box, event.drop)
|
|
216
|
+
return [
|
|
217
|
+
{
|
|
218
|
+
t: event.t,
|
|
219
|
+
until: event.t + event.ms,
|
|
220
|
+
at: centerOf(box),
|
|
221
|
+
box,
|
|
222
|
+
depth: depthFor(box),
|
|
223
|
+
},
|
|
224
|
+
]
|
|
225
|
+
}
|
|
226
|
+
case 'type':
|
|
227
|
+
return [
|
|
228
|
+
{
|
|
229
|
+
t: event.t,
|
|
230
|
+
until: event.t + event.ms,
|
|
231
|
+
at: centerOf(event.box),
|
|
232
|
+
box: event.box,
|
|
233
|
+
depth: depthFor(event.box),
|
|
234
|
+
},
|
|
235
|
+
]
|
|
236
|
+
case 'move': {
|
|
237
|
+
if (event.hold === undefined) return []
|
|
238
|
+
// A move logs no box, so a hold aims at the full depth.
|
|
239
|
+
const arrived = event.t + event.ms
|
|
240
|
+
return [
|
|
241
|
+
{
|
|
242
|
+
t: arrived,
|
|
243
|
+
until: arrived + event.hold,
|
|
244
|
+
at: event.to,
|
|
245
|
+
box: { ...event.to, width: 0, height: 0 },
|
|
246
|
+
depth: deepest,
|
|
247
|
+
},
|
|
248
|
+
]
|
|
249
|
+
}
|
|
250
|
+
default:
|
|
251
|
+
return []
|
|
295
252
|
}
|
|
296
253
|
}
|
|
297
254
|
|
|
298
255
|
/**
|
|
299
|
-
* Groups aims into zooms
|
|
300
|
-
*
|
|
301
|
-
*
|
|
256
|
+
* Groups aims into zooms and times each one off the glides around it. The next
|
|
257
|
+
* aim joins a zoom while at most one glide and no scroll lies between them,
|
|
258
|
+
* since that glide then both leaves one target and reaches the other, and the
|
|
259
|
+
* view pans along it rather than pulling back and pushing in at once.
|
|
302
260
|
*/
|
|
303
|
-
function
|
|
261
|
+
function zoomsOf(
|
|
304
262
|
aims: readonly Aim[],
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
263
|
+
events: readonly LogEvent[],
|
|
264
|
+
page: { readonly width: number; readonly height: number },
|
|
265
|
+
end: number,
|
|
266
|
+
): Zoom[] {
|
|
267
|
+
const glides = events.flatMap((event): Span[] =>
|
|
268
|
+
event.kind === 'move' && event.ms > 0
|
|
269
|
+
? [{ start: event.t, end: event.t + event.ms }]
|
|
270
|
+
: [],
|
|
271
|
+
)
|
|
272
|
+
const scrolls = events
|
|
273
|
+
.filter((event) => event.kind === 'scroll')
|
|
274
|
+
.map((event) => event.t)
|
|
275
|
+
const glidesIn = (from: number, to: number) =>
|
|
276
|
+
glides.filter((glide) => glide.start >= from && glide.end <= to)
|
|
312
277
|
|
|
313
|
-
|
|
314
|
-
|
|
278
|
+
const groups: Aim[][] = []
|
|
279
|
+
for (const aim of aims) {
|
|
280
|
+
const group = groups.at(-1)
|
|
281
|
+
const last = group?.at(-1)
|
|
282
|
+
const joins =
|
|
283
|
+
group !== undefined &&
|
|
315
284
|
last !== undefined &&
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
285
|
+
glidesIn(last.until, aim.t).length <= 1 &&
|
|
286
|
+
!scrolls.some((t) => t >= last.until && t <= aim.t)
|
|
287
|
+
if (joins) group.push(aim)
|
|
288
|
+
else groups.push([aim])
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const zooms: Zoom[] = []
|
|
292
|
+
for (const group of groups) {
|
|
293
|
+
const first = group[0]
|
|
294
|
+
const last = group.at(-1)
|
|
295
|
+
if (!first || !last) continue
|
|
296
|
+
const depth = Math.min(...group.map((aim) => aim.depth))
|
|
297
|
+
const floor = zooms.at(-1)?.out.end ?? 0
|
|
298
|
+
|
|
299
|
+
const into = arriving(glides, first.t, floor) ?? {
|
|
300
|
+
start: Math.max(floor, first.t - EASE_MS),
|
|
301
|
+
end: first.t,
|
|
326
302
|
}
|
|
303
|
+
|
|
304
|
+
const stops = [
|
|
305
|
+
{
|
|
306
|
+
at: framed(first, depth, page),
|
|
307
|
+
pan: { start: into.end, end: into.end },
|
|
308
|
+
},
|
|
309
|
+
]
|
|
310
|
+
let previous = first
|
|
311
|
+
for (const aim of group.slice(1)) {
|
|
312
|
+
const stop = stops.at(-1) ?? stops[0]
|
|
313
|
+
if (stop && !inside(aim, stop.at, depth, page)) {
|
|
314
|
+
stops.push({
|
|
315
|
+
at: framed(aim, depth, page),
|
|
316
|
+
pan: arriving(glides, aim.t, previous.until) ?? {
|
|
317
|
+
start: Math.max(previous.until, aim.t - EASE_MS),
|
|
318
|
+
end: aim.t,
|
|
319
|
+
},
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
previous = aim
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const leave = glides.find((glide) => glide.start >= last.until)
|
|
326
|
+
const cut = scrolls.find((t) => t >= last.until)
|
|
327
|
+
const out =
|
|
328
|
+
leave && (cut === undefined || leave.start <= cut)
|
|
329
|
+
? leave
|
|
330
|
+
: {
|
|
331
|
+
start: Math.min(cut ?? Infinity, last.until + HOLD_MS, end),
|
|
332
|
+
end: Math.min(cut ?? Infinity, last.until + HOLD_MS, end) + EASE_MS,
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
zooms.push({ depth, into, stops, out })
|
|
327
336
|
}
|
|
328
|
-
return
|
|
337
|
+
return zooms
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** The glide ending as an aim starts, when one does after `floor`. */
|
|
341
|
+
function arriving(
|
|
342
|
+
glides: readonly Span[],
|
|
343
|
+
t: number,
|
|
344
|
+
floor: number,
|
|
345
|
+
): Span | undefined {
|
|
346
|
+
return glides.findLast(
|
|
347
|
+
(glide) => glide.start >= floor && glide.end <= t + 0.5,
|
|
348
|
+
)
|
|
329
349
|
}
|
|
330
350
|
|
|
331
351
|
/**
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
* it pans to each stop on OpenScreen's connected-pan curve, arriving as the
|
|
335
|
-
* click lands.
|
|
352
|
+
* Whether an aim lies near enough the stop, with its whole box far enough
|
|
353
|
+
* inside the view anchored on it, that the view can stay where it is.
|
|
336
354
|
*/
|
|
337
|
-
function
|
|
338
|
-
|
|
355
|
+
function inside(
|
|
356
|
+
aim: Aim,
|
|
357
|
+
stop: Point,
|
|
358
|
+
depth: number,
|
|
359
|
+
page: { readonly width: number; readonly height: number },
|
|
360
|
+
): boolean {
|
|
361
|
+
const near = (
|
|
362
|
+
value: number,
|
|
363
|
+
low: number,
|
|
364
|
+
high: number,
|
|
365
|
+
anchor: number,
|
|
366
|
+
size: number,
|
|
367
|
+
) => {
|
|
368
|
+
const span = size / depth
|
|
369
|
+
const origin = originFor(anchor, depth, size)
|
|
370
|
+
const margin = span * MARGIN
|
|
371
|
+
return (
|
|
372
|
+
Math.abs(value - anchor) < (span / 2) * STICKY &&
|
|
373
|
+
low > origin + margin &&
|
|
374
|
+
high < origin + span - margin
|
|
375
|
+
)
|
|
376
|
+
}
|
|
377
|
+
const { box } = aim
|
|
378
|
+
return (
|
|
379
|
+
near(aim.at.x, box.x, box.x + box.width, stop.x, page.width) &&
|
|
380
|
+
near(aim.at.y, box.y, box.y + box.height, stop.y, page.height)
|
|
381
|
+
)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* The point a zoom onto `aim` scales about: its own point where the view
|
|
386
|
+
* anchored there keeps the aim's box in frame, and otherwise the nearest point
|
|
387
|
+
* that does, so both ends of a drag near the page's edge stay in view.
|
|
388
|
+
*/
|
|
389
|
+
function framed(
|
|
390
|
+
aim: Aim,
|
|
391
|
+
depth: number,
|
|
392
|
+
page: { readonly width: number; readonly height: number },
|
|
393
|
+
): Point {
|
|
394
|
+
const axis = (at: number, low: number, high: number, size: number) => {
|
|
395
|
+
const span = size / depth
|
|
396
|
+
const k = 1 - 1 / depth
|
|
397
|
+
const margin = Math.min(
|
|
398
|
+
span * MARGIN,
|
|
399
|
+
Math.max(0, (span - (high - low)) / 2),
|
|
400
|
+
)
|
|
401
|
+
const least = (high + margin - span) / k
|
|
402
|
+
const most = (low - margin) / k
|
|
403
|
+
if (least > most) return ((low + high) / 2 - span / 2) / k
|
|
404
|
+
return clamp(at, least, most)
|
|
405
|
+
}
|
|
406
|
+
const { box } = aim
|
|
407
|
+
return {
|
|
408
|
+
x: axis(aim.at.x, box.x, box.x + box.width, page.width),
|
|
409
|
+
y: axis(aim.at.y, box.y, box.y + box.height, page.height),
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* The scale and the page point held still on screen at `ms`. Inside a zoom
|
|
415
|
+
* the anchor is a stop while the scale changes and slides between stops only
|
|
416
|
+
* while it holds, so the view never pans and scales at once.
|
|
417
|
+
*/
|
|
418
|
+
function viewAt(
|
|
419
|
+
zooms: readonly Zoom[],
|
|
339
420
|
ms: number,
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
421
|
+
): { scale: number; anchor: Point } {
|
|
422
|
+
const rest = { scale: 1, anchor: { x: 0, y: 0 } }
|
|
423
|
+
const zoom = zooms.findLast((each) => ms >= each.into.start)
|
|
424
|
+
if (!zoom || ms > zoom.out.end) return rest
|
|
425
|
+
|
|
426
|
+
const first = zoom.stops[0]
|
|
427
|
+
const last = zoom.stops.at(-1)
|
|
428
|
+
if (!first || !last) return rest
|
|
429
|
+
const lift = zoom.depth - 1
|
|
430
|
+
|
|
431
|
+
if (ms < zoom.into.end) {
|
|
432
|
+
return {
|
|
433
|
+
scale: 1 + lift * easeInOutCubic(progress(zoom.into, ms)),
|
|
434
|
+
anchor: first.at,
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
if (ms >= zoom.out.start) {
|
|
438
|
+
return {
|
|
439
|
+
scale: 1 + lift * (1 - easeInOutCubic(progress(zoom.out, ms))),
|
|
440
|
+
anchor: last.at,
|
|
441
|
+
}
|
|
353
442
|
}
|
|
354
443
|
|
|
355
|
-
let
|
|
356
|
-
for (
|
|
357
|
-
|
|
358
|
-
const
|
|
359
|
-
const
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
const k = easeConnectedPan(
|
|
364
|
-
(ms - panStart) / CONNECTED_ZOOM_PAN_DURATION_MS,
|
|
365
|
-
)
|
|
366
|
-
focus = {
|
|
367
|
-
x: lerp(from.at.x, to.at.x, k),
|
|
368
|
-
y: lerp(from.at.y, to.at.y, k),
|
|
369
|
-
depth: lerp(from.depth, to.depth, k),
|
|
370
|
-
}
|
|
444
|
+
let anchor = first.at
|
|
445
|
+
for (const [i, stop] of zoom.stops.entries()) {
|
|
446
|
+
if (i === 0 || ms < stop.pan.start) continue
|
|
447
|
+
const from = zoom.stops[i - 1]?.at ?? first.at
|
|
448
|
+
const k = easeInOutCubic(progress(stop.pan, ms))
|
|
449
|
+
anchor = {
|
|
450
|
+
x: from.x + (stop.at.x - from.x) * k,
|
|
451
|
+
y: from.y + (stop.at.y - from.y) * k,
|
|
371
452
|
}
|
|
372
453
|
}
|
|
373
|
-
return
|
|
454
|
+
return { scale: zoom.depth, anchor }
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function progress(span: Span, ms: number): number {
|
|
458
|
+
return span.end > span.start ? (ms - span.start) / (span.end - span.start) : 1
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* The view's top-left along one axis when it is zoomed `scale` about `anchor`,
|
|
463
|
+
* which leaves the anchor where it sits on the unzoomed page.
|
|
464
|
+
*/
|
|
465
|
+
function originFor(anchor: number, scale: number, size: number): number {
|
|
466
|
+
const origin = anchor * (1 - 1 / scale)
|
|
467
|
+
return clamp(origin, 0, size - size / scale)
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** The log as a clip played `rate` times faster shows it, every time divided. */
|
|
471
|
+
function atRate(log: TakeLog, rate: number): TakeLog {
|
|
472
|
+
if (rate === 1) return log
|
|
473
|
+
return {
|
|
474
|
+
...log,
|
|
475
|
+
frames: Math.round(log.frames / rate),
|
|
476
|
+
events: log.events.map((event): LogEvent => {
|
|
477
|
+
const t = event.t / rate
|
|
478
|
+
if (event.kind === 'move') {
|
|
479
|
+
return {
|
|
480
|
+
...event,
|
|
481
|
+
t,
|
|
482
|
+
ms: event.ms / rate,
|
|
483
|
+
...(event.points
|
|
484
|
+
? {
|
|
485
|
+
points: event.points.map((point) => ({
|
|
486
|
+
...point,
|
|
487
|
+
t: point.t / rate,
|
|
488
|
+
})),
|
|
489
|
+
}
|
|
490
|
+
: {}),
|
|
491
|
+
...(event.hold !== undefined ? { hold: event.hold / rate } : {}),
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
return 'ms' in event
|
|
495
|
+
? { ...event, t, ms: event.ms / rate }
|
|
496
|
+
: { ...event, t }
|
|
497
|
+
}),
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function centerOf(box: Box): Point {
|
|
502
|
+
return { x: box.x + box.width / 2, y: box.y + box.height / 2 }
|
|
374
503
|
}
|
|
375
504
|
|
|
376
505
|
function union(a: Box, b: Box): Box {
|
package/src/take/film.ts
CHANGED
|
@@ -172,7 +172,12 @@ async function filmBeat(
|
|
|
172
172
|
|
|
173
173
|
let route: Route = beat.route
|
|
174
174
|
let fallback: string | undefined
|
|
175
|
-
let filmed: {
|
|
175
|
+
let filmed: {
|
|
176
|
+
frames: number
|
|
177
|
+
events: readonly LogEvent[]
|
|
178
|
+
lag?: number
|
|
179
|
+
still: Buffer
|
|
180
|
+
}
|
|
176
181
|
if (beat.route === 'step') {
|
|
177
182
|
try {
|
|
178
183
|
filmed = await filmStepped({
|
|
@@ -203,6 +208,7 @@ async function filmBeat(
|
|
|
203
208
|
route,
|
|
204
209
|
clip: CLIP_SIZES[route],
|
|
205
210
|
...(beat.push === false ? { push: false as const } : {}),
|
|
211
|
+
...(filmed.lag === undefined ? {} : { lag: filmed.lag }),
|
|
206
212
|
frames: filmed.frames,
|
|
207
213
|
events: filmed.events,
|
|
208
214
|
}
|