@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.
@@ -5,6 +5,7 @@ import { INSTALL_BROWSER, isEngineMissing } from '@/browser/engine'
5
5
  import { readTakePlan } from '@/take/plan'
6
6
  import type { TakePlanRefusal } from '@/take/plan'
7
7
  import type { TakeRefusal } from '@/take/film'
8
+ import { renderReel } from '@/take/reel'
8
9
  import {
9
10
  intro,
10
11
  logError,
@@ -24,6 +25,7 @@ type Take = typeof import('@/take/film')
24
25
 
25
26
  interface RunOptions {
26
27
  readonly out: string
28
+ readonly reel?: string
27
29
  readonly json?: boolean
28
30
  }
29
31
 
@@ -60,14 +62,19 @@ export function register(program: Command): void {
60
62
  .argument('<plan>', 'JSON file naming the beats and their steps')
61
63
  .requiredOption('--out <dir>', 'Folder the clips, logs, and tracks land in')
62
64
  .helpOption('-h, --help', 'Show this help message')
65
+ .option(
66
+ '--reel <file>',
67
+ 'Also join the beats into one animated WebP with the pointer drawn',
68
+ )
63
69
  .option('--json', 'Add a machine-readable record on stdout')
64
70
  .addHelpText(
65
71
  'after',
66
72
  [
67
73
  '',
68
74
  'Films each beat from a fresh load of the page into its own folder under',
69
- '--out, numbered in plan order: clip.mp4, log.json, and camera.json.',
70
- 'take.json beside them lists every beat and the route that filmed it.',
75
+ '--out, numbered in plan order: clip.mp4, log.json, camera.json, and',
76
+ "still.jpg, the clip's closing frame. take.json beside them lists every",
77
+ 'beat and the route that filmed it.',
71
78
  '',
72
79
  'A step beat is filmed by frame stepping in chrome-headless-shell, at',
73
80
  '3840x2160 and exactly 60 fps, with the page clock advancing one frame',
@@ -77,7 +84,13 @@ export function register(program: Command): void {
77
84
  '',
78
85
  'No cursor is drawn in a clip. The edit draws it from the log at a',
79
86
  'constant size, and the camera track carries the cursor and click ring',
80
- 'per frame beside the view. Canon renders nothing past the clip.',
87
+ 'per frame beside the view. Canon renders nothing past the clip, save',
88
+ 'the reel below.',
89
+ '',
90
+ '--reel <file> joins every beat in plan order into one looping animated',
91
+ 'WebP at 960 wide and 20 fps, keeping every third frame, with the cursor',
92
+ 'and click ring drawn from each camera track. It carries no zoom, so a',
93
+ "reviewer sees the whole page. clip.mp4 stays each beat's raw take.",
81
94
  '',
82
95
  ' {',
83
96
  ' "beats": [',
@@ -118,14 +131,16 @@ export function register(program: Command): void {
118
131
  ' server-unreachable nothing answered at the URL',
119
132
  ' target-missing a step names a target the page does not carry',
120
133
  ' take-failed the take failed against a reachable page',
134
+ ' reel-failed every beat was filmed and the reel did not encode',
121
135
  '',
122
136
  'Exit codes:',
123
- ' 0 every beat was filmed',
137
+ ' 0 every beat was filmed, and the reel written when asked for',
124
138
  ' 1 refused, with the reason on stderr or in the JSON record',
125
139
  '',
126
140
  'Examples:',
127
141
  ' canon take http://localhost:4173 plan.json --out .canon/tmp/demo/takes',
128
142
  ' canon take http://localhost:4173 plan.json --out takes --json',
143
+ ' canon take http://localhost:4173 plan.json --out takes --reel takes/reel.webp',
129
144
  '',
130
145
  ].join('\n'),
131
146
  )
@@ -222,16 +237,37 @@ async function runTake(
222
237
  )
223
238
  if (beat.fallback) logWarn(` filmed by screencast: ${beat.fallback}`)
224
239
  }
225
- outro()
226
240
 
227
- emit(emitJson, {
241
+ const filmed = {
228
242
  url,
229
243
  plan: path,
230
244
  out: result.out,
231
245
  beats: result.beats,
232
246
  durationMs: result.durationMs,
233
- })
234
- return 0
247
+ }
248
+ if (!opts.reel) {
249
+ outro()
250
+ emit(emitJson, filmed)
251
+ return 0
252
+ }
253
+
254
+ logStep('Reel')
255
+ try {
256
+ const reel = await renderReel(result.out, result.beats, resolve(opts.reel))
257
+ logInfo(
258
+ `${reel.file} ${plural(reel.frames, 'frame')} ${reel.seconds} s ${(reel.bytes / 1e6).toFixed(1)} MB`,
259
+ )
260
+ outro()
261
+ emit(emitJson, { ...filmed, reel })
262
+ return 0
263
+ } catch (error) {
264
+ const message = error instanceof Error ? error.message : String(error)
265
+ logWarn('Every beat was filmed, and the reel did not encode.')
266
+ logWarn(message.split('\n')[0] ?? '')
267
+ outro()
268
+ emit(emitJson, { ...filmed, reason: 'reel-failed', message })
269
+ return 1
270
+ }
235
271
  }
236
272
 
237
273
  /**
@@ -1,3 +1,5 @@
1
+ import { isPainted, type PaintedPatterns } from '@/pr/painted'
2
+
1
3
  /** The path segment that marks an image as evidence a pull request should compare. */
2
4
  const EVIDENCE_SEGMENT = 'evidence'
3
5
 
@@ -16,9 +18,22 @@ const PREVIEW_PREFIX = '**Preview:**'
16
18
  /** How the address line names the branch's server on the operator's own machine, after any hosted segment. */
17
19
  const LOCAL_PREFIX = '**Local preview:**'
18
20
 
21
+ /** How the reel line under the addresses opens. */
22
+ const REEL_PREFIX = '**Reel** filmed at'
23
+
24
+ /** The reel line as `renderEvidenceBody` writes it, read back for the link and the head label. */
25
+ const REEL_LINE =
26
+ /^\*\*Reel\*\* filmed at `([0-9a-f]{7,40})`:<br><img src="([^"]+)"/
27
+
28
+ /** The width a reel is filmed at, which it asks for in the comment. */
29
+ const REEL_WIDTH = 960
30
+
19
31
  /** What joins the hosted and local segments on the one address line. */
