@erclx/canon 5.78.0 → 5.80.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 (104) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/app-drive/REQUIREMENT.md +12 -0
  4. package/claude/skills/app-drive/SKILL.md +44 -8
  5. package/claude/skills/app-drive/references/config.md +18 -8
  6. package/claude/skills/app-drive/references/drive-record.md +4 -0
  7. package/claude/skills/auto-ship/REQUIREMENT.md +1 -1
  8. package/claude/skills/auto-ship/SKILL.md +5 -4
  9. package/claude/skills/auto-ship/references/draft-mark.md +33 -15
  10. package/claude/skills/auto-ship/references/failure-recovery.md +4 -2
  11. package/claude/skills/canon-cli/SKILL.md +13 -10
  12. package/claude/skills/design-pick/SKILL.md +8 -0
  13. package/claude/skills/design-pick/references/design-config.md +9 -6
  14. package/claude/skills/design-references/REQUIREMENT.md +32 -0
  15. package/claude/skills/design-references/SKILL.md +120 -0
  16. package/claude/skills/design-references/references/films.md +90 -0
  17. package/claude/skills/design-references/references/sites.md +36 -0
  18. package/claude/skills/design-taste/SKILL.md +16 -4
  19. package/claude/skills/design-taste/references/adopted.md +5 -1
  20. package/claude/skills/design-taste/references/kinds.md +1 -0
  21. package/claude/skills/design-taste/references/preflight.md +27 -1
  22. package/claude/skills/design-taste/references/tells.md +2 -0
  23. package/claude/skills/git-followup/SKILL.md +1 -0
  24. package/claude/skills/git-pr/REQUIREMENT.md +2 -0
  25. package/claude/skills/git-pr/SKILL.md +2 -0
  26. package/claude/skills/git-pr/references/final-command.md +34 -2
  27. package/claude/skills/git-pr/references/pr.md +1 -1
  28. package/claude/skills/git-ship/REQUIREMENT.md +2 -2
  29. package/claude/skills/git-ship/SKILL.md +16 -11
  30. package/claude/skills/git-ship/references/ci-watch.md +28 -4
  31. package/claude/skills/markdown-craft/references/markdown.md +4 -4
  32. package/claude/skills/plan-feature/references/plan.md +2 -0
  33. package/claude/skills/review-address/SKILL.md +2 -1
  34. package/claude/skills/review-pr/references/key-changes.md +2 -1
  35. package/claude/skills/review-ui/references/driving.md +33 -1
  36. package/claude/skills/role-orchestrator/REQUIREMENT.md +1 -1
  37. package/claude/skills/role-orchestrator/SKILL.md +2 -2
  38. package/claude/skills/role-orchestrator/references/orchestrator-hand-launch.md +2 -0
  39. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
  40. package/claude/skills/role-orchestrator/references/orchestrator-limit-stops.md +47 -0
  41. package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +3 -1
  42. package/claude/skills/role-worker/SKILL.md +1 -1
  43. package/claude/skills/search-craft/REQUIREMENT.md +3 -3
  44. package/claude/skills/search-craft/SKILL.md +12 -1
  45. package/claude/skills/target-scaffold/REQUIREMENT.md +31 -0
  46. package/claude/skills/target-scaffold/SKILL.md +44 -0
  47. package/claude/skills/target-setup/SKILL.md +5 -1
  48. package/claude/skills/task-board/SKILL.md +1 -0
  49. package/claude/skills/task-board/references/tasks.md +1 -0
  50. package/claude/skills/ui-checklist/SKILL.md +5 -1
  51. package/claude/skills/ui-craft/SKILL.md +9 -9
  52. package/claude/skills/ui-craft/references/adopted.md +4 -2
  53. package/claude/skills/ui-craft/references/capture.md +8 -0
  54. package/claude/skills/ui-craft/references/controls.md +6 -0
  55. package/claude/skills/ui-craft/references/motion.md +6 -0
  56. package/claude/skills/ui-film/references/take-craft.md +2 -0
  57. package/docs/agents/output-shape.md +2 -1
  58. package/docs/target/projects.md +2 -2
  59. package/docs/workflow/ai-workflow.md +1 -1
  60. package/docs/workflow/parallel-features.md +2 -1
  61. package/docs/workflow/skill-map.md +7 -5
  62. package/docs/workflow/visual-design-workflow.md +4 -0
  63. package/governance/ledger/skills/design-references/SKILL.md +38 -0
  64. package/governance/ledger/skills/design-taste/SKILL.md +36 -30
  65. package/governance/ledger/skills/design-taste/kinds.md +1 -0
  66. package/governance/ledger/skills/design-taste/tells.md +22 -20
  67. package/governance/ledger/skills/search-craft/SKILL.md +6 -0
  68. package/governance/ledger/skills/ui-craft/capture.md +10 -0
  69. package/governance/ledger/skills/ui-craft/controls.md +8 -0
  70. package/governance/ledger/skills/ui-craft/motion.md +9 -1
  71. package/package.json +3 -3
  72. package/src/capture/render.ts +154 -2
  73. package/src/capture/sections.ts +257 -0
  74. package/src/claude/cases/misc.ts +5 -0
  75. package/src/claude/cases/setup.ts +7 -0
  76. package/src/commands/capture.ts +149 -4
  77. package/src/commands/pr.ts +138 -15
  78. package/src/commands/sessions.ts +88 -6
  79. package/src/commands/tasks.ts +41 -2
  80. package/src/commands/tooling.ts +72 -0
  81. package/src/git/files.ts +5 -2
  82. package/src/indexes/frontmatter.ts +27 -6
  83. package/src/indexes/render.ts +21 -3
  84. package/src/markdown/colons.ts +14 -4
  85. package/src/markdown/heading-case.ts +9 -2
  86. package/src/pr/bijection.ts +8 -0
  87. package/src/pr/evidence.ts +38 -9
  88. package/src/pr/health.ts +63 -15
  89. package/src/pr/links.ts +160 -0
  90. package/src/pr/local.ts +17 -4
  91. package/src/pr/paths.ts +20 -2
  92. package/src/sessions/stop.ts +145 -0
  93. package/src/sessions/transfer/bundle.ts +1 -1
  94. package/src/take/encode.ts +16 -42
  95. package/src/take/frame-clock.ts +46 -0
  96. package/src/take/perform.ts +29 -4
  97. package/src/take/screencast.ts +114 -11
  98. package/src/tasks/holders.ts +198 -0
  99. package/src/tasks/validate.ts +148 -0
  100. package/src/tooling/list.ts +3 -0
  101. package/src/tooling/scaffold.ts +193 -0
  102. package/tooling/cloudflare/manifest.toml +1 -1
  103. package/tooling/cloudflare/reference.md +8 -0
  104. /package/src/sessions/{transfer/transcript.ts → transcript.ts} +0 -0
