@erclx/canon 5.52.0 → 5.53.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 (127) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +1 -4
  3. package/claude/.claude-plugin/plugin.json +1 -1
  4. package/claude/skills/canon-cli/SKILL.md +0 -3
  5. package/claude/skills/context-fold/REQUIREMENT.md +0 -1
  6. package/claude/skills/design-taste/references/kinds.md +0 -1
  7. package/claude/skills/draw-figure/REQUIREMENT.md +2 -3
  8. package/claude/skills/draw-figure/SKILL.md +4 -5
  9. package/claude/skills/draw-figure/references/figures.md +0 -1
  10. package/claude/skills/draw-figure/references/hand-drawn.md +1 -1
  11. package/claude/skills/pick-by-eye/REQUIREMENT.md +0 -1
  12. package/claude/skills/pick-by-eye/SKILL.md +1 -1
  13. package/claude/skills/teach-craft/REQUIREMENT.md +30 -0
  14. package/claude/skills/teach-craft/SKILL.md +39 -0
  15. package/claude/skills/teach-craft/references/lesson-craft.md +52 -0
  16. package/claude/skills/{teach-workspace → teach-craft}/references/pedagogy.md +12 -4
  17. package/docs/agents/audits.md +0 -1
  18. package/docs/target/migrations.md +10 -1
  19. package/docs/workflow/parallel-features.md +1 -1
  20. package/docs/workflow/skill-map-on-request.md +10 -22
  21. package/docs/workflow/skill-map.md +12 -14
  22. package/package.json +1 -4
  23. package/scripts/core/regen-web-previews.ts +2 -67
  24. package/src/audits/catalog.ts +0 -1
  25. package/src/browser/engine.ts +3 -6
  26. package/src/capture/render.ts +1 -1
  27. package/src/claude/cases/authoring.ts +0 -12
  28. package/src/claude/cases/misc.ts +0 -17
  29. package/src/claude/cases/workflow.ts +2 -38
  30. package/src/claude/skills-audit.ts +0 -1
  31. package/src/cli/help.ts +0 -7
  32. package/src/cli.ts +0 -6
  33. package/src/commands/records.ts +0 -1
  34. package/src/commands/serve.ts +2 -2
  35. package/src/design/components.ts +8 -1514
  36. package/src/design/css.ts +5 -6
  37. package/src/design/fonts.ts +3 -5
  38. package/src/driver/drive.ts +3 -4
  39. package/src/driver/steps.ts +2 -5
  40. package/src/inventory/walk.ts +1 -1
  41. package/src/markdown/scope.ts +4 -31
  42. package/src/records/validate.ts +4 -176
  43. package/src/sandbox/equivalence.ts +0 -5
  44. package/claude/skills/draft-and-pick/REQUIREMENT.md +0 -26
  45. package/claude/skills/draft-and-pick/SKILL.md +0 -11
  46. package/claude/skills/draft-doc/REQUIREMENT.md +0 -26
  47. package/claude/skills/draft-doc/SKILL.md +0 -11
  48. package/claude/skills/draft-figure/REQUIREMENT.md +0 -26
  49. package/claude/skills/draft-figure/SKILL.md +0 -11
  50. package/claude/skills/draft-identity/REQUIREMENT.md +0 -26
  51. package/claude/skills/draft-identity/SKILL.md +0 -11
  52. package/claude/skills/draft-ready/REQUIREMENT.md +0 -26
  53. package/claude/skills/draft-ready/SKILL.md +0 -11
  54. package/claude/skills/draft-screencast/REQUIREMENT.md +0 -26
  55. package/claude/skills/draft-screencast/SKILL.md +0 -11
  56. package/claude/skills/draft-slides/REQUIREMENT.md +0 -26
  57. package/claude/skills/draft-slides/SKILL.md +0 -11
  58. package/claude/skills/make-deck/REQUIREMENT.md +0 -36
  59. package/claude/skills/make-deck/SKILL.md +0 -80
  60. package/claude/skills/make-deck/references/deck.md +0 -183
  61. package/claude/skills/read-frames/REQUIREMENT.md +0 -30
  62. package/claude/skills/read-frames/SKILL.md +0 -55
  63. package/claude/skills/record-screencast/REQUIREMENT.md +0 -35
  64. package/claude/skills/record-screencast/SKILL.md +0 -99
  65. package/claude/skills/script-screencast/REQUIREMENT.md +0 -34
  66. package/claude/skills/script-screencast/SKILL.md +0 -70
  67. package/claude/skills/script-screencast/references/beats-template.md +0 -119
  68. package/claude/skills/teach-workspace/REQUIREMENT.md +0 -34
  69. package/claude/skills/teach-workspace/SKILL.md +0 -196
  70. package/claude/skills/teach-workspace/references/glossary.md +0 -90
  71. package/claude/skills/teach-workspace/references/lesson-build.md +0 -106
  72. package/claude/skills/teach-workspace/references/lesson-craft.md +0 -135
  73. package/claude/skills/teach-workspace/references/promotion-fold.md +0 -35
  74. package/claude/skills/teach-workspace/references/promotion.md +0 -116
  75. package/claude/skills/teach-workspace/references/revisit.md +0 -28
  76. package/claude/skills/teach-workspace/references/teach.md +0 -211
  77. package/claude/skills/video-craft/REQUIREMENT.md +0 -35
  78. package/claude/skills/video-craft/SKILL.md +0 -96
  79. package/claude/skills/video-craft/references/adopted.md +0 -110
  80. package/src/commands/demo.ts +0 -542
  81. package/src/commands/slides.ts +0 -184
  82. package/src/commands/teach.ts +0 -1401
  83. package/src/demo/beats.ts +0 -135
  84. package/src/demo/compile.ts +0 -350
  85. package/src/demo/container.ts +0 -219
  86. package/src/demo/cursors.ts +0 -55
  87. package/src/demo/drive.ts +0 -442
  88. package/src/demo/pointer.ts +0 -178
  89. package/src/demo/theme.ts +0 -112
  90. package/src/demo/timeline.ts +0 -64
  91. package/src/slides/convert/chart.ts +0 -165
  92. package/src/slides/convert/deck.ts +0 -244
  93. package/src/slides/convert/export.ts +0 -567
  94. package/src/slides/convert/master.ts +0 -198
  95. package/src/slides/convert/shapes.ts +0 -804
  96. package/src/slides/convert/svg.ts +0 -38
  97. package/src/slides/convert/walk.ts +0 -671
  98. package/src/slides/decks.ts +0 -110
  99. package/src/slides/open.ts +0 -18
  100. package/src/slides/package/fonts.ts +0 -301
  101. package/src/slides/package/motion.ts +0 -316
  102. package/src/slides/package/test-face.ts +0 -113
  103. package/src/teach/browser/compile.ts +0 -44
  104. package/src/teach/browser/dismiss.ts +0 -76
  105. package/src/teach/browser/focus-line.ts +0 -19
  106. package/src/teach/browser/glossary-filter.ts +0 -44
  107. package/src/teach/browser/head.ts +0 -30
  108. package/src/teach/browser/quiz.ts +0 -19
  109. package/src/teach/browser/reload.ts +0 -9
  110. package/src/teach/browser/sidebar.ts +0 -233
  111. package/src/teach/browser/theme.ts +0 -18
  112. package/src/teach/components/code.tsx +0 -24
  113. package/src/teach/components/heading.tsx +0 -11
  114. package/src/teach/components/list.tsx +0 -17
  115. package/src/teach/components/paragraph.tsx +0 -29
  116. package/src/teach/components/refs.tsx +0 -28
  117. package/src/teach/cross-references.ts +0 -78
  118. package/src/teach/fonts.ts +0 -16
  119. package/src/teach/highlight.ts +0 -71
  120. package/src/teach/html/jsx-dev-runtime.ts +0 -13
  121. package/src/teach/html/jsx-runtime.ts +0 -113
  122. package/src/teach/html/raw.ts +0 -28
  123. package/src/teach/lesson.ts +0 -178
  124. package/src/teach/nav.ts +0 -1337
  125. package/src/teach/render.ts +0 -240
  126. package/src/teach/workspace.ts +0 -1164
  127. package/src/teach/write-if-changed.ts +0 -17