20
32
  const ADDRESS_SEPARATOR = ' · '
21
33
 
34
+ /** The summary of the block holding every case whose pixels matched its base. */
35
+ const FOLDED_SUMMARY = 'Within tolerance'
36
+
22
37
  /** The heading every body opens with. */
23
38
  const EVIDENCE_HEADING = '## Evidence'
24
39
 
@@ -61,6 +76,8 @@ export interface EvidenceItem {
61
76
  readonly width?: number
62
77
  /** Pixel width of the base image, absent for an added case or an unread header. */
63
78
  readonly baseWidth?: number
79
+ /** Whether the head differs from its base by no more than sub-visual noise. Absent when the pair was not compared. */
80
+ readonly withinTolerance?: boolean
64
81
  }
65
82
 
66
83
  export interface EvidenceState {
@@ -144,9 +161,9 @@ function rawUrl(repo: string, sha: string, path: string): string {
144
161
  }
145
162
 
146
163
  /**
147
- * The most pixels a screenshot asks for. GitHub sizes a comment table to its
148
- * content and clamps an image to its cell, so a width past any comment fills
149
- * the cell, where `width="100%"` leaves a narrow screenshot narrow. An image
164
+ * The most pixels a screenshot asks for. GitHub clamps an image to the comment
165
+ * column, so a width past any comment fills the column, where `width="100%"`
166
+ * would stretch a narrow screenshot to fill it too. An image
150
167
  * asks for the smaller of its own width and this ceiling, so a small crop
151
168
  * shows at its true size and is never scaled up. An image whose width was not
152
169
  * read asks for the ceiling.
@@ -171,7 +188,7 @@ export function readPngWidth(bytes: Uint8Array): number | undefined {
171
188
  return width > 0 ? width : undefined
172
189
  }
173
190
 
174
- /** A table cell and an attribute value share one escape, since a stem is a path segment placed in both. */
191
+ /** A label and an attribute value share one escape, since a stem is a path segment placed in both. The pipe stays escaped, since a label once sat in a table cell. */
175
192
  function escapeCell(text: string): string {
176
193
  return text
177
194
  .replaceAll('&', '&amp;')
@@ -238,14 +255,21 @@ function commitLink(repo: string, sha: string): string {
238
255
  return `[\`${sha.slice(0, SHORT_SHA_LENGTH)}\`](https://github.com/${repo}/commit/${sha})`
239
256
  }
240
257
 
241
- function baseImage(item: EvidenceItem, repo: string, base: string): string {
242
- return item.added
243
- ? '*(new)*'
244
- : evidenceImage(
245
- rawUrl(repo, base, item.path),
246
- `${escapeCell(item.stem)} base`,
247
- item.baseWidth,
248
- )
258
+ /** The base folded under its head, so a reviewer reads the new state first and opens the old one on a click. */
259
+ function baseFold(item: EvidenceItem, repo: string, base: string): string[] {
260
+ return [
261
+ '<details>',
262
+ '<summary>Base</summary>',
263
+ '',
264
+ evidenceImage(
265
+ rawUrl(repo, base, item.path),
266
+ `${escapeCell(item.stem)} base`,
267
+ item.baseWidth,
268
+ ),
269
+ '',
270
+ '</details>',
271
+ '',
272
+ ]
249
273
  }
250
274
 
251
275
  function headImage(item: EvidenceItem, repo: string, head: string): string {
@@ -256,25 +280,45 @@ function headImage(item: EvidenceItem, repo: string, head: string): string {
256
280
  )
257
281
  }
258
282
 
283
+ /**
284
+ * One case as a label line, its head image on a line of its own, and its base
285
+ * folded beneath. An added case has no base, so it shows its head alone, and
286
+ * its label carries the new mark only where the block also holds a base for
287
+ * the mark to set it apart from.
288
+ */
289
+ function caseLines(
290
+ label: string,
291
+ item: EvidenceItem,
292
+ hasBase: boolean,
293
+ repo: string,
294
+ base: string,
295
+ head: string,
296
+ ): string[] {
297
+ const mark = item.added && hasBase ? ' *(new)*' : ''
298
+ return [
299
+ `**${label}**${mark}`,
300
+ '',
301
+ headImage(item, repo, head),
302
+ '',
303
+ ...(item.added ? [] : baseFold(item, repo, base)),
304
+ ]
305
+ }
306
+
259
307
  function block(
260
308
  opener: string,
261
309
  summary: string,
262
- header: readonly string[],
263
- rows: readonly string[],
310
+ cases: readonly string[][],
264
311
  ): string {
265
312
  return [
266
313
  opener,
267
314
  `<summary>${summary}</summary>`,
268
315
  '',
269
- `| ${header.join(' | ')} |`,
270
- `| ${header.map(() => '---').join(' | ')} |`,
271
- ...rows,
272
- '',
316
+ ...cases.flat(),
273
317
  '</details>',
274
318
  ].join('\n')
275
319
  }
276
320
 
277
- /** One block for the cases of a state that did not name a view, one case per row. */
321
+ /** One block for the cases of a state that did not name a view, each labeled by its name. */
278
322
  function renderFlat(
279
323
  label: string,
280
324
  items: readonly EvidenceItem[],
@@ -284,22 +328,13 @@ function renderFlat(
284
328
  opener: string,
285
329
  ): string {
286
330
  const hasBase = items.some((item) => !item.added)
287
- const rows = items.map((item) => {
288
- const stem = escapeCell(item.stem)
289
- const after = headImage(item, repo, head)
290
- return hasBase
291
- ? `| ${stem} | ${baseImage(item, repo, base)} | ${after} |`
292
- : `| ${stem} | ${after} |`
293
- })
294
- return block(
295
- opener,
296
- `${label} (${items.length})`,
297
- hasBase ? ['Case', 'Base', 'Head'] : ['Case', 'Head'],
298
- rows,
331
+ const cases = items.map((item) =>
332
+ caseLines(escapeCell(item.stem), item, hasBase, repo, base, head),
299
333
  )
334
+ return block(opener, `${label} (${items.length})`, cases)
300
335
  }
301
336
 
302
- /** One block for a view, a width per row and a theme per column, each theme taking a base column when the view already exists at the base. */
337
+ /** One block for a view, its cases by width and then dark before light, each labeled by width and theme, so a theme missing at a width renders nothing. */
303
338
  function renderView(
304
339
  label: string,
305
340
  cases: readonly ViewCase[],
@@ -308,38 +343,29 @@ function renderView(
308
343
  head: string,
309
344
  opener: string,
310
345
  ): string {
311
- const themes = THEMES.filter((theme) =>
312
- cases.some((entry) => entry.theme === theme),
313
- )
314
346
  const hasBase = cases.some((entry) => !entry.item.added)
315
- const widths = [...new Set(cases.map((entry) => entry.width))].sort(
316
- compareWidths,
347
+ const ordered = [...cases].sort(
348
+ (a, b) =>
349
+ compareWidths(a.width, b.width) ||
350
+ THEMES.indexOf(a.theme) - THEMES.indexOf(b.theme),
317
351
  )
318
- const header = [
319
- 'Width',
320
- ...themes.flatMap((theme) => {
321
- const name = theme === 'dark' ? 'Dark' : 'Light'
322
- return hasBase ? [`${name} base`, `${name} head`] : [name]
323
- }),
324
- ]
325
- const rows = widths.map((width) => {
326
- const cells = themes.flatMap((theme) => {
327
- const found = cases.find(
328
- (entry) => entry.theme === theme && entry.width === width,
329
- )
330
- if (found === undefined) return hasBase ? ['', ''] : ['']
331
- const after = headImage(found.item, repo, head)
332
- return hasBase ? [baseImage(found.item, repo, base), after] : [after]
333
- })
334
- return `| ${[width, ...cells].join(' | ')} |`
335
- })
336
- return block(opener, `${label} (${cases.length})`, header, rows)
352
+ const lines = ordered.map((entry) =>
353
+ caseLines(
354
+ `${entry.width} · ${entry.theme}`,
355
+ entry.item,
356
+ hasBase,
357
+ repo,
358
+ base,
359
+ head,
360
+ ),
361
+ )
362
+ return block(opener, `${label} (${cases.length})`, lines)
337
363
  }
338
364
 
339
365
  /**
340
366
  * Renders the blocks of one state: a block per view the case names split
341
367
  * into, then one flat block for the names that did not. Nothing is dropped,
342
- * so a state of unparsed names reads as the flat table it always was.
368
+ * so a state of unparsed names reads as one flat block.
343
369
  */
344
370
  function renderState(
345
371
  entry: EvidenceState,
@@ -387,18 +413,57 @@ function renderState(
387
413
  return blocks
388
414
  }
389
415
 
416
+ /** A folded case's label, naming its state and view, since the block gathers cases from every state. */
417
+ function foldedLabel(state: string, item: EvidenceItem): string {
418
+ const name = readCaseName(item.stem)
419
+ const caseLabel =
420
+ name === undefined
421
+ ? escapeCell(item.stem)
422
+ : `${escapeCell(name.view)} · ${name.width} · ${name.theme}`
423
+ return state === '' ? caseLabel : `${escapeCell(state)} / ${caseLabel}`
424
+ }
425
+
426
+ /** One closed block for every case whose pixels matched its base, so the open blocks hold only what visibly moved. */
427
+ function renderFolded(
428
+ states: readonly EvidenceState[],
429
+ repo: string,
430
+ base: string,
431
+ head: string,
432
+ ): string {
433
+ const cases = states.flatMap((entry) =>
434
+ entry.items.map((item) =>
435
+ caseLines(foldedLabel(entry.state, item), item, false, repo, base, head),
436
+ ),
437
+ )
438
+ return block('<details>', `${FOLDED_SUMMARY} (${cases.length})`, cases)
439
+ }
440
+
441
+ /** A reel embedded in the comment, and the head it was filmed at. */
442
+ export interface EvidenceReel {
443
+ readonly link: string
444
+ readonly head: string
445
+ }
446
+
447
+ function reelLine(reel: EvidenceReel): string {
448
+ const head = reel.head.slice(0, SHORT_SHA_LENGTH)
449
+ return `${REEL_PREFIX} \`${head}\`:<br><img src="${escapeCell(reel.link)}" width="${REEL_WIDTH}" alt="reel filmed at ${head}">`
450
+ }
451
+
390
452
  export function evidenceMarker(head: string): string {
391
453
  return `${MARKER_PREFIX} head=${head} -->`
392
454
  }
393
455
 
394
456
  /**
395
457
  * Renders the whole comment body: one `<details>` block per view within each
396
- * state, a width per row with a column per theme, a flat Base/Head table for
397
- * the case names that do not split into a view, and the trailing marker naming
398
- * the head this body describes.
458
+ * state, a flat block for the case names that do not split into a view, and
459
+ * the trailing marker naming the head this body describes. Each case shows its
460
+ * head at the comment's width with its base folded beneath, since a table
461
+ * split the column between the two and shrank both to a quarter of their size.
399
462
  * Every state opens while the comment carries at most `EVIDENCE_OPEN_LIMIT`
400
- * images, one per row for the head plus one per row not added, and every state
401
- * closes past it, so a comment is never half open. A visible line under
463
+ * head images, and every state closes past it, so a comment is never half
464
+ * open. A folded base takes no room until opened, so it is not counted. A case
465
+ * whose pixels matched its base moves to one closed `Within tolerance` block
466
+ * after the states, still named and embedded, and is not counted either. A visible line under
402
467
  * `## Evidence` names the base and head as short shas, each linking its commit. Every image URL is
403
468
  * pinned to a commit sha rather than a branch, so the comment keeps showing
404
469
  * what it claimed even after the branch moves.
@@ -412,6 +477,9 @@ export function evidenceMarker(head: string): string {
412
477
  *
413
478
  * A checklist closes the body, below the comparison it annotates, so one
414
479
  * comment carries the addresses, the screenshots, and what to look at.
480
+ *
481
+ * A reel sits on its own line under the addresses, labeled with the head it
482
+ * was filmed at, so a reel carried past a later push never reads as current.
415
483
  */
416
484
  export function renderEvidenceBody(
417
485
  states: readonly EvidenceState[],
@@ -421,6 +489,7 @@ export function renderEvidenceBody(
421
489
  preview?: string,
422
490
  checklist?: string,
423
491
  local?: string,
492
+ reel?: EvidenceReel,
424
493
  ): string {
425
494
  const segments = [
426
495
  ...(preview === undefined ? [] : [`${PREVIEW_PREFIX} ${preview}`]),
@@ -430,6 +499,7 @@ export function renderEvidenceBody(
430
499
  EVIDENCE_HEADING,
431
500
  '',
432
501
  ...(segments.length === 0 ? [] : [segments.join(ADDRESS_SEPARATOR), '']),
502
+ ...(reel === undefined ? [] : [reelLine(reel), '']),
433
503
  ]
434
504
  const closing =
435
505
  checklist === undefined
@@ -439,17 +509,21 @@ export function renderEvidenceBody(
439
509
  return [...opening, ...closing, evidenceMarker(head)].join('\n')
440
510
  }
441
511
 
442
- const imageCount = states.reduce(
443
- (total, entry) =>
444
- total + entry.items.reduce((sum, item) => sum + (item.added ? 1 : 2), 0),
445
- 0,
446
- )
512
+ const split = (keep: (item: EvidenceItem) => boolean): EvidenceState[] =>
513
+ states
514
+ .map((entry) => ({ ...entry, items: entry.items.filter(keep) }))
515
+ .filter((entry) => entry.items.length > 0)
516
+ const open = split((item) => item.withinTolerance !== true)
517
+ const folded = split((item) => item.withinTolerance === true)
518
+
519
+ const headCount = open.reduce((total, entry) => total + entry.items.length, 0)
447
520
  const opener =
448
- imageCount <= EVIDENCE_OPEN_LIMIT ? '<details open>' : '<details>'
521
+ headCount <= EVIDENCE_OPEN_LIMIT ? '<details open>' : '<details>'
449
522
 
450
- const sections = states.flatMap((entry) =>
451
- renderState(entry, repo, base, head, opener),
452
- )
523
+ const sections = [
524
+ ...open.flatMap((entry) => renderState(entry, repo, base, head, opener)),
525
+ ...(folded.length === 0 ? [] : [renderFolded(folded, repo, base, head)]),
526
+ ]
453
527
 
454
528
  return [
455
529
  ...opening,
@@ -464,18 +538,18 @@ export function renderEvidenceBody(
464
538
 
465
539
  /**
466
540
  * How many cases a rendered body carries. Every case has exactly one head
467
- * image, so a row counts the head images it holds, which is one in the flat
468
- * table and up to two in a view's row. A row from before the alt text named
469
- * its head image counts as one case when it matches the old row shape, so a
470
- * comment posted before the change still counts its cases and the
471
- * `would-empty` guard holds.
541
+ * image, so the count is the head images on any line, whether a table row
542
+ * holding one or two or a line of its own. A table row from before the alt
543
+ * text named its head image counts as one case when it matches the old row
544
+ * shape, so a comment posted in any earlier layout still counts its cases and
545
+ * the `would-empty` guard holds.
472
546
  */
473
547
  export function countEvidenceCases(body: string): number {
474
548
  let total = 0
475
549
  for (const line of body.split('\n')) {
476
- if (!line.startsWith('| ')) continue
477
550
  const heads = line.match(HEAD_IMAGE_PATTERN)?.length ?? 0
478
- total += heads > 0 ? heads : LEGACY_ROW_PATTERN.test(line) ? 1 : 0
551
+ if (heads > 0) total += heads
552
+ else if (line.startsWith('| ') && LEGACY_ROW_PATTERN.test(line)) total += 1
479
553
  }
480
554
  return total
481
555
  }
@@ -529,6 +603,34 @@ export function readOwed(input: OwedInput): OwedStep[] {
529
603
  return owed
530
604
  }
531
605
 
606
+ /**
607
+ * Design references are redrawn rather than captured, so recapturing them
608
+ * neither owes nor stands in for an evidence capture.
609
+ */
610
+ const DESIGN_FOLDER = 'canon/design/'
611
+
612
+ /**
613
+ * The painted paths a pull request changed while no evidence image did, in a
614
+ * project whose base keeps captures. Undefined when the base keeps none or
615
+ * was not read, since an empty list reads as checked and clean. Any changed
616
+ * evidence image clears the list, since this names a skipped capture rather
617
+ * than judging which views a capture covered.
618
+ */
619
+ export function readUncaptured(
620
+ changed: readonly string[],
621
+ patterns: PaintedPatterns,
622
+ keepsCaptures: boolean | undefined,
623
+ ): string[] | undefined {
624
+ if (keepsCaptures !== true) return undefined
625
+ if (changed.some(isEvidencePath)) return []
626
+ return changed.filter(
627
+ (path) =>
628
+ evidencePosition(path) === -1 &&
629
+ !path.startsWith(DESIGN_FOLDER) &&
630
+ isPainted(path, patterns),
631
+ )
632
+ }
633
+
532
634
  /**
533
635
  * The REST id of the comment this module already posted, read off its `url`
534
636
  * field, since `gh pr view --json comments` reports only a GraphQL id there
@@ -639,6 +741,34 @@ export function findEvidenceLocal(
639
741
  return marked === undefined ? undefined : readAddresses(marked.body).local
640
742
  }
641
743
 
744
+ /** The reel the marked comment already embeds, with the head it was filmed at. */
745
+ export function findEvidenceReel(
746
+ comments: readonly EvidenceComment[],
747
+ ): EvidenceReel | undefined {
748
+ const marked = findMarked(comments)
749
+ if (marked === undefined) return undefined
750
+ for (const line of marked.body.split('\n')) {
751
+ const match = REEL_LINE.exec(line.trim())
752
+ if (match?.[1] !== undefined && match[2] !== undefined) {
753
+ return { link: match[2].replaceAll('&amp;', '&'), head: match[1] }
754
+ }
755
+ }
756
+ return undefined
757
+ }
758
+
759
+ /**
760
+ * The reel a render embeds: a passed link, filmed at this head, replaces
761
+ * whatever the comment carries, and with none passed the posted reel carries
762
+ * forward under the head it was filmed at.
763
+ */
764
+ export function nextEvidenceReel(
765
+ link: string | undefined,
766
+ head: string,
767
+ comments: readonly EvidenceComment[],
768
+ ): EvidenceReel | undefined {
769
+ return link === undefined ? findEvidenceReel(comments) : { link, head }
770
+ }
771
+
642
772
  /**
643
773
  * The checklist the marked comment already carries, read back out of its
644
774
  * delimiters. The handoff file is deleted once posted, so every later render
package/src/pr/frames.ts CHANGED
@@ -9,6 +9,16 @@ export const LEGACY_FRAMES_BRANCH = 'canon-frames'
9
9
  /** The eight bytes every PNG opens with. A frame that lacks them embeds as a broken image. */
10
10
  const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
11
11
 
12
+ /** A WebP opens on `RIFF`, four bytes of size, then `WEBP`. */
13
+ const RIFF = [0x52, 0x49, 0x46, 0x46]
14
+ const WEBP = [0x57, 0x45, 0x42, 0x50]
15
+
16
+ /**
17
+ * The largest reel an add takes, GitHub's cap on an image in a comment, so a
18
+ * reel that would not render is refused before it reaches the branch.
19
+ */
20
+ export const REEL_MAX_BYTES = 10_000_000
21
+
12
22
  const SHORT_HEAD_LENGTH = 7
13
23
 
14
24
  const HEAD_PATTERN = /^[0-9a-f]{7,40}$/
@@ -86,9 +96,16 @@ export interface FrameInput {
86
96
  readonly box?: number
87
97
  readonly reference?: string
88
98
  readonly theme?: FrameTheme
99
+ /** The pass's reel, in place of a box or a reference. */
100
+ readonly reel?: boolean
89
101
  readonly bytes: Uint8Array
90
102
  }
91
103
 
104
+ export type ReelRead =
105
+ | { readonly kind: 'read'; readonly bytes: Uint8Array }
106
+ | { readonly kind: 'unreadable' }
107
+ | { readonly kind: 'too-large'; readonly size: number }
108
+
92
109
  export type PushResult =
93
110
  | {
94
111
  readonly kind: 'pushed'
@@ -206,6 +223,25 @@ export function referencePath(
206
223
  return `pr-${number}/${head.slice(0, SHORT_HEAD_LENGTH)}/${pass}/ref-${slug}.png`
207
224
  }
208
225
 
226
+ /** Where a pass's reel lands, one per pass beside its box frames. */
227
+ export function reelPath(number: number, head: string, pass: string): string {
228
+ return `pr-${number}/${head.slice(0, SHORT_HEAD_LENGTH)}/${pass}/reel.webp`
229
+ }
230
+
231
+ /** The reel's bytes, refusing a file that is absent, not a WebP, or past the cap. */
232
+ export async function readReel(path: string): Promise<ReelRead> {
233
+ const bytes = await readFile(path).catch(() => undefined)
234
+ if (bytes === undefined || bytes.length <= 12) return { kind: 'unreadable' }
235
+ const webp =
236
+ RIFF.every((byte, index) => bytes[index] === byte) &&
237
+ WEBP.every((byte, index) => bytes[index + 8] === byte)
238
+ if (!webp) return { kind: 'unreadable' }
239
+ if (bytes.length > REEL_MAX_BYTES) {
240
+ return { kind: 'too-large', size: bytes.length }
241
+ }
242
+ return { kind: 'read', bytes: new Uint8Array(bytes) }
243
+ }
244
+
209
245
  /** The frame's bytes, or undefined when the file is absent, empty, or not a PNG. */
210
246
  export async function readFrame(path: string): Promise<Uint8Array | undefined> {
211
247
  const bytes = await readFile(path).catch(() => undefined)
@@ -227,8 +263,9 @@ export async function pushFrame(
227
263
  api: FramesApi,
228
264
  input: FrameInput,
229
265
  ): Promise<PushResult> {
230
- const path =
231
- input.reference === undefined
266
+ const path = input.reel
267
+ ? reelPath(input.number, input.head, input.pass)
268
+ : input.reference === undefined
232
269
  ? framePath(
233
270
  input.number,
234
271
  input.head,
@@ -241,8 +278,9 @@ export async function pushFrame(
241
278
  const blob = await api.createBlob(Buffer.from(input.bytes).toString('base64'))
242
279
  if (blob === undefined) return { kind: 'refused', reason: 'push-failed' }
243
280
  const entry: TreeEntry = { path, mode: '100644', type: 'blob', sha: blob }
244
- const message =
245
- input.reference === undefined
281
+ const message = input.reel
282
+ ? `chore(frames): add reel of #${input.number}`
283
+ : input.reference === undefined
246
284
  ? `chore(frames): add box ${input.box} of #${input.number}`
247
285
  : `chore(frames): add reference ${input.reference} of #${input.number}`
248
286
 
@@ -0,0 +1,44 @@
1
+ import pixelmatch from 'pixelmatch'
2
+ import { PNG } from 'pngjs'
3
+
4
+ /** How different two colors must be for a pixel to count, where 1 is black against white. Playwright's 0.2 passed a real contrast fix. */
5
+ const COLOR_THRESHOLD = 0.1
6
+
7
+ /** The side of the square the count is taken over, so noise scattered across the image never adds up while a compact change does. */
8
+ const WINDOW_SIZE = 16
9
+
10
+ /** The most differing pixels any window may hold and still read as noise. Measured on Frameboard, noise counted 2 or fewer and real changes 14 or more. */
11
+ const MAX_WINDOW_PIXELS = 6
12
+
13
+ function decode(bytes: Uint8Array): PNG | undefined {
14
+ try {
15
+ return PNG.sync.read(Buffer.from(bytes))
16
+ } catch {
17
+ return undefined
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Whether two PNG captures differ by no more than sub-visual noise. Anti-aliased
23
+ * pixels are counted rather than skipped, since antialias detection reads a
24
+ * changed 1px border as an edge artifact, and the windowed count separates
25
+ * scattered edge noise from a compact change on its own. A pair that does not decode or differs in size reads as changed, so
26
+ * nothing the comparison cannot read is ever folded.
27
+ */
28
+ export function isWithinTolerance(base: Uint8Array, head: Uint8Array): boolean {
29
+ const before = decode(base)
30
+ const after = decode(head)
31
+ if (before === undefined || after === undefined) return false
32
+ if (before.width !== after.width || before.height !== after.height) {
33
+ return false
34
+ }
35
+ const count = pixelmatch(
36
+ before.data,
37
+ after.data,
38
+ null,
39
+ before.width,
40
+ before.height,
41
+ { threshold: COLOR_THRESHOLD, includeAA: true, windowSize: WINDOW_SIZE },
42
+ )
43
+ return count <= MAX_WINDOW_PIXELS
44
+ }