@erclx/canon 5.67.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 +12 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- 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/ui-record/REQUIREMENT.md +5 -3
- package/claude/skills/ui-record/SKILL.md +21 -10
- package/claude/skills/ui-record/references/handoff.md +25 -6
- package/claude/skills/ui-record/references/pr-clip.md +84 -0
- package/claude/skills/ui-record/references/take-craft.md +7 -0
- package/docs/agents/records.md +1 -0
- package/package.json +5 -2
- package/src/commands/pr.ts +237 -79
- package/src/commands/take.ts +44 -8
- 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/take/encode.ts +38 -1
- package/src/take/film.ts +6 -1
- package/src/take/pointer.ts +168 -0
- package/src/take/reel.ts +215 -0
- package/src/take/screencast.ts +5 -2
- package/src/take/stepper.ts +4 -1
package/src/records/backup.ts
CHANGED
|
@@ -104,6 +104,17 @@ export const EXCLUDED_ENTRIES = [
|
|
|
104
104
|
RECORDS_GIT_NAME,
|
|
105
105
|
] as const
|
|
106
106
|
|
|
107
|
+
/** Media under a video workspace, remade from its plan and composition, so never backed up. */
|
|
108
|
+
export const MEDIA_PATHSPECS = [
|
|
109
|
+
':(exclude,glob)video/*/takes/**',
|
|
110
|
+
':(exclude,glob)video/**/renders/**',
|
|
111
|
+
':(exclude,glob)video/**/snapshots/**',
|
|
112
|
+
':(exclude,glob)video/**/node_modules/**',
|
|
113
|
+
':(exclude,glob,icase)video/**/*.mp4',
|
|
114
|
+
':(exclude,glob,icase)video/**/*.webm',
|
|
115
|
+
':(exclude,glob,icase)video/**/*.mov',
|
|
116
|
+
] as const
|
|
117
|
+
|
|
107
118
|
/**
|
|
108
119
|
* Whether a top-level name stays out of every push, at either root.
|
|
109
120
|
*
|
|
@@ -598,8 +609,8 @@ async function pendingFiles(
|
|
|
598
609
|
if (scope.length === 0) return { added: [], pending: [] }
|
|
599
610
|
|
|
600
611
|
const [others, modified] = await Promise.all([
|
|
601
|
-
records(root, ['ls-files', '-z', '-o', '--', ...scope]),
|
|
602
|
-
records(root, ['ls-files', '-z', '-m', '--', ...scope]),
|
|
612
|
+
records(root, ['ls-files', '-z', '-o', '--', ...scope, ...MEDIA_PATHSPECS]),
|
|
613
|
+
records(root, ['ls-files', '-z', '-m', '--', ...scope, ...MEDIA_PATHSPECS]),
|
|
603
614
|
])
|
|
604
615
|
if (!others.ok) return failed('ls-files', others)
|
|
605
616
|
if (!modified.ok) return failed('ls-files', modified)
|
|
@@ -699,7 +710,14 @@ export async function pushRecords(root: string): Promise<PushOutcome> {
|
|
|
699
710
|
// enclosing repository, and the pathspecs are the whole list, so nothing
|
|
700
711
|
// outside them can enter the index however the ignore rules read. An
|
|
701
712
|
// excluded name never reaches the list, whatever the records index holds.
|
|
702
|
-
const staged = await records(root, [
|
|
713
|
+
const staged = await records(root, [
|
|
714
|
+
'add',
|
|
715
|
+
'-A',
|
|
716
|
+
'-f',
|
|
717
|
+
'--',
|
|
718
|
+
...scope,
|
|
719
|
+
...MEDIA_PATHSPECS,
|
|
720
|
+
])
|
|
703
721
|
if (!staged.ok) return failed('add', staged)
|
|
704
722
|
}
|
|
705
723
|
|
|
@@ -837,7 +855,13 @@ export async function pullRecords(root: string): Promise<PullOutcome> {
|
|
|
837
855
|
const { scope } = await scopedFolders(root)
|
|
838
856
|
|
|
839
857
|
if (scope.length > 0) {
|
|
840
|
-
const dirty = await records(root, [
|
|
858
|
+
const dirty = await records(root, [
|
|
859
|
+
'status',
|
|
860
|
+
'--porcelain',
|
|
861
|
+
'--',
|
|
862
|
+
...scope,
|
|
863
|
+
...MEDIA_PATHSPECS,
|
|
864
|
+
])
|
|
841
865
|
if (!dirty.ok) return failed('status', dirty)
|
|
842
866
|
|
|
843
867
|
if (dirty.text.length > 0) {
|
package/src/take/encode.ts
CHANGED
|
@@ -154,7 +154,44 @@ export async function probeFrames(clip: string): Promise<number> {
|
|
|
154
154
|
return frames
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
|
|
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> {
|
|
158
195
|
let stderr = ''
|
|
159
196
|
proc.stderr?.on('data', (chunk: Buffer) => {
|
|
160
197
|
stderr += chunk.toString()
|
package/src/take/film.ts
CHANGED
|
@@ -44,6 +44,8 @@ export interface FilmedBeat {
|
|
|
44
44
|
readonly clip: string
|
|
45
45
|
readonly log: string
|
|
46
46
|
readonly camera: string
|
|
47
|
+
/** The clip's closing frame, as a JPEG. */
|
|
48
|
+
readonly still: string
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
export type TakeResult =
|
|
@@ -131,7 +133,7 @@ async function filmBeat(
|
|
|
131
133
|
|
|
132
134
|
let route: Route = beat.route
|
|
133
135
|
let fallback: string | undefined
|
|
134
|
-
let filmed: { frames: number; events: readonly LogEvent[] }
|
|
136
|
+
let filmed: { frames: number; events: readonly LogEvent[]; still: Buffer }
|
|
135
137
|
if (beat.route === 'step') {
|
|
136
138
|
try {
|
|
137
139
|
filmed = await filmStepped({ executable, url, beat, clip: target })
|
|
@@ -158,6 +160,8 @@ async function filmBeat(
|
|
|
158
160
|
// One line, since it carries a row per frame and nobody reads it by eye.
|
|
159
161
|
const cameraPath = join(dir, 'camera.json')
|
|
160
162
|
writeFileSync(join(out, cameraPath), `${JSON.stringify(cameraTrack(log))}\n`)
|
|
163
|
+
const stillPath = join(dir, 'still.jpg')
|
|
164
|
+
writeFileSync(join(out, stillPath), filmed.still)
|
|
161
165
|
|
|
162
166
|
return {
|
|
163
167
|
name: beat.name,
|
|
@@ -170,6 +174,7 @@ async function filmBeat(
|
|
|
170
174
|
clip,
|
|
171
175
|
log: logPath,
|
|
172
176
|
camera: cameraPath,
|
|
177
|
+
still: stillPath,
|
|
173
178
|
}
|
|
174
179
|
}
|
|
175
180
|
|
|
@@ -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
|
+
}
|
package/src/take/reel.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { readFileSync, statSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import type { CameraTrack } from '@/take/camera'
|
|
5
|
+
import { finished } from '@/take/encode'
|
|
6
|
+
import type { FilmedBeat } from '@/take/film'
|
|
7
|
+
import { FPS } from '@/take/log'
|
|
8
|
+
import { type Frame, drawPointer } from '@/take/pointer'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Joins a take's beats into one short reel for a pull request: each beat's
|
|
12
|
+
* clip shrunk to one width, every third frame kept, the pointer drawn from its
|
|
13
|
+
* camera track, encoded as an animated WebP that GitHub plays inline. It
|
|
14
|
+
* draws the pointer and nothing else, since the zoom, captions, and cuts are
|
|
15
|
+
* the edit's work for a launch video.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const REEL_WIDTH = 960
|
|
19
|
+
|
|
20
|
+
/** How many take frames each reel frame stands for, 60 fps down to 20. */
|
|
21
|
+
export const REEL_EVERY = 3
|
|
22
|
+
|
|
23
|
+
export const REEL_FPS = FPS / REEL_EVERY
|
|
24
|
+
|
|
25
|
+
/** WebP quality, measured at about 1.4 MB for a 9 s reel at this width. */
|
|
26
|
+
const QUALITY = 60
|
|
27
|
+
|
|
28
|
+
export interface ReelFrame extends Frame {
|
|
29
|
+
/** Which beat of the take, in plan order. */
|
|
30
|
+
readonly beat: number
|
|
31
|
+
/** The frame's place in its beat, which draws camera frame `index * REEL_EVERY`. */
|
|
32
|
+
readonly index: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface Reel {
|
|
36
|
+
readonly file: string
|
|
37
|
+
readonly frames: number
|
|
38
|
+
readonly seconds: number
|
|
39
|
+
readonly bytes: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Yields every reel frame in order with the pointer drawn on it. The decoder
|
|
44
|
+
* keeps frames by index rather than by timestamp, since an `fps` filter that
|
|
45
|
+
* rounds timestamps drifts the pointer off what it pressed.
|
|
46
|
+
*/
|
|
47
|
+
export async function* reelFrames(
|
|
48
|
+
out: string,
|
|
49
|
+
beats: readonly FilmedBeat[],
|
|
50
|
+
): AsyncGenerator<ReelFrame> {
|
|
51
|
+
for (const [beat, filmed] of beats.entries()) {
|
|
52
|
+
const camera = JSON.parse(
|
|
53
|
+
readFileSync(join(out, filmed.camera), 'utf8'),
|
|
54
|
+
) as CameraTrack
|
|
55
|
+
const { width, height } = reelSize(camera)
|
|
56
|
+
// The camera is in CSS pixels of the page, and every route's clip shrinks
|
|
57
|
+
// to the same width, so one factor places the pointer on either.
|
|
58
|
+
const factor = width / camera.viewport.width
|
|
59
|
+
|
|
60
|
+
let index = 0
|
|
61
|
+
for await (const data of decode(join(out, filmed.clip), width, height)) {
|
|
62
|
+
const at =
|
|
63
|
+
camera.frames[Math.min(index * REEL_EVERY, camera.frames.length - 1)]
|
|
64
|
+
const frame = { data, width, height, beat, index }
|
|
65
|
+
if (at) {
|
|
66
|
+
drawPointer(
|
|
67
|
+
frame,
|
|
68
|
+
{ x: at.cursor.x * factor, y: at.cursor.y * factor },
|
|
69
|
+
at.ring,
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
yield frame
|
|
73
|
+
index += 1
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Renders the reel of a filmed take to `file`, an animated WebP. */
|
|
79
|
+
export async function renderReel(
|
|
80
|
+
out: string,
|
|
81
|
+
beats: readonly FilmedBeat[],
|
|
82
|
+
file: string,
|
|
83
|
+
): Promise<Reel> {
|
|
84
|
+
const first = beats[0]
|
|
85
|
+
if (!first) throw new Error('a reel needs at least one filmed beat')
|
|
86
|
+
const { width, height } = reelSize(
|
|
87
|
+
JSON.parse(readFileSync(join(out, first.camera), 'utf8')) as CameraTrack,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
const proc = spawn(
|
|
91
|
+
'ffmpeg',
|
|
92
|
+
[
|
|
93
|
+
'-y',
|
|
94
|
+
'-loglevel',
|
|
95
|
+
'error',
|
|
96
|
+
'-f',
|
|
97
|
+
'rawvideo',
|
|
98
|
+
'-pix_fmt',
|
|
99
|
+
'rgba',
|
|
100
|
+
'-s',
|
|
101
|
+
`${width}x${height}`,
|
|
102
|
+
'-framerate',
|
|
103
|
+
String(REEL_FPS),
|
|
104
|
+
'-i',
|
|
105
|
+
'-',
|
|
106
|
+
'-c:v',
|
|
107
|
+
'libwebp_anim',
|
|
108
|
+
'-quality',
|
|
109
|
+
String(QUALITY),
|
|
110
|
+
'-loop',
|
|
111
|
+
'0',
|
|
112
|
+
'-f',
|
|
113
|
+
'webp',
|
|
114
|
+
file,
|
|
115
|
+
],
|
|
116
|
+
{ stdio: ['pipe', 'ignore', 'pipe'] },
|
|
117
|
+
)
|
|
118
|
+
const done = finished(proc)
|
|
119
|
+
// Held so an encoder that dies mid-stream surfaces through `done` rather than
|
|
120
|
+
// as an unhandled rejection or a broken pipe thrown out of the stream.
|
|
121
|
+
done.catch(() => {})
|
|
122
|
+
proc.stdin?.on('error', () => {})
|
|
123
|
+
|
|
124
|
+
let frames = 0
|
|
125
|
+
try {
|
|
126
|
+
for await (const frame of reelFrames(out, beats)) {
|
|
127
|
+
// Raced against the exit, since a write waiting on a drain from an
|
|
128
|
+
// encoder that already died would wait forever.
|
|
129
|
+
await Promise.race([write(proc.stdin, frame.data), done])
|
|
130
|
+
frames += 1
|
|
131
|
+
}
|
|
132
|
+
} catch (error) {
|
|
133
|
+
proc.kill('SIGKILL')
|
|
134
|
+
throw error
|
|
135
|
+
}
|
|
136
|
+
proc.stdin?.end()
|
|
137
|
+
await done
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
file,
|
|
141
|
+
frames,
|
|
142
|
+
seconds: Number((frames / REEL_FPS).toFixed(2)),
|
|
143
|
+
bytes: statSync(file).size,
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function reelSize(camera: CameraTrack): { width: number; height: number } {
|
|
148
|
+
const height =
|
|
149
|
+
Math.round(
|
|
150
|
+
(REEL_WIDTH * camera.viewport.height) / camera.viewport.width / 2,
|
|
151
|
+
) * 2
|
|
152
|
+
return { width: REEL_WIDTH, height }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Decodes every `REEL_EVERY`th frame of a clip as RGBA at the reel's size. */
|
|
156
|
+
async function* decode(
|
|
157
|
+
clip: string,
|
|
158
|
+
width: number,
|
|
159
|
+
height: number,
|
|
160
|
+
): AsyncGenerator<Uint8Array> {
|
|
161
|
+
const proc = spawn(
|
|
162
|
+
'ffmpeg',
|
|
163
|
+
[
|
|
164
|
+
'-loglevel',
|
|
165
|
+
'error',
|
|
166
|
+
'-i',
|
|
167
|
+
clip,
|
|
168
|
+
'-vf',
|
|
169
|
+
`select='not(mod(n\\,${REEL_EVERY}))',scale=${width}:${height}:flags=area`,
|
|
170
|
+
'-fps_mode',
|
|
171
|
+
'passthrough',
|
|
172
|
+
'-f',
|
|
173
|
+
'rawvideo',
|
|
174
|
+
'-pix_fmt',
|
|
175
|
+
'rgba',
|
|
176
|
+
'-',
|
|
177
|
+
],
|
|
178
|
+
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
|
179
|
+
)
|
|
180
|
+
const done = finished(proc)
|
|
181
|
+
done.catch(() => {})
|
|
182
|
+
|
|
183
|
+
const size = width * height * 4
|
|
184
|
+
let pending: Buffer = Buffer.alloc(0)
|
|
185
|
+
let drained = false
|
|
186
|
+
try {
|
|
187
|
+
for await (const chunk of proc.stdout as AsyncIterable<Buffer>) {
|
|
188
|
+
pending = pending.length ? Buffer.concat([pending, chunk]) : chunk
|
|
189
|
+
while (pending.length >= size) {
|
|
190
|
+
// Copied out, since the frame is drawn on and outlives the chunk.
|
|
191
|
+
yield new Uint8Array(pending.subarray(0, size))
|
|
192
|
+
pending = pending.subarray(size)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
drained = true
|
|
196
|
+
} finally {
|
|
197
|
+
// A reader that stopped early leaves FFmpeg decoding into a full pipe.
|
|
198
|
+
if (!drained) proc.kill('SIGKILL')
|
|
199
|
+
}
|
|
200
|
+
await done
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function write(
|
|
204
|
+
stdin: NodeJS.WritableStream | null,
|
|
205
|
+
data: Uint8Array,
|
|
206
|
+
): Promise<void> {
|
|
207
|
+
return new Promise((resolve, reject) => {
|
|
208
|
+
if (!stdin) {
|
|
209
|
+
reject(new Error('ffmpeg stopped reading frames'))
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
if (stdin.write(data)) resolve()
|
|
213
|
+
else stdin.once('drain', () => resolve())
|
|
214
|
+
})
|
|
215
|
+
}
|
package/src/take/screencast.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from 'node:fs'
|
|
|
2
2
|
import { tmpdir } from 'node:os'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { chromium } from 'playwright-core'
|
|
5
|
-
import { probeFrames, transcode } from '@/take/encode'
|
|
5
|
+
import { lastFrame, probeFrames, transcode } from '@/take/encode'
|
|
6
6
|
import { FPS, type LogEvent, START, VIEWPORT } from '@/take/log'
|
|
7
7
|
import { type PointerInput, type Stage, performSteps } from '@/take/perform'
|
|
8
8
|
import type { Beat } from '@/take/plan'
|
|
@@ -22,6 +22,8 @@ const SETTLE_MS = 2500
|
|
|
22
22
|
export interface ScreencastTake {
|
|
23
23
|
readonly frames: number
|
|
24
24
|
readonly events: readonly LogEvent[]
|
|
25
|
+
/** The clip's closing frame as a JPEG. */
|
|
26
|
+
readonly still: Buffer
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
export async function filmScreencast(request: {
|
|
@@ -71,7 +73,8 @@ export async function filmScreencast(request: {
|
|
|
71
73
|
await page.screencast.stop()
|
|
72
74
|
|
|
73
75
|
await transcode(webm, request.clip, FPS)
|
|
74
|
-
|
|
76
|
+
const frames = await probeFrames(request.clip)
|
|
77
|
+
return { frames, events, still: await lastFrame(request.clip, frames) }
|
|
75
78
|
} finally {
|
|
76
79
|
await browser.close()
|
|
77
80
|
rmSync(scratch, { recursive: true, force: true })
|
package/src/take/stepper.ts
CHANGED
|
@@ -46,6 +46,8 @@ export class StepperStalled extends Error {
|
|
|
46
46
|
export interface SteppedTake {
|
|
47
47
|
readonly frames: number
|
|
48
48
|
readonly events: readonly LogEvent[]
|
|
49
|
+
/** The last frame encoded, as the JPEG the shell returned. */
|
|
50
|
+
readonly still: Buffer
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
export interface SteppedRequest {
|
|
@@ -140,7 +142,8 @@ export async function filmStepped(
|
|
|
140
142
|
await recording.hold(HOLD_MS)
|
|
141
143
|
|
|
142
144
|
await encoder.close()
|
|
143
|
-
|
|
145
|
+
if (!last) throw new Error('the take encoded no frame')
|
|
146
|
+
return { frames, events, still: last }
|
|
144
147
|
} catch (error) {
|
|
145
148
|
encoder.abort()
|
|
146
149
|
throw error
|