package/src/pr/paths.ts CHANGED
@@ -77,7 +77,7 @@ const HEADING = /^(#{1,6})\s+(.+?)\s*$/
77
77
  * A `file.ts:42` or `file.ts:42-58` suffix, which is a reader's click target
78
78
  * rather than part of the name.
79
79
  */
80
- const LINE_SUFFIX = /:\d+(?:-\d+)?$/
80
+ export const LINE_SUFFIX = /:\d+(?:-\d+)?$/
81
81
 
82
82
  /**
83
83
  * A span that carries a line suffix and nothing else, such as `:642` beside
@@ -214,6 +214,21 @@ function disclaimsChange(bullet: string): boolean {
214
214
  * reports rather than reading as an empty section.
215
215
  */
216
216
  export function readSection(body: string, title: string): string | undefined {
217
+ return readSectionAt(body, title)?.text
218
+ }
219
+
220
+ /**
221
+ * The section's text and the character offset it starts at, counted in the body
222
+ * with its line endings normalized to `\n`.
223
+ *
224
+ * The offset is what lets a caller rewrite the section in place. Searching the
225
+ * body for the text instead finds the first copy, so an identical block above
226
+ * the heading would take the edit.
227
+ */
228
+ export function readSectionAt(
229
+ body: string,
230
+ title: string,
231
+ ): { readonly text: string; readonly offset: number } | undefined {
217
232
  const lines = body.replace(/\r\n/g, '\n').split('\n')
218
233
  const wanted = title.toLowerCase()
219
234
 
@@ -246,7 +261,10 @@ export function readSection(body: string, title: string): string | undefined {
246
261
  out.push(line)
247
262
  }
248
263
 
249
- return out.join('\n')
264
+ const offset = lines
265
+ .slice(0, start)
266
+ .reduce((sum, line) => sum + line.length + 1, 0)
267
+ return { text: out.join('\n'), offset }
250
268
  }
251
269
 
252
270
  /**
@@ -0,0 +1,145 @@
1
+ import { closeSync, fstatSync, openSync, readSync } from 'node:fs'
2
+ import type { ResolvedSession } from '@/sessions/resolve'
3
+ import { locateTranscript, projectsDir } from '@/sessions/transcript'
4
+
5
+ export interface StopReading {
6
+ /**
7
+ * `usage-limit` means the last assistant entry is a limit stop, passing over
8
+ * a `<synthetic>` entry with no error, such as the one a client restart
9
+ * writes. `unknown` means the read failed or found a limit-worded entry of
10
+ * another shape, so the row may be stopped and nothing here can say.
11
+ */
12
+ readonly stopped: 'usage-limit' | 'unknown' | null
13
+ readonly stopResets: string | null
14
+ }
15
+
16
+ const NOT_STOPPED: StopReading = { stopped: null, stopResets: null }
17
+ const UNREADABLE: StopReading = { stopped: 'unknown', stopResets: null }
18
+
19
+ /**
20
+ * The largest measured distance from a final stop to the end of its file is
21
+ * under 4 KiB, while a live transcript reaches hundreds of megabytes, so the
22
+ * read stays positional and never loads the file.
23
+ */
24
+ const WINDOW_BYTES = 1024 * 1024
25
+
26
+ function readTail(path: string): {
27
+ readonly text: string
28
+ readonly cut: boolean
29
+ } {
30
+ const fd = openSync(path, 'r')
31
+ try {
32
+ const { size } = fstatSync(fd)
33
+ const length = Math.min(size, WINDOW_BYTES)
34
+ const start = size - length
35
+ const buffer = Buffer.alloc(length)
36
+ let filled = 0
37
+ while (filled < length) {
38
+ const read = readSync(fd, buffer, filled, length - filled, start + filled)
39
+ if (read === 0) break
40
+ filled += read
41
+ }
42
+ return {
43
+ text: buffer.subarray(0, filled).toString('utf8'),
44
+ cut: start > 0,
45
+ }
46
+ } finally {
47
+ closeSync(fd)
48
+ }
49
+ }
50
+
51
+ function parseEntry(line: string): Record<string, unknown> | null {
52
+ try {
53
+ const parsed: unknown = JSON.parse(line)
54
+ return typeof parsed === 'object' && parsed !== null
55
+ ? (parsed as Record<string, unknown>)
56
+ : null
57
+ } catch {
58
+ return null
59
+ }
60
+ }
61
+
62
+ function entryText(message: Record<string, unknown>): string {
63
+ if (!Array.isArray(message.content)) return ''
64
+ return message.content
65
+ .map((block: { text?: unknown } | null) =>
66
+ typeof block?.text === 'string' ? block.text : '',
67
+ )
68
+ .join('\n')
69
+ }
70
+
71
+ /**
72
+ * Walks back to the entry that decides the row. No published contract describes
73
+ * the stop entry, so this rests on a measured shape: `error: "rate_limit"`, a
74
+ * `<synthetic>` model, `isApiErrorMessage`, and text naming the limit and its
75
+ * reset.
76
+ *
77
+ * A generated entry with limit wording and any other shape reads `unknown`,
78
+ * since a release renaming the field or the type still parses cleanly and would
79
+ * otherwise read as a session that never stopped. A `<synthetic>` entry with no
80
+ * error and no limit wording is passed over, since a client restart writes one
81
+ * reading `No response requested.` with no model turn behind it.
82
+ */
83
+ function readEntries(text: string, cut: boolean): StopReading {
84
+ const lines = text.split('\n')
85
+ // The first line of a cut window usually starts mid-entry, and one that
86
+ // happens to parse cannot be told apart from it.
87
+ const first = cut ? 1 : 0
88
+
89
+ for (let index = lines.length - 1; index >= first; index -= 1) {
90
+ const entry = parseEntry(lines[index] ?? '')
91
+ if (entry === null || entry.isSidechain === true) continue
92
+
93
+ const message =
94
+ typeof entry.message === 'object' && entry.message !== null
95
+ ? (entry.message as Record<string, unknown>)
96
+ : {}
97
+ const synthetic = message.model === '<synthetic>'
98
+ const generated = synthetic || entry.isApiErrorMessage === true
99
+ const entryWords = generated ? entryText(message) : ''
100
+ const worded = /hit your .* limit/.test(entryWords)
101
+
102
+ if (entry.type !== 'assistant') {
103
+ if (worded) return UNREADABLE
104
+ continue
105
+ }
106
+
107
+ if (entry.error === 'rate_limit') {
108
+ return {
109
+ stopped: 'usage-limit',
110
+ stopResets: /resets (.+)/.exec(entryText(message))?.[1]?.trim() ?? null,
111
+ }
112
+ }
113
+ if (worded) return UNREADABLE
114
+ if (synthetic && entry.error === undefined) continue
115
+
116
+ return NOT_STOPPED
117
+ }
118
+
119
+ return cut ? UNREADABLE : NOT_STOPPED
120
+ }
121
+
122
+ /**
123
+ * Reads whether a usage limit stopped one roster row, from the tail of its
124
+ * transcript. A `busy` row reads nothing, since a turn is running and a resume
125
+ * may be in flight.
126
+ */
127
+ export function readStop(
128
+ row: Pick<ResolvedSession, 'status' | 'sessionId'>,
129
+ dir: string = projectsDir(),
130
+ ): StopReading {
131
+ if (row.status === 'busy') return NOT_STOPPED
132
+ if (row.sessionId === null) return UNREADABLE
133
+
134
+ const located = locateTranscript(row.sessionId, dir)
135
+ if (located === null) return UNREADABLE
136
+
137
+ let tail: ReturnType<typeof readTail>
138
+ try {
139
+ tail = readTail(located.transcript)
140
+ } catch {
141
+ return UNREADABLE
142
+ }
143
+
144
+ return readEntries(tail.text, tail.cut)
145
+ }
@@ -21,7 +21,7 @@ import {
21
21
  readSideFolder,
22
22
  readTranscript,
23
23
  transcriptFacts,
24
- } from '@/sessions/transfer/transcript'
24
+ } from '@/sessions/transcript'
25
25
 