package/src/demo/beats.ts DELETED
@@ -1,135 +0,0 @@
1
- /**
2
- * Reads the human-facing draft `script-screencast` writes. Nothing here knows
3
- * about a browser: the draft is prose aimed at a person, and turning it into
4
- * something executable is `@/demo/compile`'s job.
5
- */
6
-
7
- export interface Beat {
8
- readonly index: number
9
- readonly name: string
10
- readonly onScreen: string
11
- readonly action: string
12
- readonly watchFor: string
13
- readonly emphasis: string
14
- readonly caption: string
15
- readonly transitionOut?: string
16
- }
17
-
18
- export interface Draft {
19
- readonly title: string
20
- readonly beats: readonly Beat[]
21
- }
22
-
23
- export type DraftParse =
24
- | { status: 'parsed'; draft: Draft }
25
- | { status: 'failed'; reason: string }
26
-
27
- const TITLE = /^#\s+Screencast:\s*(.+)$/m
28
- const BEAT_SHEET = /^##\s+.*Beat sheet.*$/im
29
- const BEAT_HEADING = /^###\s+Beat\s+(\d+)\s*:\s*(.*)$/i
30
- const FIELD = /^-\s+([A-Za-z][A-Za-z\s]*?)\s*:\s*(.*)$/
31
-
32
- /**
33
- * Labels are matched case-insensitively with whitespace collapsed, because the
34
- * draft is hand-edited between being written and being compiled and a
35
- * capitalization change there is not a reason to refuse the whole file.
36
- */
37
- const FIELDS: Record<string, keyof Beat> = {
38
- 'on screen': 'onScreen',
39
- action: 'action',
40
- 'watch for': 'watchFor',
41
- emphasis: 'emphasis',
42
- caption: 'caption',
43
- 'transition out': 'transitionOut',
44
- }
45
-
46
- export function parseDraft(markdown: string): DraftParse {
47
- const sheet = beatSheetSection(markdown)
48
- if (sheet === undefined) {
49
- return {
50
- status: 'failed',
51
- reason: 'no "Beat sheet" section, so the draft carries no beats to run',
52
- }
53
- }
54
-
55
- const beats = readBeats(sheet)
56
- if (!beats.length) {
57
- return {
58
- status: 'failed',
59
- reason: 'the beat sheet holds no "### Beat" heading',
60
- }
61
- }
62
-
63
- return {
64
- status: 'parsed',
65
- draft: { title: TITLE.exec(markdown)?.[1]?.trim() ?? '', beats },
66
- }
67
- }
68
-
69
- /**
70
- * Returns the lines between the beat sheet heading and the next `##`, so a
71
- * `### Beat` heading elsewhere in the draft cannot be read as a beat.
72
- */
73
- function beatSheetSection(markdown: string): string[] | undefined {
74
- const lines = markdown.split('\n')
75
- const start = lines.findIndex((line) => BEAT_SHEET.test(line))
76
- if (start === -1) return undefined
77
-
78
- const rest = lines.slice(start + 1)
79
- const end = rest.findIndex((line) => /^##\s/.test(line))
80
- return end === -1 ? rest : rest.slice(0, end)
81
- }
82
-
83
- function readBeats(lines: string[]): Beat[] {
84
- const beats: Beat[] = []
85
- let open: Partial<Beat> | undefined
86
-
87
- for (const line of lines) {
88
- const heading = BEAT_HEADING.exec(line)
89
- if (heading) {
90
- if (open) beats.push(sealBeat(open))
91
- open = {
92
- index: Number(heading[1]),
93
- name: (heading[2] ?? '').trim(),
94
- }
95
- continue
96
- }
97
-
98
- if (!open) continue
99
- const field = FIELD.exec(line)
100
- if (!field) continue
101
-
102
- const key = FIELDS[normalizeLabel(field[1] ?? '')]
103
- if (key) open = { ...open, [key]: (field[2] ?? '').trim() }
104
- }
105
-
106
- if (open) beats.push(sealBeat(open))
107
- return beats
108
- }
109
-
110
- /**
111
- * Fills the five required fields with empty strings rather than leaving them
112
- * undefined, so a downstream reader distinguishes "the operator left it blank"
113
- * from "the label was misspelt" by the presence of the key alone.
114
- *
115
- * `transitionOut` stays optional, because the draft adds it only when it is
116
- * not the default and an empty one would claim a decision nobody made.
117
- */
118
- function sealBeat(open: Partial<Beat>): Beat {
119
- const beat: Beat = {
120
- index: open.index ?? 0,
121
- name: open.name ?? '',
122
- onScreen: open.onScreen ?? '',
123
- action: open.action ?? '',
124
- watchFor: open.watchFor ?? '',
125
- emphasis: open.emphasis ?? '',
126
- caption: open.caption ?? '',
127
- }
128
- return open.transitionOut === undefined
129
- ? beat
130
- : { ...beat, transitionOut: open.transitionOut }
131
- }
132
-
133
- function normalizeLabel(label: string): string {
134
- return label.trim().toLowerCase().replace(/\s+/g, ' ')
135
- }
@@ -1,350 +0,0 @@
1
- import type { Beat, Draft } from '@/demo/beats'
2
-
3
- /**
4
- * Turns the human-facing draft into the machine-facing plan. The two are
5
- * separate artifacts on purpose: a beat carries no target, no wait condition,
6
- * and no timing, and putting those four fields on every beat would destroy the
7
- * property the draft was designed around. See
8
- * `.canon/groundwork/38-demo-recorder/06-decision.md`.
9
- *
10
- * A compiled plan is committed rather than scratch, because the timing below is
11
- * a starting point the operator tunes and the draft cannot reproduce a tuned
12
- * value.
13
- */
14
-
15
- /** Hand-tuned in spike 2 against one fixture. Nothing establishes them in general. */
16
- const POINTER_TRAVEL_MS = 400
17
- const TYPE_DELAY_MS = 110
18
- const HOLD_MS = 600
19
- const FINAL_HOLD_MS = 1200
20
-
21
- /**
22
- * A step is a round trip to the browser, so a fixed count reproduces the
23
- * defect under a new name if it does not respond to the round-trip cost
24
- * measured at drive time. The bounds guard the extremes a bad measurement
25
- * could produce: too few steps teleports rather than glides, and a
26
- * near-zero measured cost cannot be trusted enough to let the count run
27
- * away.
28
- *
29
- * Measured on 2026-08-26 against a served fixture, post-navigation: 10
30
- * rounds of `page.mouse.move` averaged 16.6 milliseconds a step, well short
31
- * of either bound at the 400 millisecond default (about 24 steps). A
32
- * machine landing under `POINTER_TRAVEL_MS / MAX_POINTER_STEPS`, 3.33
33
- * milliseconds a step here, hits the cap and stops responding to a faster
34
- * one still. Nothing measured here establishes where a real machine sits
35
- * relative to that boundary in general.
36
- */
37
- export const MIN_POINTER_STEPS = 6
38
- export const MAX_POINTER_STEPS = 120
39
-
40
- /**
41
- * Pure on purpose: the round-trip cost comes from a real browser and can
42
- * only be measured at drive time, so this takes it as an argument rather
43
- * than measuring it itself, which is what keeps it testable against a
44
- * stubbed cost.
45
- */
46
- export function deriveSteps(travelMs: number, roundTripMs: number): number {
47
- if (roundTripMs <= 0) return MAX_POINTER_STEPS
48
- const steps = Math.round(travelMs / roundTripMs)
49
- return Math.min(MAX_POINTER_STEPS, Math.max(MIN_POINTER_STEPS, steps))
50
- }
51
-
52
- const VIEWPORT = { width: 1280, height: 720 } as const
53
- const ANNOTATIONS = {
54
- durationMs: 900,
55
- position: 'bottom-right',
56
- fontSize: 22,
57
- } as const
58
-
59
- export type StepKind =
60
- | 'navigate'
61
- | 'click'
62
- | 'fill'
63
- | 'hover'
64
- | 'scroll'
65
- | 'wait'
66
- | 'hold'
67
-
68
- /** Verbs that point at an element, so a plan without a target cannot run. */
69
- const TARGETED: ReadonlySet<StepKind> = new Set<StepKind>([
70
- 'click',
71
- 'fill',
72
- 'hover',
73
- 'scroll',
74
- ])
75
-
76
- const VERBS: Record<string, StepKind> = {
77
- navigate: 'navigate',
78
- open: 'navigate',
79
- visit: 'navigate',
80
- load: 'navigate',
81
- click: 'click',
82
- press: 'click',
83
- tap: 'click',
84
- submit: 'click',
85
- toggle: 'click',
86
- type: 'fill',
87
- fill: 'fill',
88
- enter: 'fill',
89
- input: 'fill',
90
- hover: 'hover',
91
- scroll: 'scroll',
92
- wait: 'wait',
93
- pause: 'wait',
94
- settle: 'wait',
95
- }
96
-
97
- export interface DemoStep {
98
- readonly beat: number
99
- readonly name: string
100
- readonly kind: StepKind
101
- readonly target: string
102
- readonly text: string
103
- readonly waitFor: string
104
- readonly holdMs: number
105
- readonly caption: string
106
- readonly still: boolean
107
- readonly note?: string
108
- }
109
-
110
- export type ColorScheme = 'light' | 'dark'
111
-
112
- export interface DemoPlan {
113
- readonly slug: string
114
- readonly title: string
115
- readonly url: string
116
- readonly viewport: { readonly width: number; readonly height: number }
117
- readonly colorScheme?: ColorScheme
118
- readonly output: { readonly video: string; readonly still: string }
119
- readonly pointer: { readonly travelMs: number; readonly typeDelayMs: number }
120
- readonly annotations: typeof ANNOTATIONS
121
- readonly steps: readonly DemoStep[]
122
- }
123
-
124
- export interface CompileOptions {
125
- readonly slug: string
126
- readonly outDir: string
127
- }
128
-
129
- export function compilePlan(draft: Draft, options: CompileOptions): DemoPlan {
130
- const stillAt = heroIndex(draft.beats)
131
- return {
132
- slug: options.slug,
133
- title: draft.title,
134
- url: '',
135
- viewport: VIEWPORT,
136
- output: {
137
- video: `${options.outDir}/${options.slug}/take/${options.slug}.webm`,
138
- still: `${options.outDir}/${options.slug}/take/${options.slug}.png`,
139
- },
140
- pointer: { travelMs: POINTER_TRAVEL_MS, typeDelayMs: TYPE_DELAY_MS },
141
- annotations: ANNOTATIONS,
142
- steps: draft.beats.map((beat, position) =>
143
- compileStep(beat, {
144
- still: position === stillAt,
145
- last: position === draft.beats.length - 1,
146
- }),
147
- ),
148
- }
149
- }
150
-
151
- function compileStep(
152
- beat: Beat,
153
- place: { still: boolean; last: boolean },
154
- ): DemoStep {
155
- const verb = firstVerb(beat.action)
156
- const kind = verb ? VERBS[verb] : undefined
157
- const step: DemoStep = {
158
- beat: beat.index,
159
- name: beat.name,
160
- kind: kind ?? 'hold',
161
- target: '',
162
- text: '',
163
- waitFor: '',
164
- holdMs: place.last ? FINAL_HOLD_MS : HOLD_MS,
165
- caption: beat.caption,
166
- still: place.still,
167
- }
168
-
169
- if (kind) return step
170
- return {
171
- ...step,
172
- note: `no step maps to the verb "${beat.action.trim()}", so this beat only waits`,
173
- }
174
- }
175
-
176
- /**
177
- * The draft specifies one verb per beat, and a hand-edited beat reads as a
178
- * phrase often enough that matching only the whole field would refuse work a
179
- * reader can see is a click. The first recognized word wins.
180
- */
181
- function firstVerb(action: string): string | undefined {
182
- return action
183
- .toLowerCase()
184
- .split(/[^a-z]+/)
185
- .find((word) => word in VERBS)
186
- }
187
-
188
- /**
189
- * The still is a landing frame, so it wants the beat the draft calls the hero
190
- * rather than an arbitrary one. Falling back to the last beat rather than the
191
- * first is deliberate: a demo's final state is the payoff, and a cold open is
192
- * usually an empty screen.
193
- */
194
- function heroIndex(beats: readonly Beat[]): number {
195
- const named = beats.findIndex((beat) => /hero/i.test(beat.name))
196
- return named === -1 ? beats.length - 1 : named
197
- }
198
-
199
- export type PlanParse =
200
- | { status: 'parsed'; plan: DemoPlan }
201
- | { status: 'failed'; reason: string }
202
-
203
- /**
204
- * Validates a plan read off disk. The file is hand-edited between being
205
- * compiled and being run, which is the whole reason it is committed rather than
206
- * regenerated, so every field is checked here rather than trusted.
207
- *
208
- * An absent field takes the seeded default and a present one is kept, so a
209
- * plan that dropped a timing block still runs while a tuned one is never
210
- * overwritten.
211
- */
212
- export function parsePlan(text: string): PlanParse {
213
- let raw: unknown
214
- try {
215
- raw = JSON.parse(text)
216
- } catch (error) {
217
- return {
218
- status: 'failed',
219
- reason: error instanceof Error ? error.message : 'unreadable json',
220
- }
221
- }
222
-
223
- if (!isRecord(raw)) return { status: 'failed', reason: 'not a json object' }
224
- if (!Array.isArray(raw.steps))
225
- return { status: 'failed', reason: 'steps is not an array' }
226
- if (!raw.steps.length) return { status: 'failed', reason: 'steps is empty' }
227
-
228
- const steps: DemoStep[] = []
229
- for (const [index, entry] of raw.steps.entries()) {
230
- const step = parseStep(entry, index)
231
- if ('reason' in step) return { status: 'failed', reason: step.reason }
232
- steps.push(step.step)
233
- }
234
-
235
- const colorScheme = parseColorScheme(raw.colorScheme)
236
- if (colorScheme.status === 'failed') return colorScheme
237
-
238
- const slug = asText(raw.slug)
239
- const output = isRecord(raw.output) ? raw.output : {}
240
- const pointer = isRecord(raw.pointer) ? raw.pointer : {}
241
- const viewport = isRecord(raw.viewport) ? raw.viewport : {}
242
-
243
- return {
244
- status: 'parsed',
245
- plan: {
246
- slug,
247
- title: asText(raw.title),
248
- url: asText(raw.url),
249
- viewport: {
250
- width: asNumber(viewport.width, VIEWPORT.width),
251
- height: asNumber(viewport.height, VIEWPORT.height),
252
- },
253
- ...(colorScheme.value && { colorScheme: colorScheme.value }),
254
- output: {
255
- video:
256
- asText(output.video) ||
257
- `demos/${slug || 'demo'}/take/${slug || 'demo'}.webm`,
258
- still:
259
- asText(output.still) ||
260
- `demos/${slug || 'demo'}/take/${slug || 'demo'}.png`,
261
- },
262
- pointer: {
263
- travelMs: asNumber(pointer.travelMs, POINTER_TRAVEL_MS),
264
- typeDelayMs: asNumber(pointer.typeDelayMs, TYPE_DELAY_MS),
265
- },
266
- annotations: ANNOTATIONS,
267
- steps,
268
- },
269
- }
270
- }
271
-
272
- function parseColorScheme(
273
- value: unknown,
274
- ):
275
- | { status: 'ok'; value?: ColorScheme }
276
- | { status: 'failed'; reason: string } {
277
- if (value === undefined) return { status: 'ok' }
278
- if (value === 'light' || value === 'dark') return { status: 'ok', value }
279
- return {
280
- status: 'failed',
281
- reason: `colorScheme is ${JSON.stringify(value)}, which is not light or dark`,
282
- }
283
- }
284
-
285
- function parseStep(
286
- entry: unknown,
287
- index: number,
288
- ): { step: DemoStep } | { reason: string } {
289
- if (!isRecord(entry)) return { reason: `steps[${index}] is not an object` }
290
-
291
- const kind = asText(entry.kind)
292
- if (!isStepKind(kind)) {
293
- return {
294
- reason: `steps[${index}].kind is "${kind}", which is not a step this can drive`,
295
- }
296
- }
297
-
298
- const step: DemoStep = {
299
- beat: asNumber(entry.beat, index + 1),
300
- name: asText(entry.name),
301
- kind,
302
- target: asText(entry.target),
303
- text: asText(entry.text),
304
- waitFor: asText(entry.waitFor),
305
- holdMs: asNumber(entry.holdMs, HOLD_MS),
306
- caption: asText(entry.caption),
307
- still: entry.still === true,
308
- }
309
- const note = asText(entry.note)
310
- return { step: note ? { ...step, note } : step }
311
- }
312
-
313
- function isStepKind(value: string): value is StepKind {
314
- return (
315
- value === 'navigate' ||
316
- value === 'click' ||
317
- value === 'fill' ||
318
- value === 'hover' ||
319
- value === 'scroll' ||
320
- value === 'wait' ||
321
- value === 'hold'
322
- )
323
- }
324
-
325
- function isRecord(value: unknown): value is Record<string, unknown> {
326
- return typeof value === 'object' && value !== null && !Array.isArray(value)
327
- }
328
-
329
- function asText(value: unknown): string {
330
- return typeof value === 'string' ? value : ''
331
- }
332
-
333
- function asNumber(value: unknown, fallback: number): number {
334
- return typeof value === 'number' && Number.isFinite(value) ? value : fallback
335
- }
336
-
337
- /**
338
- * Names every field a person still has to fill before the plan can drive
339
- * anything. Compile reports these and run refuses on them, so an unfilled plan
340
- * fails at the point it is written rather than part-way through a recording.
341
- */
342
- export function unresolved(plan: DemoPlan): string[] {
343
- const missing: string[] = plan.url.trim() ? [] : ['url']
344
- plan.steps.forEach((step, index) => {
345
- if (TARGETED.has(step.kind) && !step.target.trim()) {
346
- missing.push(`steps[${index}].target`)
347
- }
348
- })
349
- return missing
350
- }
@@ -1,219 +0,0 @@
1
- import { existsSync, mkdirSync, rmSync } from 'node:fs'
2
- import { join, parse } from 'node:path'
3
- import { execa } from 'execa'
4
-
5
- /**
6
- * A post-step on the file `@/demo/drive` already wrote. It touches neither
7
- * the compiler nor the driving code, which is why deferring it out of the
8
- * feature that shipped first cost no rework.
9
- */
10
-
11
- const CONVERTER_BIN = 'ffmpeg'
12
- export const INSTALL_CONVERTER = 'ffmpeg (see https://ffmpeg.org/download.html)'
13
-
14
- export type ContainerResult =
15
- | { status: 'converted'; mp4Path: string }
16
- | { status: 'skipped'; reason: 'converter-missing' }
17
- | { status: 'failed'; reason: string }
18
-
19
- export type GifResult =
20
- | { status: 'converted'; gifPath: string }
21
- | { status: 'skipped'; reason: 'converter-missing' }
22
- | { status: 'failed'; reason: string }
23
-
24
- export type FramesResult =
25
- | { status: 'extracted'; framePaths: string[] }
26
- | { status: 'skipped'; reason: 'converter-missing' }
27
- | { status: 'failed'; reason: string }
28
-
29
- export interface ExtractFramesOptions {
30
- readonly fps?: number
31
- }
32
-
33
- /**
34
- * Writes mp4 beside the webm rather than instead of it, since both stated use
35
- * cases are a `<video>` tag on a page the operator controls, where webm
36
- * already plays, and the social platform case is what mp4 is for.
37
- *
38
- * A missing binary is reported as skipped rather than failed. The recording
39
- * already succeeded, and failing the run over an optional step would discard
40
- * a good artifact.
41
- */
42
- export async function convertToMp4(
43
- webmPath: string,
44
- bin: string = CONVERTER_BIN,
45
- ): Promise<ContainerResult> {
46
- const { dir, name } = parse(webmPath)
47
- const mp4Path = join(dir, `${name}.mp4`)
48
-
49
- const result = await execa(
50
- bin,
51
- [
52
- '-y',
53
- '-i',
54
- webmPath,
55
- '-c:v',
56
- 'libx264',
57
- '-pix_fmt',
58
- 'yuv420p',
59
- '-c:a',
60
- 'aac',
61
- '-movflags',
62
- '+faststart',
63
- mp4Path,
64
- ],
65
- { reject: false },
66
- )
67
-
68
- if (result.failed && result.code === 'ENOENT') {
69
- return { status: 'skipped', reason: 'converter-missing' }
70
- }
71
- if (result.exitCode !== 0) {
72
- return {
73
- status: 'failed',
74
- reason: result.stderr?.trim() || `ffmpeg exited ${result.exitCode}`,
75
- }
76
- }
77
- return { status: 'converted', mp4Path }
78
- }
79
-
80
- /**
81
- * Opt-in rather than written beside every recording, because a gif is an order
82
- * of magnitude larger than the webm it derives from and only one destination
83
- * needs one: a README on a host that strips `<video>`, which GitHub does. The
84
- * mp4 above is unconditional for the opposite reason, being cheap and wanted
85
- * wherever the webm already plays.
86
- *
87
- * A palette is generated from the source and then applied, rather than letting
88
- * ffmpeg quantize per frame. A per-frame palette is what makes a screen
89
- * recording of flat UI colors band and shimmer, which is precisely the artifact
90
- * that would make a page look worse in the README than it does in a browser.
91
- *
92
- * Dithering is off because this command records applications rather than
93
- * photographs. A global palette already covers flat interface colors, so the
94
- * dither only writes noise the encoder then has to store: measured at 3.19MB
95
- * against 2.70MB on one recording, with the same text region cropped from both
96
- * and read as visually identical. Width is the larger lever at 2.38MB for 800
97
- * pixels, and it is not taken, since the terminal rows are what the recording
98
- * exists to have read. Frame rate is almost no lever at all.
99
- *
100
- * The missing-binary and failure handling matches `convertToMp4` exactly: the
101
- * recording already succeeded by the time this runs, so an optional step never
102
- * fails the run.
103
- */
104
- export async function convertToGif(
105
- webmPath: string,
106
- bin: string = CONVERTER_BIN,
107
- ): Promise<GifResult> {
108
- const { dir, name } = parse(webmPath)
109
- const gifPath = join(dir, `${name}.gif`)
110
-
111
- const result = await execa(
112
- bin,
113
- [
114
- '-y',
115
- '-i',
116
- webmPath,
117
- '-filter_complex',
118
- '[0:v] fps=12,scale=960:-1:flags=lanczos,split [a][b];[a] palettegen [p];[b][p] paletteuse=dither=none',
119
- gifPath,
120
- ],
121
- { reject: false },
122
- )
123
-
124
- if (result.failed && result.code === 'ENOENT') {
125
- return { status: 'skipped', reason: 'converter-missing' }
126
- }
127
- if (result.exitCode !== 0) {
128
- return {
129
- status: 'failed',
130
- reason: result.stderr?.trim() || `ffmpeg exited ${result.exitCode}`,
131
- }
132
- }
133
- return { status: 'converted', gifPath }
134
- }
135
-
136
- /**
137
- * Written beside the video by default, so frames fall under the same
138
- * `take` folder gitignore entry the still already uses with no new rule
139
- * needed. One frame a second by default, matched to the seconds-to-tens-of-
140
- * seconds length a tuned recording runs, without a smarter sampling strategy
141
- * nobody has asked for.
142
- *
143
- * The missing-binary and failure handling matches `convertToMp4` exactly: the
144
- * recording already succeeded by the time this runs, so an optional step
145
- * never fails the run.
146
- *
147
- * A prior extraction's frames are cleared before ffmpeg runs, not left for
148
- * `-y` to overwrite. ffmpeg's `-y` only overwrites the indices this run
149
- * produces, so a shorter re-extraction against the same video's stable
150
- * output path (the plan's declared `output.video`, unchanged across
151
- * re-records) would otherwise leave the previous run's tail in place,
152
- * sorted into the returned list as if it were still current.
153
- */
154
- export async function extractFrames(
155
- videoPath: string,
156
- outDir?: string,
157
- opts: ExtractFramesOptions = {},
158
- bin: string = CONVERTER_BIN,
159
- ): Promise<FramesResult> {
160
- const { dir, name } = parse(videoPath)
161
- const targetDir = outDir ?? dir
162
- const fps = opts.fps ?? 1
163
- if (outDir) mkdirSync(outDir, { recursive: true })
164
-
165
- if (existsSync(targetDir)) {
166
- for (const stale of new Bun.Glob(`${name}-frame-*.png`).scanSync({
167
- cwd: targetDir,
168
- onlyFiles: true,
169
- })) {
170
- rmSync(join(targetDir, stale))
171
- }
172
- }
173
-
174
- const pattern = join(targetDir, `${name}-frame-%03d.png`)
175
- const result = await execa(
176
- bin,
177
- ['-y', '-i', videoPath, '-vf', `fps=${fps}`, pattern],
178
- { reject: false },
179
- )
180
-
181
- if (result.failed && result.code === 'ENOENT') {
182
- return { status: 'skipped', reason: 'converter-missing' }
183
- }
184
- if (result.exitCode !== 0) {
185
- return {
186
- status: 'failed',
187
- reason: result.stderr?.trim() || `ffmpeg exited ${result.exitCode}`,
188
- }
189
- }
190
-
191
- return { status: 'extracted', framePaths: collectFrames(targetDir, name) }
192
- }
193
-
194
- /**
195
- * The glob-and-sort step `extractFrames` reads its result from, pulled out so
196
- * a test can drive it against a directory it wrote by hand rather than
197
- * through a real extraction, which is the only way to reach the four-digit
198
- * boundary below without an ffmpeg run long enough to produce one.
199
- *
200
- * ffmpeg's `%03d` pads to three characters and then widens rather than
201
- * truncating, so a run producing 1000 or more frames writes `-1000.png`
202
- * beside `-999.png`, and a lexicographic sort reads the wider name first.
203
- * `frameIndex` sorts on the parsed number instead.
204
- */
205
- export function collectFrames(targetDir: string, name: string): string[] {
206
- return [
207
- ...new Bun.Glob(`${name}-frame-*.png`).scanSync({
208
- cwd: targetDir,
209
- onlyFiles: true,
210
- }),
211
- ]
212
- .sort((a, b) => frameIndex(a) - frameIndex(b))
213
- .map((frame) => join(targetDir, frame))
214
- }
215
-
216
- function frameIndex(filename: string): number {
217
- const match = filename.match(/-frame-(\d+)\.png$/)
218
- return match ? Number(match[1]) : 0
219
- }