@erclx/canon 5.67.0 → 5.69.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.
Files changed (105) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +1 -1
  3. package/claude/.claude-plugin/plugin.json +1 -1
  4. package/claude/skills/app-drive/REQUIREMENT.md +5 -5
  5. package/claude/skills/app-drive/SKILL.md +14 -4
  6. package/claude/skills/app-drive/references/drive-record.md +12 -1
  7. package/claude/skills/canon-cli/SKILL.md +2 -2
  8. package/claude/skills/canon-feedback/REQUIREMENT.md +1 -1
  9. package/claude/skills/canon-feedback/SKILL.md +1 -1
  10. package/claude/skills/design-identity/REQUIREMENT.md +4 -4
  11. package/claude/skills/design-identity/SKILL.md +7 -7
  12. package/claude/skills/{pick-by-eye → design-pick}/REQUIREMENT.md +6 -6
  13. package/claude/skills/{pick-by-eye → design-pick}/SKILL.md +18 -10
  14. package/claude/skills/{pick-by-eye → design-pick}/references/board.md +17 -11
  15. package/claude/skills/design-pick/references/design-config.md +33 -0
  16. package/claude/skills/design-record/REQUIREMENT.md +2 -2
  17. package/claude/skills/design-record/SKILL.md +1 -1
  18. package/claude/skills/design-taste/REQUIREMENT.md +2 -2
  19. package/claude/skills/design-taste/SKILL.md +3 -3
  20. package/claude/skills/design-taste/references/preflight.md +1 -1
  21. package/claude/skills/git-issue/REQUIREMENT.md +1 -1
  22. package/claude/skills/git-issue/references/issue.md +1 -1
  23. package/claude/skills/plan-feature/SKILL.md +1 -1
  24. package/claude/skills/plan-feature/references/plan.md +21 -6
  25. package/claude/skills/plan-groundwork/SKILL.md +1 -1
  26. package/claude/skills/pr-evidence/REQUIREMENT.md +1 -1
  27. package/claude/skills/pr-evidence/SKILL.md +7 -1
  28. package/claude/skills/pr-evidence/references/evidence.md +33 -3
  29. package/claude/skills/review-craft/references/ui.md +30 -10
  30. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +3 -3
  31. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +1 -1
  32. package/claude/skills/role-orchestrator/references/orchestrator-planning-wait.md +24 -0
  33. package/claude/skills/role-planner/SKILL.md +4 -2
  34. package/claude/skills/role-worker/SKILL.md +5 -6
  35. package/claude/skills/task-board/references/tasks.md +1 -1
  36. package/claude/skills/task-complete/references/receipt-sweep.md +1 -1
  37. package/claude/skills/{ux-audit → ui-audit}/REQUIREMENT.md +3 -3
  38. package/claude/skills/{ux-audit → ui-audit}/SKILL.md +7 -7
  39. package/claude/skills/ui-checklist/REQUIREMENT.md +1 -1
  40. package/claude/skills/ui-craft/SKILL.md +1 -1
  41. package/claude/skills/{ui-record → ui-film}/REQUIREMENT.md +7 -5
  42. package/claude/skills/{ui-record → ui-film}/SKILL.md +23 -12
  43. package/claude/skills/{ui-record → ui-film}/references/config.md +3 -3
  44. package/claude/skills/{ui-record → ui-film}/references/handoff.md +28 -9
  45. package/claude/skills/ui-film/references/pr-clip.md +84 -0
  46. package/claude/skills/{ui-record → ui-film}/references/take-craft.md +8 -1
  47. package/claude/skills/{ux-measure → ui-measure}/REQUIREMENT.md +3 -3
  48. package/claude/skills/{ux-measure → ui-measure}/SKILL.md +6 -6
  49. package/docs/agents/audits.md +1 -1
  50. package/docs/agents/migrate.md +13 -13
  51. package/docs/agents/records.md +1 -0
  52. package/docs/target/migrations.md +12 -10
  53. package/docs/target/projects.md +1 -1
  54. package/docs/target/sync.md +1 -1
  55. package/docs/workflow/skill-map-on-request.md +1 -2
  56. package/docs/workflow/skill-map.md +9 -10
  57. package/docs/workflow/visual-design-workflow.md +6 -1
  58. package/governance/ledger/claude/background.md +26 -0
  59. package/governance/rules/claude/background.md +19 -0
  60. package/governance/stacks/base.toml +1 -0
  61. package/package.json +8 -5
  62. package/scripts/ci/install-playwright-deps.sh +52 -0
  63. package/src/claude/cases/authoring.ts +2 -2
  64. package/src/claude/cases/misc.ts +1 -1
  65. package/src/claude/cases/setup.ts +0 -5
  66. package/src/claude/cases/workflow.ts +6 -6
  67. package/src/commands/fixtures/gh.sh +64 -0
  68. package/src/commands/gov.ts +33 -3
  69. package/src/commands/migrate.ts +12 -2
  70. package/src/commands/pr.ts +237 -79
  71. package/src/commands/take.ts +44 -8
  72. package/src/commands/tasks.ts +183 -0
  73. package/src/gate/stages.ts +2 -2
  74. package/src/gov/audit.ts +48 -11
  75. package/src/gov/detect.ts +1 -1
  76. package/src/markdown/scope.ts +9 -14
  77. package/src/migrate/record-layout.ts +5 -3
  78. package/src/migrate/skill-names.ts +21 -4
  79. package/src/pr/evidence.ts +208 -78
  80. package/src/pr/frames.ts +42 -4
  81. package/src/pr/pixels.ts +44 -0
  82. package/src/records/backup.ts +28 -4
  83. package/src/records/validate.ts +1 -0
  84. package/src/sandbox/headless/dispatch-shim.sh +15 -0
  85. package/src/sandbox/headless/dispatch.ts +12 -14
  86. package/src/sandbox/stub-gh.sh +26 -0
  87. package/src/sandbox/stub-remote.ts +8 -29
  88. package/src/sync/engine.ts +1 -1
  89. package/src/take/camera.ts +2 -2
  90. package/src/take/encode.ts +38 -1
  91. package/src/take/film.ts +26 -3
  92. package/src/take/pointer.ts +169 -0
  93. package/src/take/reel.ts +215 -0
  94. package/src/take/screencast.ts +5 -2
  95. package/src/take/stepper.ts +23 -6
  96. package/src/take/zoom/NOTICE.md +1 -1
  97. package/src/tasks/launch-wait.ts +160 -0
  98. package/tooling/claude/reference.md +1 -1
  99. package/claude/skills/canon-feedback-triage/REQUIREMENT.md +0 -34
  100. package/claude/skills/canon-feedback-triage/SKILL.md +0 -98
  101. package/claude/skills/plan-ready/REQUIREMENT.md +0 -35
  102. package/claude/skills/plan-ready/SKILL.md +0 -136
  103. package/claude/skills/plan-ready/references/assembly.md +0 -75
  104. package/claude/skills/plan-ready/references/ready.md +0 -128
  105. /package/claude/skills/{pick-by-eye → design-pick}/references/live-arms.md +0 -0
