@erclx/canon 5.77.0 → 5.79.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 (125) hide show
  1. package/CHANGELOG.md +43 -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/auto-ship/references/ui-checklist.md +9 -1
  12. package/claude/skills/code-craft/REQUIREMENT.md +1 -1
  13. package/claude/skills/code-craft/SKILL.md +7 -0
  14. package/claude/skills/code-craft/references/adopted.md +8 -0
  15. package/claude/skills/git-followup/SKILL.md +1 -0
  16. package/claude/skills/git-pr/REQUIREMENT.md +2 -0
  17. package/claude/skills/git-pr/SKILL.md +2 -0
  18. package/claude/skills/git-pr/references/final-command.md +34 -2
  19. package/claude/skills/git-pr/references/pr.md +1 -1
  20. package/claude/skills/git-ship/REQUIREMENT.md +2 -2
  21. package/claude/skills/git-ship/SKILL.md +16 -11
  22. package/claude/skills/git-ship/references/ci-watch.md +28 -4
  23. package/claude/skills/markdown-craft/references/markdown.md +4 -4
  24. package/claude/skills/plan-feature/references/plan.md +2 -0
  25. package/claude/skills/pr-evidence/SKILL.md +6 -1
  26. package/claude/skills/pr-evidence/references/evidence.md +1 -0
  27. package/claude/skills/pr-evidence/references/reel.md +9 -1
  28. package/claude/skills/review-address/SKILL.md +2 -1
  29. package/claude/skills/review-craft/references/ui.md +13 -1
  30. package/claude/skills/review-pr/references/body-variants.md +2 -0
  31. package/claude/skills/review-pr/references/close-out.md +2 -0
  32. package/claude/skills/review-pr/references/key-changes.md +2 -1
  33. package/claude/skills/review-pr/references/post.md +1 -0
  34. package/claude/skills/review-ui/REQUIREMENT.md +1 -0
  35. package/claude/skills/review-ui/SKILL.md +2 -2
  36. package/claude/skills/review-ui/references/driving.md +32 -4
  37. package/claude/skills/review-ui/references/post.md +6 -2
  38. package/claude/skills/role-orchestrator/REQUIREMENT.md +1 -1
  39. package/claude/skills/role-orchestrator/SKILL.md +4 -4
  40. package/claude/skills/role-orchestrator/references/orchestrator-hand-launch.md +2 -0
  41. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
  42. package/claude/skills/role-orchestrator/references/orchestrator-limit-stops.md +47 -0
  43. package/claude/skills/role-orchestrator/references/orchestrator-output.md +24 -1
  44. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +3 -2
  45. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +35 -4
  46. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +1 -1
  47. package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +3 -1
  48. package/claude/skills/role-worker/SKILL.md +2 -1
  49. package/claude/skills/task-board/SKILL.md +1 -0
  50. package/claude/skills/task-board/references/tasks.md +1 -0
  51. package/claude/skills/test-craft/SKILL.md +1 -0
  52. package/claude/skills/ui-checklist/REQUIREMENT.md +1 -1
  53. package/claude/skills/ui-checklist/SKILL.md +2 -1
  54. package/claude/skills/ui-craft/references/capture.md +16 -0
  55. package/claude/skills/ui-film/REQUIREMENT.md +2 -2
  56. package/claude/skills/ui-film/SKILL.md +6 -1
  57. package/claude/skills/ui-film/references/compose-craft.md +68 -11
  58. package/claude/skills/ui-film/references/config.md +2 -2
  59. package/claude/skills/ui-film/references/handoff.md +1 -1
  60. package/claude/skills/ui-film/references/harness.md +71 -0
  61. package/claude/skills/ui-film/references/take-craft.md +42 -17
  62. package/docs/workflow/ai-workflow.md +1 -1
  63. package/docs/workflow/operating-model.md +6 -5
  64. package/docs/workflow/parallel-features.md +2 -1
  65. package/docs/workflow/visual-design-workflow.md +2 -1
  66. package/governance/ledger/skills/code-craft/SKILL.md +9 -0
  67. package/governance/ledger/skills/test-craft/SKILL.md +1 -0
  68. package/governance/ledger/skills/ui-craft/capture.md +18 -5
  69. package/package.json +4 -3
  70. package/src/commands/pr.ts +255 -46
  71. package/src/commands/sessions.ts +88 -6
  72. package/src/commands/take.ts +12 -1
  73. package/src/commands/tasks.ts +41 -2
  74. package/src/design/components.ts +5 -107
  75. package/src/design/favicon.ts +21 -17
  76. package/src/design/render.ts +9 -27
  77. package/src/design/template.ts +42 -0
  78. package/src/design/templates/favicon.svg +17 -0
  79. package/src/design/templates/hand-drawn-figure.css +54 -0
  80. package/src/design/templates/preview.html +27 -0
  81. package/src/design/templates/scrollbar.css +29 -0
  82. package/src/design/templates/status.css +24 -0
  83. package/src/gate/embedded.ts +400 -0
  84. package/src/gate/stages.ts +8 -0
  85. package/src/git/files.ts +5 -2
  86. package/src/indexes/frontmatter.ts +27 -6
  87. package/src/indexes/render.ts +21 -3
  88. package/src/markdown/colons.ts +14 -4
  89. package/src/markdown/heading-case.ts +9 -2
  90. package/src/pr/bijection.ts +8 -0
  91. package/src/pr/evidence.ts +81 -13
  92. package/src/pr/frames.ts +1 -6
  93. package/src/pr/health.ts +63 -15
  94. package/src/pr/lift.ts +77 -9
  95. package/src/pr/links.ts +160 -0
  96. package/src/pr/local.ts +17 -4
  97. package/src/pr/paths.ts +20 -2
  98. package/src/pr/rest.ts +2 -0
  99. package/src/pr/review-scope.ts +28 -0
  100. package/src/pr/review-state.ts +5 -5
  101. package/src/pr/waiver.ts +45 -0
  102. package/src/sessions/stop.ts +145 -0
  103. package/src/sessions/transfer/bundle.ts +1 -1
  104. package/src/take/camera.ts +337 -208
  105. package/src/take/encode.ts +16 -42
  106. package/src/take/film.ts +7 -1
  107. package/src/take/frame-clock.ts +46 -0
  108. package/src/take/log.ts +43 -2
  109. package/src/take/perform.ts +85 -13
  110. package/src/take/plan.ts +26 -5
  111. package/src/take/reel.ts +30 -5
  112. package/src/take/screencast.ts +223 -13
  113. package/src/take/stepper.ts +24 -4
  114. package/src/tasks/holders.ts +198 -0
  115. package/src/tasks/validate.ts +148 -0
  116. package/src/commands/fixtures/gh.sh +0 -64
  117. package/src/take/fixtures/busy.html +0 -107
  118. package/src/take/fixtures/fade.html +0 -49
  119. package/src/take/fixtures/fetch.html +0 -38
  120. package/src/take/fixtures/input.html +0 -138
  121. package/src/take/zoom/NOTICE.md +0 -45
  122. package/src/take/zoom/math-utils.ts +0 -69
  123. package/src/take/zoom/region-utils.ts +0 -55
  124. package/src/take/zoom/spring.ts +0 -133
  125. /package/src/sessions/{transfer/transcript.ts → transcript.ts} +0 -0
