@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.
@@ -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, ['add', '-A', '-f', '--', ...scope])
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, ['status', '--porcelain', '--', ...scope])
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) {
@@ -154,7 +154,44 @@ export async function probeFrames(clip: string): Promise<number> {
154
154
  return frames
155
155
  }
156
156
 
157
- function finished(proc: ChildProcess): Promise<void> {
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
+ }
@@ -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
+ }
@@ -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
- return { frames: await probeFrames(request.clip), events }
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 })
@@ -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
- return { frames, events }
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