@@ -8,6 +8,11 @@ import {
8
8
  } from 'node:fs'
9
9
  import { homedir } from 'node:os'
10
10
  import { join } from 'node:path'
11
+ import { fileURLToPath } from 'node:url'
12
+
13
+ const DISPATCH_SHIM = fileURLToPath(
14
+ new URL('dispatch-shim.sh', import.meta.url),
15
+ )
11
16
 
12
17
  /*
13
18
  * The bound on a nested background dispatch, and the recording behind it.
@@ -31,9 +36,11 @@ import { join } from 'node:path'
31
36
  * The prevention. Writes a `claude` that refuses a background dispatch by flag
32
37
  * name and delegates everything else, for the runner to place first on the PATH
33
38
  * of the session it spawns. It stays a bash file, since it stands in for a
34
- * binary any shell in the session may exec.
39
+ * binary any shell in the session may exec. The refusal lives in
40
+ * `dispatch-shim.sh`, so the shell gate lints it, and the `claude` written here
41
+ * is a two-line wrapper that runs it with the real binary as its first argument.
35
42
  *
36
- * The real binary is baked into the file rather than passed through the
43
+ * The real binary is baked into the wrapper rather than passed through the
37
44
  * environment. An env var carrying it would sit in the spawned session's own
38
45
  * environment, which hands any arm the exact string that walks around the shim.
39
46
  *
@@ -46,22 +53,13 @@ import { join } from 'node:path'
46
53
  * backstops.
47
54
  */