@@ -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.
package/src/take/film.ts CHANGED
@@ -172,7 +172,12 @@ async function filmBeat(
172
172
 
173
173
  let route: Route = beat.route
174
174
  let fallback: string | undefined
175
- let filmed: { frames: number; events: readonly LogEvent[]; still: Buffer }
175
+ let filmed: {
176
+ frames: number
177
+ events: readonly LogEvent[]
178
+ lag?: number
179
+ still: Buffer
180
+ }
176
181
  if (beat.route === 'step') {
177
182
  try {
178
183
  filmed = await filmStepped({
@@ -203,6 +208,7 @@ async function filmBeat(
203
208
  route,
204
209
  clip: CLIP_SIZES[route],
205
210
  ...(beat.push === false ? { push: false as const } : {}),
211
+ ...(filmed.lag === undefined ? {} : { lag: filmed.lag }),
206
212
  frames: filmed.frames,
207
213
  events: filmed.events,
208
214
  }
@@ -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
+ }
package/src/take/log.ts CHANGED
@@ -31,6 +31,11 @@ export interface Box {
31
31
  readonly height: number
32
32
  }
33
33
 
34
+ /** A position the page was sent, at the clip time it was sent. */
35
+ export interface SentPoint extends Point {
36
+ readonly t: number
37
+ }
38
+
34
39
  export type LogEvent =
35
40
  | {
36
41
  readonly t: number
@@ -38,6 +43,15 @@ export type LogEvent =
38
43
  readonly from: Point
39
44
  readonly to: Point
40
45
  readonly ms: number
46
+ /**
47
+ * Every position the glide sent, in order. A reader draws the cursor off
48
+ * these rather than off the eased curve, since a page slower than a frame
49
+ * skips points the curve would still draw. Absent in a log written
50
+ * before the field and in a harness log, where the curve stands in.
51
+ */
52
+ readonly points?: readonly SentPoint[]
53
+ /** How long the camera holds aimed at `to` once the glide ends. */
54
+ readonly hold?: number
41
55
  }
42
56
  | {
43
57
  readonly t: number
@@ -103,6 +117,12 @@ export interface TakeLog {
103
117
  readonly clip?: { readonly width: number; readonly height: number }
104
118
  /** False when the beat turned the camera's zoom off. */
105
119
  readonly push?: false
120
+ /**
121
+ * Milliseconds a screencast take shifted every event by, being how long a
122
+ * move took to show in the clip. Absent on the stepped route, which needs
123
+ * no shift, and where the page gave no frame to calibrate on.
124
+ */
125
+ readonly lag?: number
106
126
  readonly frames: number
107
127
  readonly events: readonly LogEvent[]
108
128
  }
@@ -190,13 +210,34 @@ export function along(from: Point, to: Point, progress: number): Point {
190
210
  return { x: from.x + (to.x - from.x) * k, y: from.y + (to.y - from.y) * k }
191
211
  }
192
212
 
193
- /** The pointer's position at a time on the clip's clock, read off the moves. */
213
+ /**
214
+ * The pointer's position at a time on the clip's clock, read off the points
215
+ * each move sent, or off its eased curve when it carries none.
216
+ */
194
217
  export function cursorAt(events: readonly LogEvent[], ms: number): Point {
195
218
  let position = START
196
219
  for (const event of events) {
197
220
  if (event.kind !== 'move') continue
198
221
  if (ms < event.t) break
199
- position = along(event.from, event.to, (ms - event.t) / event.ms)
222
+ position = event.points?.length
223
+ ? sentAt(event.from, event.points, ms)
224
+ : along(event.from, event.to, (ms - event.t) / event.ms)
200
225
  }
201
226
  return position
202
227
  }
228
+
229
+ function sentAt(from: Point, points: readonly SentPoint[], ms: number): Point {
230
+ let before: SentPoint | undefined
231
+ for (const point of points) {
232
+ if (point.t > ms) {
233
+ if (!before) return from
234
+ const k = (ms - before.t) / (point.t - before.t)
235
+ return {
236
+ x: before.x + (point.x - before.x) * k,
237
+ y: before.y + (point.y - before.y) * k,
238
+ }
239
+ }
240
+ before = point
241
+ }
242
+ return before ? { x: before.x, y: before.y } : from
243
+ }
@@ -3,6 +3,7 @@ import {
3
3
  FPS,
4
4
  type LogEvent,
5
5
  type Point,
6
+ type SentPoint,
6
7
  VIEWPORT,
7
8
  along,
8
9
  travelMs,
@@ -28,6 +29,16 @@ const SCROLL_MS = 900
28
29
  /** How long a drag holds still after its press, so the viewer sees the grab land. */
29
30
  const GRAB_MS = 150
30
31
 
32
+ /**
33
+ * The pacing the operator settled by eye in groundwork track
34
+ * `210-film-craft-and-soundtrack`: a rest on the target before a press, a hold
35
+ * on its result before the pointer leaves, and a pause between clicking a
36
+ * field and its first key, so the camera lands before anything changes.
37
+ */
38
+ const REST_MS = 300
39
+ const RESULT_MS = 500
40
+ const TYPE_MS = 400
41
+
31
42
  export type PointerInput =
32
43
  | {
33
44
  readonly type: 'move'
@@ -73,6 +84,12 @@ export interface Stage {
73
84
  press(input: KeyInput): Promise<void>
74
85
  /** Evaluates an expression in the page. */
75
86
  evaluate<T>(expression: string): Promise<T>
87
+ /**
88
+ * Waits for the page's next frame before a glide stamps a point, on a route
89
+ * whose clock runs apart from the page's frames. A stage that steps those
90
+ * frames itself leaves it out.
91
+ */
92
+ align?(): Promise<void>
76
93
  }
77
94
 
78
95
  /** A target the pointer cannot reach, which refuses rather than films a guess. */
@@ -125,13 +142,22 @@ export async function performSteps(
125
142
  ): Promise<void> {
126
143
  for (const step of steps) {
127
144
  switch (step.kind) {
128
- case 'move':
129
- await moveTo(
130
- stage,
131
- state,
132
- 'target' in step ? center(await boxOf(stage, step.target)) : step,
133
- )
145
+ case 'move': {
146
+ const to =
147
+ 'target' in step
148
+ ? center(await boxOf(stage, step.target))
149
+ : { x: step.x, y: step.y }
150
+ if (step.hold === undefined) {
151
+ await moveTo(stage, state, to)
152
+ break
153
+ }
154
+ const glide =
155
+ (await moveTo(stage, state, to, { unlogged: true })) ??
156
+ stillAt(stage, state.pointer)
157
+ await stage.hold(step.hold)
158
+ state.log?.push({ ...glide, hold: step.hold })
134
159
  break
160
+ }
135
161
  case 'click':
136
162
  case 'dblclick':
137
163
  await click(
@@ -141,6 +167,7 @@ export async function performSteps(
141
167
  step.modifiers ?? [],
142
168
  step.kind === 'dblclick' ? 2 : 1,
143
169
  )
170
+ await pause(stage, state, RESULT_MS)
144
171
  break
145
172
  case 'drag':
146
173
  await drag(
@@ -167,6 +194,7 @@ export async function performSteps(
167
194
  case 'fill': {
168
195
  const box = await boxOf(stage, step.target)
169
196
  await click(stage, state, box, [], 1)
197
+ await pause(stage, state, TYPE_MS)
170
198
  const start = stage.now()
171
199
  for (const character of step.text) {
172
200
  await stage.key(character)
@@ -180,6 +208,7 @@ export async function performSteps(
180
208
  chars: step.text.length,
181
209
  ms: stage.now() - start,
182
210
  })
211
+ await pause(stage, state, RESULT_MS)
183
212
  break
184
213
  }
185
214
  case 'scroll': {
@@ -214,25 +243,53 @@ export async function performSteps(
214
243
  }
215
244
  }
216
245
 
246
+ type Glide = Extract<LogEvent, { kind: 'move' }>
247
+
248
+ /**
249
+ * Glides the pointer to `to` and returns the glide, or undefined when the
250
+ * pointer is already there. The glide is logged unless `unlogged`, which a
251
+ * caller sets to log it with more fields itself.
252
+ */
217
253
  async function moveTo(
218
254
  stage: Stage,
219
255
  state: Performance,
220
256
  to: Point,
221
- held?: { readonly modifiers: readonly Modifier[] },
222
- ): Promise<void> {
257
+ options: {
258
+ readonly held?: { readonly modifiers: readonly Modifier[] }
259
+ readonly unlogged?: true
260
+ } = {},
261
+ ): Promise<Glide | undefined> {
223
262
  const from = state.pointer
224
- if (Math.hypot(to.x - from.x, to.y - from.y) < 1) return
263
+ if (Math.hypot(to.x - from.x, to.y - from.y) < 1) return undefined
225
264
  const frames = Math.max(1, Math.round(travelMs(from, to) / FRAME_MS))
226
- const pressed = held
227
- ? { held: true as const, ...withModifiers(held.modifiers) }
265
+ const pressed = options.held
266
+ ? { held: true as const, ...withModifiers(options.held.modifiers) }
228
267
  : {}
229
268
  const start = stage.now()
269
+ const points: SentPoint[] = []
230
270
  await paced(stage, frames, async (frame) => {
231
271
  state.pointer = along(from, to, frame / frames)
272
+ await stage.align?.()
273
+ points.push({ t: stage.now(), ...state.pointer })
232
274
  await stage.pointer({ type: 'move', ...state.pointer, ...pressed })
233
275
  })
234
276
  state.pointer = to
235
- state.log?.push({ t: start, kind: 'move', from, to, ms: stage.now() - start })
277
+ const glide: Glide = {
278
+ t: start,
279
+ kind: 'move',
280
+ from,
281
+ to,
282
+ ms: stage.now() - start,
283
+ points,
284
+ }
285
+ if (!options.unlogged) state.log?.push(glide)
286
+ return glide
287
+ }
288
+
289
+ /** A glide of no length, for a hold aimed where the pointer already rests. */
290
+ function stillAt(stage: Stage, at: Point): Glide {
291
+ const t = stage.now()
292
+ return { t, kind: 'move', from: at, to: at, ms: 0, points: [{ t, ...at }] }
236
293
  }
237
294
 
238
295
  /**
@@ -263,6 +320,7 @@ async function click(
263
320
  ): Promise<void> {
264
321
  const at = center(box)
265
322
  await moveTo(stage, state, at)
323
+ await pause(stage, state, REST_MS)
266
324
  await holding(stage, modifiers, async () => {
267
325
  state.log?.push({
268
326
  t: stage.now(),
@@ -280,6 +338,18 @@ async function click(
280
338
  })
281
339
  }
282
340
 
341
+ /**
342
+ * Lets `ms` of clip time pass while a beat is filmed, and none in setup, which
343
+ * no viewer sees.
344
+ */
345
+ async function pause(
346
+ stage: Stage,
347
+ state: Performance,
348
+ ms: number,
349
+ ): Promise<void> {
350
+ if (state.log) await stage.hold(ms)
351
+ }
352
+
283
353
  /**
284
354
  * Presses on the source, holds a moment so the page reads a grab rather than
285
355
  * a click, glides held to the target, and releases there. The drop target is
@@ -296,12 +366,13 @@ async function drag(
296
366
  const from = center(box)
297
367
  const to = center(drop)
298
368
  await moveTo(stage, state, from)
369
+ await pause(stage, state, REST_MS)
299
370
  await holding(stage, modifiers, async () => {
300
371
  const start = stage.now()
301
372
  const press = { clickCount: 1, ...withModifiers(modifiers) }
302
373
  await stage.pointer({ type: 'down', ...from, ...press })
303
374
  await stage.hold(GRAB_MS)
304
- await moveTo(stage, state, to, { modifiers })
375
+ await moveTo(stage, state, to, { held: { modifiers } })
305
376
  await stage.pointer({ type: 'up', ...to, ...press })
306
377
  state.log?.push({
307
378
  t: start,
@@ -314,6 +385,7 @@ async function drag(
314
385
  ms: stage.now() - start,
315
386
  })
316
387
  })
388
+ await pause(stage, state, RESULT_MS)
317
389
  }
318
390
 
319
391
  /**
package/src/take/plan.ts CHANGED
@@ -18,9 +18,17 @@ interface Held {
18
18
  readonly modifiers?: readonly Modifier[]
19
19
  }
20
20
 
21
+ /**
22
+ * A move's milliseconds at its end with the camera aimed there, absent when the
23
+ * move aims the camera nowhere.
24
+ */
25
+ interface Aimed {
26
+ readonly hold?: number
27
+ }
28
+
21
29
  export type TakeStep =
22
- | { readonly kind: 'move'; readonly target: string }
23
- | { readonly kind: 'move'; readonly x: number; readonly y: number }
30
+ | ({ readonly kind: 'move'; readonly target: string } & Aimed)
31
+ | ({ readonly kind: 'move'; readonly x: number; readonly y: number } & Aimed)
24
32
  | ({ readonly kind: 'click'; readonly target: string } & Held)
25
33
  | ({ readonly kind: 'dblclick'; readonly target: string } & Held)
26
34
  | ({
@@ -196,15 +204,17 @@ function readStep(entry: unknown, at: string): TakeStep {
196
204
  }
197
205
 
198
206
  switch (entry.kind) {
199
- case 'move':
200
- if (target) return { kind: 'move', target }
207
+ case 'move': {
208
+ const aimed = readHold(entry, at)
209
+ if (target) return { kind: 'move', target, ...aimed }
201
210
  if (isNumber(entry.x) && isNumber(entry.y)) {
202
- return { kind: 'move', x: entry.x, y: entry.y }
211
+ return { kind: 'move', x: entry.x, y: entry.y, ...aimed }
203
212
  }
204
213
  throw new Refusal(
205
214
  'bad-step',
206
215
  `${at} is a move with neither a target nor an x and y`,
207
216
  )
217
+ }
208
218
  case 'click':
209
219
  case 'dblclick':
210
220
  return {
@@ -258,6 +268,17 @@ function readStep(entry: unknown, at: string): TakeStep {
258
268
  }
259
269
  }
260
270
 
271
+ function readHold(entry: Record<string, unknown>, at: string): Aimed {
272
+ if (!('hold' in entry)) return {}
273
+ if (!isNumber(entry.hold) || entry.hold < 0) {
274
+ throw new Refusal(
275
+ 'bad-step',
276
+ `${at} is a move whose hold is not a non-negative number of ms`,
277
+ )
278
+ }
279
+ return { hold: entry.hold }
280
+ }
281
+
261
282
  function readModifiers(value: unknown, at: string): Held {
262
283
  if (value === undefined) return {}
263
284
  if (!Array.isArray(value)) {
package/src/take/reel.ts CHANGED
@@ -22,8 +22,15 @@ export const REEL_EVERY = 3
22
22
 
23
23
  export const REEL_FPS = FPS / REEL_EVERY
24
24
 
25
- /** WebP quality, measured at about 1.4 MB for a 9 s reel at this width. */
26
- const QUALITY = 60
25
+ /** The most GitHub renders inline as an image in a comment. */
26
+ export const REEL_MAX_BYTES = 10_000_000
27
+
28
+ /**
29
+ * Quality of the lossy fallback, measured at about 1.4 MB for a 9 s reel at
30
+ * this width. It skips frames whose change is faint, so a fade-out can leave a
31
+ * ghost, and the fallback exists only to keep a long reel under the cap.
32
+ */
33
+ const LOSSY_QUALITY = 60
27
34
 
28
35
  export interface ReelFrame extends Frame {
29
36
  /** Which beat of the take, in plan order. */
@@ -37,6 +44,8 @@ export interface Reel {
37
44
  readonly frames: number
38
45
  readonly seconds: number
39
46
  readonly bytes: number
47
+ /** False when the reel passed the cap lossless and was encoded again lossy. */
48
+ readonly lossless?: boolean
40
49
  }
41
50
 
42
51
  /**
@@ -75,11 +84,28 @@ export async function* reelFrames(
75
84
  }
76
85
  }
77
86
 
78
- /** Renders the reel of a filmed take to `file`, an animated WebP. */
87
+ /**
88
+ * Renders the reel of a filmed take to `file`, an animated WebP. It encodes
89
+ * lossless, since the lossy encoder skips a frame whose change is faint and
90
+ * never repaints the last copy of an element that faded out. A reel past
91
+ * `maxBytes` is encoded again lossy so a long reel still posts.
92
+ */
79
93
  export async function renderReel(
80
94
  out: string,
81
95
  beats: readonly FilmedBeat[],
82
96
  file: string,
97
+ maxBytes: number = REEL_MAX_BYTES,
98
+ ): Promise<Reel> {
99
+ const lossless = await encode(out, beats, file, true)
100
+ if (lossless.bytes <= maxBytes) return { ...lossless, lossless: true }
101
+ return { ...(await encode(out, beats, file, false)), lossless: false }
102
+ }
103
+
104
+ async function encode(
105
+ out: string,
106
+ beats: readonly FilmedBeat[],
107
+ file: string,
108
+ lossless: boolean,
83
109
  ): Promise<Reel> {
84
110
  const first = beats[0]
85
111
  if (!first) throw new Error('a reel needs at least one filmed beat')
@@ -105,8 +131,7 @@ export async function renderReel(
105
131
  '-',
106
132
  '-c:v',
107
133
  'libwebp_anim',
108
- '-quality',
109
- String(QUALITY),
134
+ ...(lossless ? ['-lossless', '1'] : ['-quality', String(LOSSY_QUALITY)]),
110
135
  '-loop',
111
136
  '0',
112
137
  '-f',