26
26
  /** Bumped when a field changes meaning, so an older import refuses a newer bundle. */
27
27
  export const BUNDLE_FORMAT = 1
@@ -75,18 +75,24 @@ export function openFrameEncoder(out: string, fps: number): FrameEncoder {
75
75
  // Held so a failure while frames are still being written surfaces at close
76
76
  // rather than as an unhandled rejection.
77
77
  done.catch(() => {})
78
+ // Rejects once FFmpeg is gone, since a full pipe with no reader never drains.
79
+ const stopped = done.then(() => {
80
+ throw new Error('ffmpeg stopped reading frames')
81
+ })
82
+ stopped.catch(() => {})
83
+ // A write to the dead process's pipe fails here, and `stopped` reports it.
84
+ proc.stdin?.on('error', () => {})
78
85
 
79
86
  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
- }),
87
+ write: async (jpeg) => {
88
+ const stdin = proc.stdin
89
+ if (!stdin || stdin.destroyed) return stopped
90
+ if (stdin.write(jpeg)) return
91
+ await Promise.race([
92
+ new Promise<void>((resolve) => stdin.once('drain', resolve)),
93
+ stopped,
94
+ ])
95
+ },
90
96
  close: async () => {
91
97
  proc.stdin?.end()
92
98
  await done
@@ -97,38 +103,6 @@ export function openFrameEncoder(out: string, fps: number): FrameEncoder {
97
103
  }
98
104
  }