48
55
  export function installDispatchShim(dir: string, real: string): void {
49
- const shim = [
56
+ const wrapper = [
50
57
  '#!/usr/bin/env bash',
51
- '# Generated by installDispatchShim in src/sandbox/headless/dispatch.ts.',
52
- 'for arg in "$@"; do',
53
- ' case "$arg" in',
54
- ' --bg | --background)',
55
- ' echo "canon sandbox harness: refusing $arg. A sandbox run may not dispatch a background session, so this shim stands first on PATH in place of the real claude binary. Drive the refusal the fixture asks for, or run this outside the harness." >&2',
56
- ' exit 64',
57
- ' ;;',
58
- ' esac',
59
- 'done',
60
- `exec ${shellQuote(real)} "$@"`,
58
+ `exec bash ${shellQuote(DISPATCH_SHIM)} ${shellQuote(real)} "$@"`,
61
59
  '',
62
60
  ].join('\n')
63
61
 
64
- writeFileSync(join(dir, 'claude'), shim, 'utf8')
62
+ writeFileSync(join(dir, 'claude'), wrapper, 'utf8')
65
63
  chmodSync(join(dir, 'claude'), 0o755)
66
64
  }
67
65
 
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env bash
2
+ # stub gh
3
+ dir="$STUB_GH_DIR"
4
+ line="$(printf '%q ' "$@")"
5
+ line="${line% }"
6
+ printf '%s\n' "$line" >>"$dir/gh.log"
7
+
8
+ case "$1" in
9
+ api) exit 0 ;;
10
+ pr)
11
+ case "$2" in
12
+ create)
13
+ count="$(cat "$dir/pr-count" 2>/dev/null || echo 0)"
14
+ count=$((count + 1))
15
+ echo "$count" >"$dir/pr-count"
16
+ echo "https://github.com/$GITHUB_ORG/$STUB_GH_REPO/pull/$count"
17
+ exit 0
18
+ ;;
19
+ close | list) exit 0 ;;
20
+ esac
21
+ ;;
22
+ esac
23
+
24
+ printf '%s\n' "$line" >>"$dir/gh-unknown.log"
25
+ echo "stub gh: unknown call: $line" >&2
26
+ exit 1
@@ -1,14 +1,15 @@
1
1
  import { execFileSync } from 'node:child_process'
2
2
  import {
3
3
  chmodSync,
4
+ copyFileSync,
4
5
  existsSync,
5
6
  mkdirSync,
6
7
  readdirSync,
7
8
  readFileSync,
8
9
  rmSync,
9
- writeFileSync,
10
10
  } from 'node:fs'
11
11
  import { join } from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
12
13
 
13
14
  const STUB_ORG = 'stub-org'
14
15
  const ANCHOR_REPO = 'canon-sandbox'
@@ -26,34 +27,11 @@ const PINNED_DATE = '2026-01-01T00:00:00Z'
26
27
  * Answers the `gh` calls the git scenarios make and fails on any other, so a new
27
28
  * call reaches a reader as a red arm rather than a silent pass. Every call is
28
29
  * logged as one line, quoted so an argument holding a space stays one argument.
30
+ * The pull request URL it prints reads `GITHUB_ORG` and `STUB_GH_REPO` from the
31
+ * environment `createStubRemote` returns, so it follows `STUB_ORG` and
32
+ * `ANCHOR_REPO`.
29
33
  */
30
- const STUB_GH = `#!/usr/bin/env bash
31
- # stub gh
32
- dir="$STUB_GH_DIR"
33
- line="$(printf '%q ' "$@")"
34
- line="\${line% }"
35
- printf '%s\\n' "$line" >>"$dir/gh.log"
36
-
37
- case "$1" in
38
- api) exit 0 ;;
39
- pr)
40
- case "$2" in
41
- create)
42
- count="$(cat "$dir/pr-count" 2>/dev/null || echo 0)"
43
- count=$((count + 1))
44
- echo "$count" >"$dir/pr-count"
45
- echo "https://github.com/${STUB_ORG}/${ANCHOR_REPO}/pull/$count"
46
- exit 0
47
- ;;
48
- close | list) exit 0 ;;
49
- esac
50
- ;;
51
- esac
52
-
53
- printf '%s\\n' "$line" >>"$dir/gh-unknown.log"
54
- echo "stub gh: unknown call: $line" >&2
55
- exit 1
56
- `
34
+ const STUB_GH = fileURLToPath(new URL('stub-gh.sh', import.meta.url))
57
35
 
