@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,207 @@
|
|
|
1
|
+
import { type ChildProcess, spawn } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Joins a take's frames into an H.264 clip through the system FFmpeg. Canon
|
|
5
|
+
* ships no encoder, so FFmpeg is a setup step a target runs, and its absence is
|
|
6
|
+
* refused up front rather than after a take has been filmed.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export class FfmpegMissing extends Error {}
|
|
10
|
+
|
|
11
|
+
/** H.264 in yuv420p plays in every browser and editor HyperFrames reads from. */
|
|
12
|
+
const OUTPUT = [
|
|
13
|
+
'-c:v',
|
|
14
|
+
'libx264',
|
|
15
|
+
'-preset',
|
|
16
|
+
'medium',
|
|
17
|
+
'-crf',
|
|
18
|
+
'16',
|
|
19
|
+
'-pix_fmt',
|
|
20
|
+
'yuv420p',
|
|
21
|
+
'-movflags',
|
|
22
|
+
'+faststart',
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
/** Resolves when `ffmpeg` runs, and throws `FfmpegMissing` when it does not. */
|
|
26
|
+
export async function requireFfmpeg(): Promise<void> {
|
|
27
|
+
const proc = spawn('ffmpeg', ['-version'], { stdio: 'ignore' })
|
|
28
|
+
try {
|
|
29
|
+
await finished(proc)
|
|
30
|
+
} catch (error) {
|
|
31
|
+
throw new FfmpegMissing(
|
|
32
|
+
`ffmpeg did not run: ${error instanceof Error ? error.message : String(error)}`,
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface FrameEncoder {
|
|
38
|
+
/** Appends one JPEG frame, waiting while FFmpeg catches up. */
|
|
39
|
+
write(jpeg: Buffer): Promise<void>
|
|
40
|
+
/** Ends the stream and resolves once the clip is written. */
|
|
41
|
+
close(): Promise<void>
|
|
42
|
+
/** Stops FFmpeg and leaves whatever it wrote. */
|
|
43
|
+
abort(): void
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Streams JPEG frames into one clip at `fps`, so a 4K take never lands on disk
|
|
48
|
+
* as a folder of stills.
|
|
49
|
+
*/
|
|
50
|
+
export function openFrameEncoder(out: string, fps: number): FrameEncoder {
|
|
51
|
+
const proc = spawn(
|
|
52
|
+
'ffmpeg',
|
|
53
|
+
[
|
|
54
|
+
'-y',
|
|
55
|
+
'-loglevel',
|
|
56
|
+
'error',
|
|
57
|
+
'-f',
|
|
58
|
+
'image2pipe',
|
|
59
|
+
'-framerate',
|
|
60
|
+
String(fps),
|
|
61
|
+
'-c:v',
|
|
62
|
+
'mjpeg',
|
|
63
|
+
'-i',
|
|
64
|
+
'-',
|
|
65
|
+
// A JPEG is full range and H.264 is read as TV range, so without this the
|
|
66
|
+
// levels pass through unscaled and a player crushes the blacks and whites.
|
|
67
|
+
'-vf',
|
|
68
|
+
'scale=in_range=full:out_range=tv',
|
|
69
|
+
...OUTPUT,
|
|
70
|
+
out,
|
|
71
|
+
],
|
|
72
|
+
{ stdio: ['pipe', 'ignore', 'pipe'] },
|
|
73
|
+
)
|
|
74
|
+
const done = finished(proc)
|
|
75
|
+
// Held so a failure while frames are still being written surfaces at close
|
|
76
|
+
// rather than as an unhandled rejection.
|
|
77
|
+
done.catch(() => {})
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
write: (jpeg) =>
|
|
81
|
+
new Promise((resolve, reject) => {
|
|
82
|
+
const stdin = proc.stdin
|
|
83
|
+
if (!stdin || stdin.destroyed) {
|
|
84
|
+
reject(new Error('ffmpeg stopped reading frames'))
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
if (stdin.write(jpeg)) resolve()
|
|
88
|
+
else stdin.once('drain', resolve)
|
|
89
|
+
}),
|
|
90
|
+
close: async () => {
|
|
91
|
+
proc.stdin?.end()
|
|
92
|
+
await done
|
|
93
|
+
},
|
|
94
|
+
abort: () => {
|
|
95
|
+
proc.kill('SIGKILL')
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Re-encodes a recorded clip to the same H.264 output at a constant `fps`. */
|
|
101
|
+
export async function transcode(
|
|
102
|
+
input: string,
|
|
103
|
+
out: string,
|
|
104
|
+
fps: number,
|
|
105
|
+
): Promise<void> {
|
|
106
|
+
const proc = spawn(
|
|
107
|
+
'ffmpeg',
|
|
108
|
+
[
|
|
109
|
+
'-y',
|
|
110
|
+
'-loglevel',
|
|
111
|
+
'error',
|
|
112
|
+
'-i',
|
|
113
|
+
input,
|
|
114
|
+
'-r',
|
|
115
|
+
String(fps),
|
|
116
|
+
...OUTPUT,
|
|
117
|
+
out,
|
|
118
|
+
],
|
|
119
|
+
{ stdio: ['ignore', 'ignore', 'pipe'] },
|
|
120
|
+
)
|
|
121
|
+
await finished(proc)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Counts a clip's frames, which a real-time take cannot know in advance and a
|
|
126
|
+
* camera track has to match one for one.
|
|
127
|
+
*/
|
|
128
|
+
export async function probeFrames(clip: string): Promise<number> {
|
|
129
|
+
const proc = spawn(
|
|
130
|
+
'ffprobe',
|
|
131
|
+
[
|
|
132
|
+
'-v',
|
|
133
|
+
'error',
|
|
134
|
+
'-select_streams',
|
|
135
|
+
'v:0',
|
|
136
|
+
'-count_packets',
|
|
137
|
+
'-show_entries',
|
|
138
|
+
'stream=nb_read_packets',
|
|
139
|
+
'-of',
|
|
140
|
+
'csv=p=0',
|
|
141
|
+
clip,
|
|
142
|
+
],
|
|
143
|
+
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
|
144
|
+
)
|
|
145
|
+
let stdout = ''
|
|
146
|
+
proc.stdout?.on('data', (chunk: Buffer) => {
|
|
147
|
+
stdout += chunk.toString()
|
|
148
|
+
})
|
|
149
|
+
await finished(proc)
|
|
150
|
+
const frames = Number.parseInt(stdout.trim(), 10)
|
|
151
|
+
if (!Number.isFinite(frames)) {
|
|
152
|
+
throw new Error(`ffprobe read no frame count from ${clip}`)
|
|
153
|
+
}
|
|
154
|
+
return frames
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Reads the clip's closing frame as one JPEG. It selects the frame by index
|
|
159
|
+
* rather than seeking from the end, since a seek lands on whichever frame
|
|
160
|
+
* the container's timing puts nearest, which was measured missing the last.
|
|
161
|
+
*/
|
|
162
|
+
export async function lastFrame(clip: string, frames: number): Promise<Buffer> {
|
|
163
|
+
const proc = spawn(
|
|
164
|
+
'ffmpeg',
|
|
165
|
+
[
|
|
166
|
+
'-v',
|
|
167
|
+
'error',
|
|
168
|
+
'-i',
|
|
169
|
+
clip,
|
|
170
|
+
'-vf',
|
|
171
|
+
`select=eq(n\\,${frames - 1})`,
|
|
172
|
+
'-frames:v',
|
|
173
|
+
'1',
|
|
174
|
+
'-q:v',
|
|
175
|
+
'2',
|
|
176
|
+
'-f',
|
|
177
|
+
'image2pipe',
|
|
178
|
+
'-c:v',
|
|
179
|
+
'mjpeg',
|
|
180
|
+
'-',
|
|
181
|
+
],
|
|
182
|
+
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
|
183
|
+
)
|
|
184
|
+
const chunks: Buffer[] = []
|
|
185
|
+
proc.stdout?.on('data', (chunk: Buffer) => chunks.push(chunk))
|
|
186
|
+
await finished(proc)
|
|
187
|
+
const jpeg = Buffer.concat(chunks)
|
|
188
|
+
if (jpeg.length === 0) {
|
|
189
|
+
throw new Error(`ffmpeg read no frame ${frames - 1} from ${clip}`)
|
|
190
|
+
}
|
|
191
|
+
return jpeg
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function finished(proc: ChildProcess): Promise<void> {
|
|
195
|
+
let stderr = ''
|
|
196
|
+
proc.stderr?.on('data', (chunk: Buffer) => {
|
|
197
|
+
stderr += chunk.toString()
|
|
198
|
+
})
|
|
199
|
+
return new Promise((resolve, reject) => {
|
|
200
|
+
proc.on('error', reject)
|
|
201
|
+
proc.on('close', (code) => {
|
|
202
|
+
if (code === 0) resolve()
|
|
203
|
+
else
|
|
204
|
+
reject(new Error(`ffmpeg exited ${code}: ${stderr.trim().slice(-400)}`))
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
}
|
package/src/take/film.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join, resolve } from 'node:path'
|
|
3
|
+
import { isBrowserMissing, isServerUnreachable } from '@/browser/engine'
|
|
4
|
+
import { cameraTrack } from '@/take/camera'
|
|
5
|
+
import { FfmpegMissing, requireFfmpeg } from '@/take/encode'
|
|
6
|
+
import {
|
|
7
|
+
FPS,
|
|
8
|
+
LOG_VERSION,
|
|
9
|
+
type LogEvent,
|
|
10
|
+
type TakeLog,
|
|
11
|
+
VIEWPORT,
|
|
12
|
+
} from '@/take/log'
|
|
13
|
+
import { TargetMissing } from '@/take/perform'
|
|
14
|
+
import type { Beat, Route, TakePlan } from '@/take/plan'
|
|
15
|
+
import { filmScreencast } from '@/take/screencast'
|
|
16
|
+
import { ShellMissing, findShell } from '@/take/shell'
|
|
17
|
+
import { StepperStalled, filmStepped } from '@/take/stepper'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Films every beat of a plan into its own folder and writes the record the
|
|
21
|
+
* edit reads. Each beat opens the page fresh, so a beat starts from the state
|
|
22
|
+
* its setup reaches rather than wherever the last one left the page.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export const TAKE_VERSION = 1
|
|
26
|
+
|
|
27
|
+
export type TakeRefusal =
|
|
28
|
+
| 'ffmpeg-missing'
|
|
29
|
+
| 'browser-missing'
|
|
30
|
+
| 'server-unreachable'
|
|
31
|
+
| 'target-missing'
|
|
32
|
+
| 'take-failed'
|
|
33
|
+
|
|
34
|
+
export interface FilmedBeat {
|
|
35
|
+
readonly name: string
|
|
36
|
+
/** The beat's folder, relative to the take's. */
|
|
37
|
+
readonly dir: string
|
|
38
|
+
readonly requested: Route
|
|
39
|
+
readonly route: Route
|
|
40
|
+
/** Why a `step` beat was filmed by screencast instead, when it was. */
|
|
41
|
+
readonly fallback?: string
|
|
42
|
+
readonly frames: number
|
|
43
|
+
readonly seconds: number
|
|
44
|
+
readonly clip: string
|
|
45
|
+
readonly log: string
|
|
46
|
+
readonly camera: string
|
|
47
|
+
/** The clip's closing frame, as a JPEG. */
|
|
48
|
+
readonly still: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type TakeResult =
|
|
52
|
+
| {
|
|
53
|
+
readonly status: 'filmed'
|
|
54
|
+
readonly out: string
|
|
55
|
+
readonly beats: readonly FilmedBeat[]
|
|
56
|
+
readonly durationMs: number
|
|
57
|
+
}
|
|
58
|
+
| {
|
|
59
|
+
readonly status: 'failed'
|
|
60
|
+
readonly reason: TakeRefusal
|
|
61
|
+
readonly message: string
|
|
62
|
+
readonly beat?: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface TakeRequest {
|
|
66
|
+
readonly url: string
|
|
67
|
+
readonly plan: TakePlan
|
|
68
|
+
readonly out: string
|
|
69
|
+
readonly env?: NodeJS.ProcessEnv
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const REACH_TIMEOUT_MS = 5000
|
|
73
|
+
|
|
74
|
+
export async function take(request: TakeRequest): Promise<TakeResult> {
|
|
75
|
+
const started = Date.now()
|
|
76
|
+
const out = resolve(request.out)
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
await requireFfmpeg()
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (error instanceof FfmpegMissing) return failed('ffmpeg-missing', error)
|
|
82
|
+
throw error
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let executable = ''
|
|
86
|
+
if (request.plan.beats.some((beat) => beat.route === 'step')) {
|
|
87
|
+
try {
|
|
88
|
+
executable = findShell(request.env)
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (error instanceof ShellMissing) return failed('browser-missing', error)
|
|
91
|
+
throw error
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Probes the origin rather than the address, since an address can carry a
|
|
96
|
+
// single-use ticket that a probe would spend before the browser opens it.
|
|
97
|
+
try {
|
|
98
|
+
await fetch(new URL(request.url).origin, {
|
|
99
|
+
signal: AbortSignal.timeout(REACH_TIMEOUT_MS),
|
|
100
|
+
})
|
|
101
|
+
} catch (error) {
|
|
102
|
+
return failed('server-unreachable', error)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
mkdirSync(out, { recursive: true })
|
|
106
|
+
const beats: FilmedBeat[] = []
|
|
107
|
+
for (const [index, beat] of request.plan.beats.entries()) {
|
|
108
|
+
const dir = `${String(index + 1).padStart(2, '0')}-${slug(beat.name)}`
|
|
109
|
+
mkdirSync(join(out, dir), { recursive: true })
|
|
110
|
+
try {
|
|
111
|
+
beats.push(await filmBeat(request.url, beat, out, dir, executable))
|
|
112
|
+
} catch (error) {
|
|
113
|
+
return failed(classify(error), error, beat.name)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
writeFileSync(
|
|
118
|
+
join(out, 'take.json'),
|
|
119
|
+
`${JSON.stringify({ version: TAKE_VERSION, url: request.url, beats }, null, 2)}\n`,
|
|
120
|
+
)
|
|
121
|
+
return { status: 'filmed', out, beats, durationMs: Date.now() - started }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function filmBeat(
|
|
125
|
+
url: string,
|
|
126
|
+
beat: Beat,
|
|
127
|
+
out: string,
|
|
128
|
+
dir: string,
|
|
129
|
+
executable: string,
|
|
130
|
+
): Promise<FilmedBeat> {
|
|
131
|
+
const clip = join(dir, 'clip.mp4')
|
|
132
|
+
const target = join(out, clip)
|
|
133
|
+
|
|
134
|
+
let route: Route = beat.route
|
|
135
|
+
let fallback: string | undefined
|
|
136
|
+
let filmed: { frames: number; events: readonly LogEvent[]; still: Buffer }
|
|
137
|
+
if (beat.route === 'step') {
|
|
138
|
+
try {
|
|
139
|
+
filmed = await filmStepped({ executable, url, beat, clip: target })
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (!(error instanceof StepperStalled)) throw error
|
|
142
|
+
route = 'screencast'
|
|
143
|
+
fallback = error.message
|
|
144
|
+
filmed = await filmScreencast({ url, beat, clip: target })
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
filmed = await filmScreencast({ url, beat, clip: target })
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const log: TakeLog = {
|
|
151
|
+
version: LOG_VERSION,
|
|
152
|
+
viewport: VIEWPORT,
|
|
153
|
+
fps: FPS,
|
|
154
|
+
route,
|
|
155
|
+
frames: filmed.frames,
|
|
156
|
+
events: filmed.events,
|
|
157
|
+
}
|
|
158
|
+
const logPath = join(dir, 'log.json')
|
|
159
|
+
writeFileSync(join(out, logPath), `${JSON.stringify(log, null, 2)}\n`)
|
|
160
|
+
// One line, since it carries a row per frame and nobody reads it by eye.
|
|
161
|
+
const cameraPath = join(dir, 'camera.json')
|
|
162
|
+
writeFileSync(join(out, cameraPath), `${JSON.stringify(cameraTrack(log))}\n`)
|
|
163
|
+
const stillPath = join(dir, 'still.jpg')
|
|
164
|
+
writeFileSync(join(out, stillPath), filmed.still)
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
name: beat.name,
|
|
168
|
+
dir,
|
|
169
|
+
requested: beat.route,
|
|
170
|
+
route,
|
|
171
|
+
...(fallback ? { fallback } : {}),
|
|
172
|
+
frames: filmed.frames,
|
|
173
|
+
seconds: Number((filmed.frames / FPS).toFixed(2)),
|
|
174
|
+
clip,
|
|
175
|
+
log: logPath,
|
|
176
|
+
camera: cameraPath,
|
|
177
|
+
still: stillPath,
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function classify(error: unknown): TakeRefusal {
|
|
182
|
+
if (error instanceof TargetMissing) return 'target-missing'
|
|
183
|
+
if (error instanceof ShellMissing || isBrowserMissing(error)) {
|
|
184
|
+
return 'browser-missing'
|
|
185
|
+
}
|
|
186
|
+
if (isServerUnreachable(error)) return 'server-unreachable'
|
|
187
|
+
return 'take-failed'
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function failed(
|
|
191
|
+
reason: TakeRefusal,
|
|
192
|
+
error: unknown,
|
|
193
|
+
beat?: string,
|
|
194
|
+
): TakeResult {
|
|
195
|
+
return {
|
|
196
|
+
status: 'failed',
|
|
197
|
+
reason,
|
|
198
|
+
message: error instanceof Error ? error.message : String(error),
|
|
199
|
+
...(beat ? { beat } : {}),
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function slug(name: string): string {
|
|
204
|
+
return (
|
|
205
|
+
name
|
|
206
|
+
.toLowerCase()
|
|
207
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
208
|
+
.replace(/^-|-$/g, '')
|
|
209
|
+
.slice(0, 40) || 'beat'
|
|
210
|
+
)
|
|
211
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<title>Fade</title>
|
|
6
|
+
<!--
|
|
7
|
+
A band that fades from black to white over exactly one second of page
|
|
8
|
+
time once the button is pressed, so its brightness frame by frame reads
|
|
9
|
+
how much page time each frame of a take advanced.
|
|
10
|
+
-->
|
|
11
|
+
<style>
|
|
12
|
+
body {
|
|
13
|
+
margin: 0;
|
|
14
|
+
background: #808080;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
#go {
|
|
18
|
+
position: absolute;
|
|
19
|
+
left: 100px;
|
|
20
|
+
top: 100px;
|
|
21
|
+
width: 200px;
|
|
22
|
+
height: 60px;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
#band {
|
|
26
|
+
position: absolute;
|
|
27
|
+
left: 0;
|
|
28
|
+
top: 300px;
|
|
29
|
+
width: 1920px;
|
|
30
|
+
height: 780px;
|
|
31
|
+
background-color: #000;
|
|
32
|
+
transition: background-color 1000ms linear;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
#band.lit {
|
|
36
|
+
background-color: #fff;
|
|
37
|
+
}
|
|
38
|
+
</style>
|
|
39
|
+
</head>
|
|
40
|
+
<body>
|
|
41
|
+
<button id="go">Go</button>
|
|
42
|
+
<div id="band"></div>
|
|
43
|
+
<script>
|
|
44
|
+
document.getElementById('go').addEventListener('click', () => {
|
|
45
|
+
document.getElementById('band').classList.add('lit')
|
|
46
|
+
})
|
|
47
|
+
</script>
|
|
48
|
+
</body>
|
|
49
|
+
</html>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<title>Fetch</title>
|
|
6
|
+
<!--
|
|
7
|
+
A button whose press starts a fetch the server answers slowly, the way
|
|
8
|
+
a page switch in an app loads what the next view shows. A take must keep
|
|
9
|
+
stepping frames while the fetch is in flight.
|
|
10
|
+
-->
|
|
11
|
+
<style>
|
|
12
|
+
body {
|
|
13
|
+
margin: 0;
|
|
14
|
+
background: #808080;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
#go {
|
|
18
|
+
position: absolute;
|
|
19
|
+
left: 100px;
|
|
20
|
+
top: 100px;
|
|
21
|
+
width: 200px;
|
|
22
|
+
height: 60px;
|
|
23
|
+
}
|
|
24
|
+
</style>
|
|
25
|
+
</head>
|
|
26
|
+
<body>
|
|
27
|
+
<button id="go">Load</button>
|
|
28
|
+
<p id="status">idle</p>
|
|
29
|
+
<script>
|
|
30
|
+
document.getElementById('go').addEventListener('click', () => {
|
|
31
|
+
document.getElementById('status').textContent = 'loading'
|
|
32
|
+
fetch('/slow').then(() => {
|
|
33
|
+
document.getElementById('status').textContent = 'loaded'
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
</script>
|
|
37
|
+
</body>
|
|
38
|
+
</html>
|
package/src/take/log.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { Route } from '@/take/plan'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The click log a take writes beside its clip, and the pointer path both
|
|
5
|
+
* capture routes move along and the camera reads back. Every coordinate is in
|
|
6
|
+
* CSS pixels of the page, and every time is in milliseconds on the clip's own
|
|
7
|
+
* clock, so frame `n` sits at `n * 1000 / FPS` whatever route filmed it.
|
|
8
|
+
*
|
|
9
|
+
* The path lives here rather than in a capture route because the edit draws the
|
|
10
|
+
* cursor from this log, so the route moving the real pointer and the reader
|
|
11
|
+
* redrawing it have to agree on one curve.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const LOG_VERSION = 1
|
|
15
|
+
|
|
16
|
+
export const FPS = 60
|
|
17
|
+
|
|
18
|
+
/** The page a take films, in CSS pixels. The stepper renders it at twice this. */
|
|
19
|
+
export const VIEWPORT = { width: 1920, height: 1080 } as const
|
|
20
|
+
|
|
21
|
+
export interface Point {
|
|
22
|
+
readonly x: number
|
|
23
|
+
readonly y: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface Box {
|
|
27
|
+
readonly x: number
|
|
28
|
+
readonly y: number
|
|
29
|
+
readonly width: number
|
|
30
|
+
readonly height: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type LogEvent =
|
|
34
|
+
| {
|
|
35
|
+
readonly t: number
|
|
36
|
+
readonly kind: 'move'
|
|
37
|
+
readonly from: Point
|
|
38
|
+
readonly to: Point
|
|
39
|
+
readonly ms: number
|
|
40
|
+
}
|
|
41
|
+
| {
|
|
42
|
+
readonly t: number
|
|
43
|
+
readonly kind: 'click'
|
|
44
|
+
readonly at: Point
|
|
45
|
+
readonly box: Box
|
|
46
|
+
}
|
|
47
|
+
| {
|
|
48
|
+
readonly t: number
|
|
49
|
+
readonly kind: 'type'
|
|
50
|
+
readonly at: Point
|
|
51
|
+
readonly box: Box
|
|
52
|
+
readonly chars: number
|
|
53
|
+
/** From the first key to the last, so the camera holds for the burst. */
|
|
54
|
+
readonly ms: number
|
|
55
|
+
}
|
|
56
|
+
| {
|
|
57
|
+
readonly t: number
|
|
58
|
+
readonly kind: 'scroll'
|
|
59
|
+
readonly at: Point
|
|
60
|
+
readonly dx: number
|
|
61
|
+
readonly dy: number
|
|
62
|
+
readonly ms: number
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface TakeLog {
|
|
66
|
+
readonly version: typeof LOG_VERSION
|
|
67
|
+
readonly viewport: { readonly width: number; readonly height: number }
|
|
68
|
+
readonly fps: number
|
|
69
|
+
/** The route that filmed the clip, which can differ from the one asked for. */
|
|
70
|
+
readonly route: Route
|
|
71
|
+
readonly frames: number
|
|
72
|
+
readonly events: readonly LogEvent[]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Where the pointer rests before a take's first move. */
|
|
76
|
+
export const START: Point = { x: VIEWPORT.width / 2, y: VIEWPORT.height / 2 }
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* How long the pointer takes to travel, slower for longer reaches and never so
|
|
80
|
+
* quick that a viewer loses it. Carried over from the groundwork takes the
|
|
81
|
+
* camera presets were judged on.
|
|
82
|
+
*/
|
|
83
|
+
export function travelMs(from: Point, to: Point): number {
|
|
84
|
+
return Math.min(
|
|
85
|
+
1200,
|
|
86
|
+
Math.max(300, Math.hypot(to.x - from.x, to.y - from.y) * 0.9),
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function easeInOutCubic(t: number): number {
|
|
91
|
+
const x = Math.max(0, Math.min(1, t))
|
|
92
|
+
return x < 0.5 ? 4 * x * x * x : 1 - (-2 * x + 2) ** 3 / 2
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function along(from: Point, to: Point, progress: number): Point {
|
|
96
|
+
const k = easeInOutCubic(progress)
|
|
97
|
+
return { x: from.x + (to.x - from.x) * k, y: from.y + (to.y - from.y) * k }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The pointer's position at a time on the clip's clock, read off the moves. */
|
|
101
|
+
export function cursorAt(events: readonly LogEvent[], ms: number): Point {
|
|
102
|
+
let position = START
|
|
103
|
+
for (const event of events) {
|
|
104
|
+
if (event.kind !== 'move') continue
|
|
105
|
+
if (ms < event.t) break
|
|
106
|
+
position = along(event.from, event.to, (ms - event.t) / event.ms)
|
|
107
|
+
}
|
|
108
|
+
return position
|
|
109
|
+
}
|