99
105
 
100
- /**
101
- * Re-encodes a recorded clip to the same H.264 output at a constant `fps`, cut
102
- * at `ms`. Playwright's recorder pads its webm past the last frame by at least
103
- * a second, so the cut is what ends the clip where the take's clock stopped.
104
- * A shorter input stays as it is, since `-t` never extends one.
105
- */
106
- export async function transcode(
107
- input: string,
108
- out: string,
109
- fps: number,
110
- ms: number,
111
- ): Promise<void> {
112
- const proc = spawn(
113
- 'ffmpeg',
114
- [
115
- '-y',
116
- '-loglevel',
117
- 'error',
118
- '-i',
119
- input,
120
- '-t',
121
- (ms / 1000).toFixed(3),
122
- '-r',
123
- String(fps),
124
- ...OUTPUT,
125
- out,
126
- ],
127
- { stdio: ['ignore', 'ignore', 'pipe'] },
128
- )
129
- await finished(proc)
130
- }
131
-
132
106
  /**
133
107
  * Counts a clip's frames, which a real-time take cannot know in advance and a
134
108
  * camera track has to match one for one.
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Places a screencast's frames on the take's monotonic clock. Playwright stamps
3
+ * a frame in wall time, and a time service can step the wall clock mid-take, so
4
+ * a stamp is only ever read against the wall clock of the moment it arrives.
5
+ */
6
+
7
+ /**
8
+ * A frame's time on the monotonic clock. A step between the frame's swap and its
9
+ * arrival cannot be told apart from latency, so a forward one lands that frame
10
+ * early and a backward one is capped at the arrival, the latest it can be.
11
+ */
12
+ export function frameStamp(reading: {
13
+ readonly presentedWall: number
14
+ readonly wallNow: number
15
+ readonly monoNow: number
16
+ }): number {
17
+ const { presentedWall, wallNow, monoNow } = reading
18
+ return Math.min(monoNow, presentedWall - (wallNow - monoNow))
19
+ }
20
+
21
+ /**
22
+ * Which frame fills each slot of a clip, as indexes into `stamps`, which holds
23
+ * the frames in arrival order. A slot takes the newest frame stamped at or
24
+ * before it, and slot 0 takes the first frame whenever it was presented. The
25
+ * indexes never fall, so an early stamp cannot reorder the clip. Empty when no
26
+ * frame arrived.
27
+ */
28
+ export function slotFrames(
29
+ stamps: readonly number[],
30
+ clip: {
31
+ readonly startMs: number
32
+ readonly lengthMs: number
33
+ readonly frameMs: number
34
+ },
35
+ ): number[] {
36
+ if (stamps.length === 0) return []
37
+ const slots = Math.round(clip.lengthMs / clip.frameMs)
38
+ const filled: number[] = []
39
+ let at = 0
40
+ for (let slot = 0; slot < slots; slot += 1) {
41
+ const clock = clip.startMs + slot * clip.frameMs
42
+ while ((stamps[at + 1] ?? Infinity) <= clock) at += 1
43
+ filled.push(at)
44
+ }
45
+ return filled
46
+ }
@@ -472,6 +472,12 @@ async function scrollPoint(stage: Stage, target: string): Promise<Point> {
472
472
  * innermost element with a size whose whole text is those words, so a hidden
473
473
  * copy earlier in the page cannot shadow a visible one, and anything else as a
474
474
  * CSS selector.
475
+ * The document is searched first and every open shadow root after it in tree
476
+ * order, so a document-tree match wins, except that innermost reads containment
477
+ * through a shadow root to its host and a `text=` match holding a sized match
478
+ * inside its subtree's roots gives way to it. A selector matches within one
479
+ * root and never across a shadow boundary. A closed root and a frame stay
480
+ * unreachable.
475
481
  * It runs inside the page so both routes resolve a target the same way, and a
476
482
  * match with no size counts as missing, since a click on it would land on
477
483
  * whatever sits behind.
@@ -479,19 +485,38 @@ async function scrollPoint(stage: Stage, target: string): Promise<Point> {
479
485
  export function boxExpression(target: string): string {
480
486
  return `(() => {
481
487
  const target = ${JSON.stringify(target)}
488
+ const roots = []
489
+ const collect = (root) => {
490
+ roots.push(root)
491
+ for (const node of root.querySelectorAll('*')) {
492
+ if (node.shadowRoot) collect(node.shadowRoot)
493
+ }
494
+ }
495
+ collect(document)
496
+ const within = (outer, inner) => {
497
+ for (let node = inner; node; node = node.parentNode ?? node.host ?? null) {
498
+ if (node === outer) return true
499
+ }
500
+ return false
501
+ }
482
502
  let element = null
483
503
  if (target.startsWith('text=')) {
484
504
  const want = target.slice(5).trim()
485
- const matches = [...document.querySelectorAll('body *')].filter(
486
- (node) => node.textContent.trim() === want,
505
+ const matches = roots.flatMap((root) =>
506
+ [...root.querySelectorAll(root === document ? 'body *' : '*')].filter(
507
+ (node) => node.textContent.trim() === want,
508
+ ),
487
509
  )
488
- const innermost = matches.filter((node) => !matches.some((other) => other !== node && node.contains(other)))
510
+ const innermost = matches.filter((node) => !matches.some((other) => other !== node && within(node, other)))
489
511
  element = innermost.find((node) => {
490
512
  const rect = node.getBoundingClientRect()
491
513
  return rect.width > 0 && rect.height > 0
492
514
  }) ?? null
493
515
  } else {
494
- element = document.querySelector(target)
516
+ for (const root of roots) {
517
+ element = root.querySelector(target)
518
+ if (element) break
519
+ }
495
520
  }
496
521
  if (!element) return null
497
522
  const rect = element.getBoundingClientRect()
@@ -1,8 +1,8 @@
1
- import { mkdtempSync, rmSync } from 'node:fs'
2
- import { tmpdir } from 'node:os'
3
- import { join } from 'node:path'
1
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
4
3
  import { chromium } from 'playwright-core'
5
- import { lastFrame, probeFrames, transcode } from '@/take/encode'
4
+ import { lastFrame, openFrameEncoder, probeFrames } from '@/take/encode'
5
+ import { frameStamp, slotFrames } from '@/take/frame-clock'
6
6
  import { FPS, type LogEvent, START, VIEWPORT } from '@/take/log'
7
7
  import {
8
8
  type PointerInput,
@@ -51,8 +51,13 @@ export async function filmScreencast(request: {
51
51
  readonly beat: Beat
52
52
  readonly clip: string
53
53
  }): Promise<ScreencastTake> {
54
- const scratch = mkdtempSync(join(tmpdir(), 'canon-take-cast-'))
55
- const browser = await chromium.launch()
54
+ // Ahead of the launch, so a folder that refuses leaves no browser holding the
55
+ // process open past its refusal.
56
+ const scratch = openSpool(request.clip)
57
+ const browser = await chromium.launch().catch((error: unknown) => {
58
+ scratch.remove()
59
+ throw error
60
+ })
56
61
  let still: (() => Promise<Buffer | undefined>) | undefined
57
62
  try {
58
63
  const page = await browser.newPage({ viewport: VIEWPORT })
@@ -97,9 +102,33 @@ export async function filmScreencast(request: {
97
102
  if (request.beat.setup.length > 0) await live.hold(1000)
98
103
  const lag = await calibrate(page)
99
104
 
100
- const webm = join(scratch, 'take.webm')
101
105
  const events: LogEvent[] = []
102
- await page.screencast.start({ path: webm, size: VIEWPORT, fps: FPS })
106
+ const spool: SpooledFrame[] = []
107
+ let spoolFailure: unknown
108
+ await page.screencast.start({
109
+ size: VIEWPORT,
110
+ // The docs tie this to `path`, but 1.64.1 still reads it here, where it
111
+ // doubles the frames Chromium may hold unacknowledged.
112
+ fps: FPS,
113
+ // Spooled rather than held, since a busy page sends about 19 MB a second.
114
+ // The write stays synchronous, so the acknowledgement Chromium paces its
115
+ // frames on waits for no encoder. Playwright calls this from a listener
116
+ // nothing awaits, so a throw here would surface as an unhandled rejection.
117
+ onFrame: ({ data, timestamp }) => {
118
+ const stamp = frameStamp({
119
+ presentedWall: timestamp,
120
+ wallNow: Date.now(),
121
+ monoNow: performance.now(),
122
+ })
123
+ const path = join(scratch.folder, `${spool.length}.jpg`)
124
+ try {
125
+ writeFileSync(path, data)
126
+ spool.push({ path, stamp })
127
+ } catch (error) {
128
+ spoolFailure ??= error
129
+ }
130
+ },
131
+ })
103
132
  const started = performance.now()
104
133
  const recording = stageOn(() => performance.now() - started)
105
134
  await recording.hold(HOLD_MS)
@@ -110,8 +139,9 @@ export async function filmScreencast(request: {
110
139
  await recording.hold(HOLD_MS)
111
140
  const filmedMs = recording.now()
112
141
  await page.screencast.stop()
142
+ if (spoolFailure !== undefined) throw spoolFailure
113
143
 
114
- await transcode(webm, request.clip, FPS, filmedMs)
144
+ await encodeSpool(spool, request.clip, started, filmedMs)
115
145
  const frames = await probeFrames(request.clip)
116
146
  const closing = await lastFrame(request.clip, frames)
117
147
  if (lag === undefined) return { frames, events, still: closing }
@@ -126,13 +156,86 @@ export async function filmScreencast(request: {
126
156
  throw error
127
157
  } finally {
128
158
  await browser.close()
129
- rmSync(scratch, { recursive: true, force: true })
159
+ scratch.remove()
130
160
  }
131
161
  }
132
162
 
133
163
  type Page = import('playwright-core').Page
134
164
  type Mouse = import('playwright-core').Mouse
135
165
 
166
+ interface SpooledFrame {
167
+ readonly path: string
168
+ /** When the frame was presented, on the monotonic clock. */
169
+ readonly stamp: number
170
+ }
171
+
172
+ /**
173
+ * Opens the folder holding one take's frames, beside its clip rather than in a
174
+ * temp folder nothing sweeps, since a 34 s beat on a busy page spools about
175
+ * 650 MB. A take killed outright runs no handler, so it leaves the folder where
176
+ * its owner looks, and the next take of that beat clears it.
177
+ */
178
+ function openSpool(clip: string): { folder: string; remove: () => void } {
179
+ const folder = join(dirname(clip), 'spool')
180
+ rmSync(folder, { recursive: true, force: true })
181
+ mkdirSync(folder, { recursive: true })
182
+ const remove = (): void => {
183
+ process.off('exit', remove)
184
+ process.off('SIGINT', interrupt)
185
+ process.off('SIGTERM', interrupt)
186
+ rmSync(folder, { recursive: true, force: true })
187
+ }
188
+ // A signal skips the exit event, so it removes the folder and then re-raises
189
+ // itself with this handler gone, ending the process as it would have.
190
+ const interrupt = (signal: NodeJS.Signals): void => {
191
+ remove()
192
+ process.kill(process.pid, signal)
193
+ }
194
+ process.on('exit', remove)
195
+ process.once('SIGINT', interrupt)
196
+ process.once('SIGTERM', interrupt)
197
+ return { folder, remove }
198
+ }
199
+
200
+ /**
201
+ * Writes the clip from the spool, one frame per slot of the stage clock, so its
202
+ * length is the take's rather than whatever the frames span. Refuses when no
203
+ * frame arrived, since there is nothing true to show.
204
+ */
205
+ async function encodeSpool(
206
+ spool: readonly SpooledFrame[],
207
+ clip: string,
208
+ startMs: number,
209
+ lengthMs: number,
210
+ ): Promise<void> {
211
+ const slots = slotFrames(
212
+ spool.map((frame) => frame.stamp),
213
+ { startMs, lengthMs, frameMs: FRAME_MS },
214
+ )
215
+ if (slots.length === 0) {
216
+ throw new Error(
217
+ `the screencast delivered no frame in ${(lengthMs / 1000).toFixed(2)} s of filming`,
218
+ )
219
+ }
220
+ const encoder = openFrameEncoder(clip, FPS)
221
+ try {
222
+ let shown = -1
223
+ let jpeg = Buffer.alloc(0)
224
+ for (const index of slots) {
225
+ const frame = spool[index]
226
+ if (frame && index !== shown) {
227
+ jpeg = readFileSync(frame.path)
228
+ shown = index
229
+ }
230
+ await encoder.write(jpeg)
231
+ }
232
+ await encoder.close()
233
+ } catch (error) {
234
+ encoder.abort()
235
+ throw error
236
+ }
237
+ }
238
+
136
239
  /**
137
240
  * The page time of its next animation frame, or undefined when none comes
138
241
  * within `capMs`, as on a hidden page, or the page closed first.
@@ -155,7 +258,7 @@ const PAINTED = '__canonTakePainted'
155
258
  * How long a move takes to show in the clip, measured by sending one aligned
156
259
  * move to `START` and reading the animation frame after its handler against
157
260
  * the frame it was aligned to. Both readings are page time, so no clock mapping
158
- * is needed. The recorder captures a frame once it is presented, one page
261
+ * is needed. The screencast captures a frame once it is presented, one page
159
262
  * frame after that frame begins, so the lag counts one page frame past the
160
263
  * readings, read off two consecutive animation frames rather than assumed from
161
264
  * the clip's rate. That held a dragged box within 7 px on every run where the