58
36
  /**
59
37
  * Builds a local bare repository and a stub `gh` under `stubDir` and returns the
@@ -75,13 +53,14 @@ export function createStubRemote(stubDir: string): Record<string, string> {
75
53
  )
76
54
 
77
55
  const gh = join(bin, 'gh')
78
- writeFileSync(gh, STUB_GH)
56
+ copyFileSync(STUB_GH, gh)
79
57
  chmodSync(gh, 0o755)
80
58
 
81
59
  return {
82
60
  PATH: `${bin}:${process.env.PATH ?? ''}`,
83
61
  STUB_GH_DIR: stubDir,
84
62
  GITHUB_ORG: STUB_ORG,
63
+ STUB_GH_REPO: ANCHOR_REPO,
85
64
  GIT_CONFIG_COUNT: '1',
86
65
  GIT_CONFIG_KEY_0: `url.${remotes}/.insteadOf`,
87
66
  GIT_CONFIG_VALUE_0: `https://github.com/${STUB_ORG}/`,
@@ -545,7 +545,7 @@ function reportCoverage(coverage: readonly RuleCoverage[]): void {
545
545
  for (const entry of coverage) {
546
546
  logInfo(describeCoverage(entry))
547
547
  if (entry.ui) {
548
- logInfo(' Audit those files with /canon:ux-audit.')
548
+ logInfo(' Audit those files with /canon:ui-audit.')
549
549
  }
550
550
  }
551
551
  }
@@ -20,8 +20,8 @@ import {
20
20
 
21
21
  /**
22
22
  * Turns a take's log into one camera per frame, preset O4 from groundwork
23
- * track `193-ui-record`: OpenScreen's timing aimed at each click, a fixed
24
- * depth, a focus that holds still until a click leaves the inner part of the
23
+ * track `193-ui-record`, a folder name kept as written (canon-keep-retired).
24
+ * The preset is OpenScreen's timing aimed at each click, a fixed depth, a focus that holds still until a click leaves the inner part of the
25
25
  * view, and a dead zone that keeps the cursor in frame. HyperFrames renders the
26
26
  * track as it stands, so it carries no curve a renderer would have to agree on.
27
27
  */
@@ -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 =
@@ -65,6 +67,8 @@ export interface TakeRequest {
65
67
  readonly plan: TakePlan
66
68
  readonly out: string
67
69
  readonly env?: NodeJS.ProcessEnv
70
+ /** How long one stepped frame may take before its beat falls back to a screencast. */
71
+ readonly stallMs?: number
68
72
  }
69
73
 
70
74
  const REACH_TIMEOUT_MS = 5000
