@erclx/aitk 3.11.2 → 3.12.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aitk",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "3.11.2",
4
+ "version": "3.12.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -34,6 +34,10 @@ The plan is committed, not scratch. Its timing is a starting point tuned by watc
34
34
 
35
35
  `aitk demo run` reads the plan, refuses if a field is still empty, and drives the application the plan's URL names. It records the whole run to `webm` and writes the still from the beat the draft calls the hero, falling back to the last beat, since a demo's final state is the payoff and a cold open is usually an empty screen.
36
36
 
37
+ A step's caption from the draft renders as an overlay while its hold plays, so the narration a person wrote is what shows on screen rather than the name of the action the engine performed.
38
+
39
+ When `ffmpeg` is on PATH, the run also writes an mp4 beside the webm, since webm plays in a `<video>` tag but nothing else accepts it. A target without `ffmpeg` still gets the webm and a line naming what to install, and the run does not fail over the missing converter.
40
+
37
41
  | Option | Behavior |
38
42
  | ---------------- | ----------------------------------------------------------- |
39
43
  | `--out <dir>` | Directory to write into, overriding what the plan names |
@@ -48,7 +52,7 @@ A step waits on its `waitFor` selector becoming visible and then holds for its o
48
52
 
49
53
  The browser engine's own annotation draws a red dot at the moment of a click and an action label in a corner. It paints no cursor, so a run without more looks like the pointer teleports between targets.
50
54
 
51
- The recorder injects a pointer element before navigation and moves it through the engine's pointer with interpolated steps rather than through the element-clicking helper, which resolves a target and jumps to it. The step count is the whole difference between a cursor that travels and one that appears. The pointer reads the element under it on every move and switches between an arrow, a hand, and a text beam, so it reflects the page the way a real cursor does.
55
+ The recorder injects a pointer element before navigation and moves it through the engine's pointer with interpolated steps rather than through the element-clicking helper, which resolves a target and jumps to it. The step count is the whole difference between a cursor that travels and one that appears, and how many steps a move takes is derived from the machine's own round-trip cost rather than fixed, so the same plan glides at roughly the same pace on a loaded machine as an idle one. It also reads the element under it on every move and switches between an arrow, a hand, and a text beam, so it reflects the page the way a real cursor does.
52
56
 
53
57
  `--cursor` points at a folder of Windows cursor resources and the browser decodes them directly, with no conversion step and no image tooling. Each resource carries a hotspot per size, and the largest entry's hotspot scaled to the drawn size is what puts the artwork's tip where the click lands. A theme contributes per state, so a folder holding an arrow and no hand still supplies its arrow and the bundled artwork covers the rest.
54
58
 
@@ -77,6 +81,6 @@ The cost is stated rather than hidden: the browser binary installs separately, s
77
81
 
78
82
  ## What it does not do
79
83
 
80
- A generated recording is a raw take. Nothing trims it, scores it, or narrates it, and the beat's caption does not reach the video, since the engine's overlay carries the action it performed rather than text from the draft.
84
+ A generated recording is a raw take. Nothing trims it or scores it beyond the caption each beat already carries.
81
85
 
82
86
  It also does not replace a narrated screencast. That has a hero moment, a cut list, and a voice, none of which survives being generated. This answers the case where the alternative is recording nothing.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "3.11.2",
4
+ "version": "3.12.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -3,6 +3,7 @@ import { basename, dirname, extname, join, relative, resolve } from 'node:path'
3
3
  import type { Command } from 'commander'
4
4
  import { parseDraft } from '@/demo/beats'
5
5
  import { compilePlan, parsePlan, unresolved } from '@/demo/compile'
6
+ import { convertToMp4, INSTALL_CONVERTER } from '@/demo/container'
6
7
  import { DEFAULT_CURSORS } from '@/demo/cursors'
7
8
  import { loadCursorTheme } from '@/demo/theme'
8
9
  import { intro, logError, logInfo, logStep, logWarn, outro, plural } from '@/ui'