@@ -106,7 +110,16 @@ export async function take(request: TakeRequest): Promise<TakeResult> {
106
110
  const dir = `${String(index + 1).padStart(2, '0')}-${slug(beat.name)}`
107
111
  mkdirSync(join(out, dir), { recursive: true })
108
112
  try {
109
- beats.push(await filmBeat(request.url, beat, out, dir, executable))
113
+ beats.push(
114
+ await filmBeat(
115
+ request.url,
116
+ beat,
117
+ out,
118
+ dir,
119
+ executable,
120
+ request.stallMs,
121
+ ),
122
+ )
110
123
  } catch (error) {
111
124
  return failed(classify(error), error, beat.name)
112
125
  }
@@ -125,16 +138,23 @@ async function filmBeat(
125
138
  out: string,
126
139
  dir: string,
127
140
  executable: string,
141
+ stallMs: number | undefined,
128
142
  ): Promise<FilmedBeat> {
129
143
  const clip = join(dir, 'clip.mp4')
130
144
  const target = join(out, clip)
131
145
 
132
146
  let route: Route = beat.route
133
147
  let fallback: string | undefined
134
- let filmed: { frames: number; events: readonly LogEvent[] }
148
+ let filmed: { frames: number; events: readonly LogEvent[]; still: Buffer }
135
149
  if (beat.route === 'step') {
136
150
  try {
137
- filmed = await filmStepped({ executable, url, beat, clip: target })
151
+ filmed = await filmStepped({
152
+ executable,
153
+ url,
154
+ beat,
155
+ clip: target,
156
+ stallMs,
157
+ })
138
158
  } catch (error) {
139
159
  if (!(error instanceof StepperStalled)) throw error
140
160
  route = 'screencast'
@@ -158,6 +178,8 @@ async function filmBeat(
158
178
  // One line, since it carries a row per frame and nobody reads it by eye.
159
179
  const cameraPath = join(dir, 'camera.json')
160
180
  writeFileSync(join(out, cameraPath), `${JSON.stringify(cameraTrack(log))}\n`)
181
+ const stillPath = join(dir, 'still.jpg')
182
+ writeFileSync(join(out, stillPath), filmed.still)
161
183
 
162
184
  return {
163
185
  name: beat.name,
@@ -170,6 +192,7 @@ async function filmBeat(
170
192
  clip,
171
193
  log: logPath,
172
194
  camera: cameraPath,
195
+ still: stillPath,
173
196
  }
174
197
  }
175
198
 
@@ -0,0 +1,169 @@
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 (canon-keep-retired):
6
+ * a dark arrow outlined
7
+ * in white, and a blue ring that grows from its tip and fades out. Both stay
8
+ * one size whatever the frame shows, so the pointer reads the same in every
9
+ * beat of a reel. Drawn in TypeScript rather than a browser, so the reel needs
10
+ * no page and no seek, and a test can read the pixels it leaves.
11
+ */
12
+
13
+ export interface Frame {
14
+ /** RGBA, four bytes a pixel, row by row. */
15
+ readonly data: Uint8Array
16
+ readonly width: number
17
+ readonly height: number
18
+ }
19
+
20
+ /** The arrow, relative to its tip, from the groundwork's 24 px cursor path. */
21
+ const ARROW: readonly Point[] = [
22
+ { x: 0, y: 0 },
23
+ { x: 0, y: 17 },
24
+ { x: 4.5, y: 12.5 },
25
+ { x: 7.5, y: 19 },
26
+ { x: 10, y: 18 },
27
+ { x: 7, y: 11.5 },
28
+ { x: 13.5, y: 11.5 },
29
+ ]
30
+
31
+ const ARROW_FILL = [17, 17, 17] as const
32
+ const ARROW_STROKE = [255, 255, 255] as const
33
+ const STROKE_HALF = 0.75
34
+
35
+ /** The ring's outer radius at full size, and its stroke at that size. */
36
+ export const RING_RADIUS = 18
37
+ const RING_STROKE = 2
38
+ const RING_COLOR = [37, 99, 235] as const
39
+ /** The ring starts at this share of its size and grows by the expansion. */
40
+ const RING_START = 0.4
41
+
42
+ /**
43
+ * Draws the pointer with its tip at `at`, in the frame's own pixels, and the
44
+ * ring around it when `ring` is a 0 to 1 expansion. The ring sits under the
45
+ * arrow, and anything past the frame's edge is clipped.
46
+ */
47
+ export function drawPointer(
48
+ frame: Frame,
49
+ at: Point,
50
+ ring: number | null,
51
+ ): void {
52
+ if (ring !== null) drawRing(frame, at, ring)
53
+ drawArrow(frame, at)
54
+ }
55
+
56
+ function drawRing(frame: Frame, at: Point, ring: number): void {
57
+ const scale = RING_START + ring
58
+ const stroke = RING_STROKE * scale
59
+ const middle = RING_RADIUS * scale - stroke / 2
60
+ const opacity = 1 - ring
61
+ if (opacity <= 0) return
62
+
63
+ const reach = RING_RADIUS * scale + 1
64
+ forEachPixel(frame, at, reach, reach, reach, reach, (px, py) => {
65
+ const distance = Math.hypot(px - at.x, py - at.y)
66
+ const coverage = clamp01(stroke / 2 + 0.5 - Math.abs(distance - middle))
67
+ return coverage > 0
68
+ ? { color: RING_COLOR, alpha: coverage * opacity }
69
+ : null
70
+ })
71
+ }
72
+
73
+ function drawArrow(frame: Frame, at: Point): void {
74
+ const reach = STROKE_HALF + 1
75
+ forEachPixel(frame, at, reach, 13.5 + reach, reach, 19 + reach, (px, py) => {
76
+ const d = signedDistance({ x: px - at.x, y: py - at.y })
77
+ // Coverage of the outlined shape, then of the fill inside the outline,
78
+ // each antialiased across one pixel.
79
+ const outer = clamp01(0.5 - (d - STROKE_HALF))
80
+ if (outer <= 0) return null
81
+ const inner = clamp01(0.5 - (d + STROKE_HALF))
82
+ return {
83
+ color: ARROW_STROKE,
84
+ alpha: outer,
85
+ over: ARROW_FILL,
86
+ overAlpha: inner,
87
+ }
88
+ })
89
+ }
90
+
91
+ interface Paint {
92
+ readonly color: readonly [number, number, number]
93
+ readonly alpha: number
94
+ /** A second color laid over the first, for the fill inside the outline. */
95
+ readonly over?: readonly [number, number, number]
96
+ readonly overAlpha?: number
97
+ }
98
+
99
+ /**
100
+ * Calls `paint` for each pixel center inside the box around `at`, clipped to
101
+ * the frame, and blends whatever it returns onto that pixel.
102
+ */
103
+ function forEachPixel(
104
+ frame: Frame,
105
+ at: Point,
106
+ left: number,
107
+ right: number,
108
+ up: number,
109
+ down: number,
110
+ paint: (x: number, y: number) => Paint | null,
111
+ ): void {
112
+ const x0 = Math.max(0, Math.floor(at.x - left))
113
+ const x1 = Math.min(frame.width - 1, Math.ceil(at.x + right))
114
+ const y0 = Math.max(0, Math.floor(at.y - up))
115
+ const y1 = Math.min(frame.height - 1, Math.ceil(at.y + down))
116
+ for (let y = y0; y <= y1; y += 1) {
117
+ for (let x = x0; x <= x1; x += 1) {
118
+ const result = paint(x + 0.5, y + 0.5)
119
+ if (!result) continue
120
+ const i = (y * frame.width + x) * 4
121
+ blend(frame.data, i, result.color, result.alpha)
122
+ if (result.over && result.overAlpha) {
123
+ blend(frame.data, i, result.over, result.overAlpha)
124
+ }
125
+ }
126
+ }
127
+ }
128
+
129
+ function blend(
130
+ data: Uint8Array,
131
+ i: number,
132
+ color: readonly [number, number, number],
133
+ alpha: number,
134
+ ): void {
135
+ for (let c = 0; c < 3; c += 1) {
136
+ const base = data[i + c] ?? 0
137
+ data[i + c] = Math.round(base + ((color[c] ?? 0) - base) * alpha)
138
+ }
139
+ }
140
+
141
+ /** Distance from `p` to the arrow's outline, negative inside it. */
142
+ function signedDistance(p: Point): number {
143
+ let nearest = Number.POSITIVE_INFINITY
144
+ let inside = false
145
+ for (let i = 0, j = ARROW.length - 1; i < ARROW.length; j = i, i += 1) {
146
+ const a = ARROW[j] ?? ARROW[0]
147
+ const b = ARROW[i] ?? ARROW[0]
148
+ if (!a || !b) continue
149
+ nearest = Math.min(nearest, segmentDistance(p, a, b))
150
+ if (
151
+ a.y > p.y !== b.y > p.y &&
152
+ p.x < ((b.x - a.x) * (p.y - a.y)) / (b.y - a.y) + a.x
153
+ ) {
154
+ inside = !inside
155
+ }
156
+ }
157
+ return inside ? -nearest : nearest
158
+ }
159
+
160
+ function segmentDistance(p: Point, a: Point, b: Point): number {
161
+ const dx = b.x - a.x
162
+ const dy = b.y - a.y
163
+ const k = clamp01(((p.x - a.x) * dx + (p.y - a.y) * dy) / (dx * dx + dy * dy))
164
+ return Math.hypot(p.x - (a.x + k * dx), p.y - (a.y + k * dy))
165
+ }
166
+
167
+ function clamp01(value: number): number {
168
+ return Math.min(Math.max(value, 0), 1)
169
+ }
@@ -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
+ }