@@ -89,6 +90,9 @@ export function register(program: Command): void {
89
90
  'Needs a browser binary. Install it with:',
90
91
  ` ${INSTALL_BROWSER}`,
91
92
  '',
93
+ 'Writes mp4 beside the webm when ffmpeg is on PATH, and skips it',
94
+ `otherwise without failing the run. Install it with: ${INSTALL_CONVERTER}`,
95
+ '',
92
96
  'Exit codes:',
93
97
  ' 0 the recording and the still were written',
94
98
  ' 1 refused, with the reason on stderr',
@@ -283,14 +287,35 @@ async function runDrive(planPath: string, opts: RunOptions): Promise<number> {
283
287
  logInfo(
284
288
  `${result.steps} steps in ${Math.round(result.durationMs / 100) / 10}s`,
285
289
  )
290
+
291
+ let mp4Path: string | undefined
292
+ let mp4Reason: string | undefined
293
+ if (result.videoPath) {
294
+ logStep('Container')
295
+ const converted = await convertToMp4(result.videoPath)
296
+ if (converted.status === 'converted') {
297
+ mp4Path = converted.mp4Path
298
+ logInfo(display(mp4Path))
299
+ } else if (converted.status === 'skipped') {
300
+ mp4Reason = converted.reason
301
+ logWarn('ffmpeg is not installed, so no mp4 was written.')
302
+ logWarn(`Install it with: ${INSTALL_CONVERTER}`)
303
+ } else {
304
+ mp4Reason = converted.reason
305
+ logWarn(`mp4 conversion failed: ${converted.reason}`)
306
+ }
307
+ }
308
+
286
309
  outro()
287
310
 
288
311
  emit(opts.json, {
289
312
  plan: source,
290
313
  video: result.videoPath ?? null,
314
+ mp4: mp4Path ?? null,
291
315
  still: result.stillPath ?? null,
292
316
  steps: result.steps,
293
317
  durationMs: result.durationMs,
318
+ ...(mp4Reason ? { mp4Reason } : {}),
294
319
  })
295
320
  return 0
296
321
  }
@@ -13,11 +13,42 @@ import type { Beat, Draft } from '@/demo/beats'
13
13
  */
14
14
 
15
15
  /** Hand-tuned in spike 2 against one fixture. Nothing establishes them in general. */
16
- const POINTER_STEPS = 45
16
+ const POINTER_TRAVEL_MS = 400
17
17
  const TYPE_DELAY_MS = 110
18
18
  const HOLD_MS = 600
19
19
  const FINAL_HOLD_MS = 1200
20
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
+
21
52
  const VIEWPORT = { width: 1280, height: 720 } as const
22
53
  const ANNOTATIONS = {
23
54
  durationMs: 900,
@@ -82,7 +113,7 @@ export interface DemoPlan {
82
113
  readonly url: string
83
114
  readonly viewport: { readonly width: number; readonly height: number }
84
115
  readonly output: { readonly video: string; readonly still: string }
85
- readonly pointer: { readonly steps: number; readonly typeDelayMs: number }
116
+ readonly pointer: { readonly travelMs: number; readonly typeDelayMs: number }
86
117
  readonly annotations: typeof ANNOTATIONS
87
118
  readonly steps: readonly DemoStep[]
88
119
  }
@@ -103,7 +134,7 @@ export function compilePlan(draft: Draft, options: CompileOptions): DemoPlan {
103
134
  video: `${options.outDir}/${options.slug}.webm`,
104
135
  still: `${options.outDir}/${options.slug}.png`,
105
136
  },
106
- pointer: { steps: POINTER_STEPS, typeDelayMs: TYPE_DELAY_MS },
137
+ pointer: { travelMs: POINTER_TRAVEL_MS, typeDelayMs: TYPE_DELAY_MS },
107
138
  annotations: ANNOTATIONS,
108
139
  steps: draft.beats.map((beat, position) =>
109
140
  compileStep(beat, {
@@ -218,7 +249,7 @@ export function parsePlan(text: string): PlanParse {
218
249
  still: asText(output.still) || `demos/${slug || 'demo'}.png`,
219
250
  },
220
251
  pointer: {
221
- steps: asNumber(pointer.steps, POINTER_STEPS),
252
+ travelMs: asNumber(pointer.travelMs, POINTER_TRAVEL_MS),
222
253
  typeDelayMs: asNumber(pointer.typeDelayMs, TYPE_DELAY_MS),
223
254
  },
224
255
  annotations: ANNOTATIONS,
@@ -0,0 +1,63 @@
1
+ import { join, parse } from 'node:path'
2
+ import { execa } from 'execa'
3
+
4
+ /**
5
+ * A post-step on the file `@/demo/drive` already wrote. It touches neither
6
+ * the compiler nor the driving code, which is why deferring it out of the
7
+ * feature that shipped first cost no rework.
8
+ */
9
+
10
+ const CONVERTER_BIN = 'ffmpeg'
11
+ export const INSTALL_CONVERTER = 'ffmpeg (see https://ffmpeg.org/download.html)'
12
+
13
+ export type ContainerResult =
14
+ | { status: 'converted'; mp4Path: string }
15
+ | { status: 'skipped'; reason: 'converter-missing' }
16
+ | { status: 'failed'; reason: string }
17
+
18
+ /**
19
+ * Writes mp4 beside the webm rather than instead of it, since both stated use
20
+ * cases are a `<video>` tag on a page the operator controls, where webm
21
+ * already plays, and the social platform case is what mp4 is for.
22
+ *
23
+ * A missing binary is reported as skipped rather than failed. The recording
24
+ * already succeeded, and failing the run over an optional step would discard
25
+ * a good artifact.
26
+ */
27
+ export async function convertToMp4(
28
+ webmPath: string,
29
+ bin: string = CONVERTER_BIN,
30
+ ): Promise<ContainerResult> {
31
+ const { dir, name } = parse(webmPath)
32
+ const mp4Path = join(dir, `${name}.mp4`)
33
+
34
+ const result = await execa(
35
+ bin,
36
+ [
37
+ '-y',
38
+ '-i',
39
+ webmPath,
40
+ '-c:v',
41
+ 'libx264',
42
+ '-pix_fmt',
43
+ 'yuv420p',
44
+ '-c:a',
45
+ 'aac',
46
+ '-movflags',
47
+ '+faststart',
48
+ mp4Path,
49
+ ],
50
+ { reject: false },
51
+ )
52
+
53
+ if (result.failed && result.code === 'ENOENT') {
54
+ return { status: 'skipped', reason: 'converter-missing' }
55
+ }
56
+ if (result.exitCode !== 0) {
57
+ return {
58
+ status: 'failed',
59
+ reason: result.stderr?.trim() || `ffmpeg exited ${result.exitCode}`,
60
+ }
61
+ }
62
+ return { status: 'converted', mp4Path }
63
+ }
package/src/demo/drive.ts CHANGED
@@ -3,10 +3,17 @@ import { tmpdir } from 'node:os'
3
3
  import { dirname, join } from 'node:path'
4
4
  import { chromium } from 'playwright-core'
5
5
  import type { Browser, BrowserContext, Page } from 'playwright-core'
6
+ import { deriveSteps } from '@/demo/compile'
6
7
  import type { DemoPlan, DemoStep } from '@/demo/compile'
7
8
  import type { CursorSet } from '@/demo/pointer'
8
9
  import { pointerSource } from '@/demo/pointer'
9
10
 
11
+ declare global {
12
+ interface Window {
13
+ __aitk_demo_caption__?: (text: string) => void
14
+ }
15
+ }
16
+
10
17
  /**
11
18
  * Drives a running application and records what it did. Every browser reference
12
19
  * the demo feature adds lives here, and `src/commands/demo.ts` reaches it
@@ -34,6 +41,10 @@ const POINTER_SIZE = 32
34
41
  */
35
42
  const START = { x: 8, y: 8 }
36
43
  const SETTLE_MS = 250
44
+ /** Round trips sampled to price one, on the page a step is actually about to move across. */
45
+ const CALIBRATION_STEPS = 8
46
+ /** DOM id the caption bar installs under, read back by `drive.e2e.test.ts`. */
47
+ export const CAPTION_ID = '__aitk_demo_caption_bar__'
37
48
 
38
49
  /**
39
50
  * The two output paths arrive resolved rather than as a root this re-resolves
@@ -118,8 +129,10 @@ export async function drive(options: DriveOptions): Promise<DriveResult> {
118
129
  await context.addInitScript({
119
130
  content: pointerSource(options.cursors, POINTER_SIZE),
120
131
  })
132
+ await context.addInitScript({ content: captionInitScript() })
121
133
  const page = await context.newPage()
122
134
  const video = page.video()
135
+ const pace: PointerPace = {}
123
136
 
124
137
  // The opening navigate is skipped when the plan already starts with one,
125
138
  // because a draft written around an opening verb compiles to a `navigate`
@@ -130,7 +143,7 @@ export async function drive(options: DriveOptions): Promise<DriveResult> {
130
143
  }
131
144
 
132
145
  for (const step of plan.steps) {
133
- await runStep(page, plan, step)
146
+ await runStep(page, plan, step, pace)
134
147
  // The first marked step wins. One file holds one frame, so a plan a
135
148
  // person edited to mark several would otherwise write each over the last
136
149
  // and keep whichever ran last, with nothing saying so.
@@ -187,10 +200,24 @@ async function launch(): Promise<Launch> {
187
200
  }
188
201
  }
189
202
 
190
- async function runStep(
203
+ /**
204
+ * Holds the round trip once a step has measured it, so every `moveTo` after
205
+ * the first reuses the same reading rather than re-timing on every move.
206
+ */
207
+ export interface PointerPace {
208
+ roundTripMs?: number
209
+ }
210
+
211
+ /**
212
+ * Exported so `drive.e2e.test.ts` can drive one real step against a real
213
+ * caption and read it back, which is the integration a full `drive()` call
214
+ * cannot assert without decoding the video it writes.
215
+ */
216
+ export async function runStep(
191
217
  page: Page,
192
218
  plan: DemoPlan,
193
219
  step: DemoStep,
220
+ pace: PointerPace,
194
221
  ): Promise<void> {
195
222
  switch (step.kind) {
196
223
  case 'navigate':
@@ -198,33 +225,47 @@ async function runStep(
198
225
  await page.mouse.move(START.x, START.y, { steps: 2 })
199
226
  break
200
227
  case 'click':
201
- await moveTo(page, plan, step)
228
+ await moveTo(page, plan, step, pace)
202
229
  await page.mouse.down()
203
230
  await page.mouse.up()
204
231
  break
205
232
  case 'fill':
206
- await moveTo(page, plan, step)
233
+ await moveTo(page, plan, step, pace)
207
234
  await page.mouse.down()
208
235
  await page.mouse.up()
209
236
  await page.keyboard.type(step.text, { delay: plan.pointer.typeDelayMs })
210
237
  break
211
238
  case 'hover':
212
- await moveTo(page, plan, step)
239
+ await moveTo(page, plan, step, pace)
213
240
  break
214
241
  case 'scroll':
215
242
  await page.locator(step.target).first().scrollIntoViewIfNeeded()
216
243
  await page.waitForTimeout(SETTLE_MS)
217
- await moveTo(page, plan, step)
244
+ await moveTo(page, plan, step, pace)
218
245
  break
219
246
  case 'wait':
220
247
  case 'hold':
221
248
  break
222
249
  }
223
250
 
251
+ // Set after the action rather than before it, so the caption shows for the
252
+ // hold that follows rather than for the page the action is about to leave.
253
+ await setCaption(page, step.caption)
224
254
  if (step.waitFor) await page.locator(step.waitFor).first().waitFor()
225
255
  await page.waitForTimeout(step.holdMs)
226
256
  }
227
257
 
258
+ /**
259
+ * Timed on the page a `moveTo` is actually about to move across, never on the
260
+ * blank page before it, since layout, paint, and page script are what a step
261
+ * pays the round trip against and a blank page has none of the three.
262
+ */
263
+ async function calibrateRoundTrip(page: Page): Promise<number> {
264
+ const startedAt = Date.now()
265
+ await page.mouse.move(START.x, START.y, { steps: CALIBRATION_STEPS })
266
+ return (Date.now() - startedAt) / CALIBRATION_STEPS
267
+ }
268
+
228
269
  /**
229
270
  * Travel is the whole point of driving the engine's pointer rather than calling
230
271
  * the element-clicking helper, which resolves a target and jumps to it. The
@@ -237,16 +278,78 @@ async function moveTo(
237
278
  page: Page,
238
279
  plan: DemoPlan,
239
280
  step: DemoStep,
281
+ pace: PointerPace,
240
282
  ): Promise<void> {
283
+ pace.roundTripMs ??= await calibrateRoundTrip(page)
241
284
  const locator = page.locator(step.target).first()
242
285
  await locator.waitFor()
243
286
  const box = await locator.boundingBox()
244
287
  if (!box) throw new Error(`${step.target} has no box to point at`)
245
288
  await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, {
246
- steps: plan.pointer.steps,
289
+ steps: deriveSteps(plan.pointer.travelMs, pace.roundTripMs),
247
290
  })
248
291
  }
249
292
 
293
+ /**
294
+ * Playwright's own `showActions` overlay names the API call it made, not the
295
+ * beat's narration, so a caption needs an element of its own rather than
296
+ * reusing that annotation. Runs alongside `pointerSource`, guarded the same
297
+ * way against a page that already carries one.
298
+ */
299
+ export function captionInitScript(): string {
300
+ return `(() => {
301
+ if (window.__aitk_demo_caption__) return;
302
+
303
+ let label;
304
+
305
+ const install = () => {
306
+ if (label || !document.body) return;
307
+ const bar = document.createElement('div');
308
+ bar.id = '${CAPTION_ID}';
309
+ bar.setAttribute('aria-hidden', 'true');
310
+ bar.style.cssText = [
311
+ 'position:fixed',
312
+ 'left:0',
313
+ 'right:0',
314
+ 'bottom:32px',
315
+ 'display:flex',
316
+ 'justify-content:center',
317
+ 'pointer-events:none',
318
+ 'z-index:2147483647',
319
+ ].join(';');
320
+ label = document.createElement('span');
321
+ label.style.cssText = [
322
+ 'background:rgba(16,16,20,0.85)',
323
+ 'color:#f4f4f5',
324
+ 'font:600 20px/1.4 system-ui,sans-serif',
325
+ 'padding:10px 22px',
326
+ 'border-radius:8px',
327
+ 'max-width:80vw',
328
+ 'text-align:center',
329
+ 'display:none',
330
+ ].join(';');
331
+ bar.appendChild(label);
332
+ document.body.appendChild(bar);
333
+ window.__aitk_demo_caption__ = (text) => {
334
+ label.textContent = text || '';
335
+ label.style.display = text ? 'inline-block' : 'none';
336
+ };
337
+ };
338
+
339
+ if (document.readyState === 'loading') {
340
+ addEventListener('DOMContentLoaded', install, { once: true });
341
+ } else {
342
+ install();
343
+ }
344
+ })();`
345
+ }
346
+
347
+ async function setCaption(page: Page, caption: string): Promise<void> {
348
+ await page.evaluate((text) => {
349
+ window.__aitk_demo_caption__?.(text)
350
+ }, caption)
351
+ }
352
+
250
353
  function failed(reason: DriveRefusal, error: unknown): DriveFailure {
251
354
  return {
252
355
  status: 'failed',