rvlib-shipkit 2.1.0 → 2.2.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 (63) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/package.json +2 -1
  3. package/src/assets/kits/identity-kit.ts +7 -3
  4. package/src/assets/run-assets.ts +18 -4
  5. package/src/checks/SK004-gitignore-policy.check.ts +11 -6
  6. package/src/checks/SK013-one-way-in-policy.check.ts +13 -8
  7. package/src/cli/cmd_CacheAudit.ts +3 -2
  8. package/src/cli/cmd_Calls.ts +53 -0
  9. package/src/cli/cmd_Ci.ts +4 -3
  10. package/src/cli/cmd_Commands.ts +158 -43
  11. package/src/cli/cmd_Fleet.ts +3 -1
  12. package/src/cli/cmd_GlobalProcesses.ts +7 -2
  13. package/src/cli/cmd_Shot.ts +63 -0
  14. package/src/cli/cmd_Start.ts +8 -4
  15. package/src/cli/cmd_Trailer.ts +108 -0
  16. package/src/cli/kit/builtins.ts +40 -0
  17. package/src/cli/kit/calls.ts +177 -0
  18. package/src/cli/kit/families.ts +67 -0
  19. package/src/cli/kit/index.ts +8 -0
  20. package/src/cli/kit/pages-check.ts +36 -0
  21. package/src/cli/kit/project-cli.ts +73 -0
  22. package/src/cli/kit/verb.ts +157 -0
  23. package/src/cli/list-flag.ts +3 -0
  24. package/src/cli.ts +13 -2
  25. package/src/core/layout.ts +10 -2
  26. package/src/core/migrate-layout.ts +31 -8
  27. package/src/core/one-way-in-policy.ts +12 -4
  28. package/src/mv/mv-md.ts +2 -1
  29. package/src/reflect/prompt-corpus.ts +5 -0
  30. package/src/scaffold/cli-skeleton.ts +149 -0
  31. package/src/scaffold/files/agent-index.md +2 -2
  32. package/src/scaffold/files/tsconfig.json +3 -0
  33. package/src/scaffold/files/verify-skill.md +1 -1
  34. package/src/scaffold/project-scaffold.ts +90 -12
  35. package/src/shipkit-files.ts +8 -4
  36. package/src/shot/client.ts +67 -0
  37. package/src/shot/clip-encoder.ts +146 -0
  38. package/src/shot/dom-clip.ts +124 -0
  39. package/src/shot/serve-shot.ts +54 -0
  40. package/src/shot/shooter.ts +343 -0
  41. package/src/shot/shot-ask.ts +189 -0
  42. package/src/shot/shot.shipkit.ts +14 -0
  43. package/src/smoke/run-smoke.ts +2 -2
  44. package/src/start-repo.ts +29 -14
  45. package/src/suite/judge-suites.ts +56 -10
  46. package/src/suite/select.ts +2 -5
  47. package/src/suite/suite-cli.ts +3 -2
  48. package/src/trailer/adapter.ts +23 -0
  49. package/src/trailer/build.ts +344 -0
  50. package/src/trailer/ffmpeg.ts +50 -0
  51. package/src/trailer/footage.ts +104 -0
  52. package/src/trailer/grammar.ts +915 -0
  53. package/src/trailer/graph.ts +323 -0
  54. package/src/trailer/load-unit.ts +14 -0
  55. package/src/trailer/look.ts +110 -0
  56. package/src/trailer/plan.ts +133 -0
  57. package/src/trailer/stitch.ts +150 -0
  58. package/src/trailer/titles.ts +274 -0
  59. package/src/trailer/url.ts +108 -0
  60. package/src/trailer/vertical-draw.ts +286 -0
  61. package/src/trailer/vertical.ts +148 -0
  62. package/src/trailer/vocab.ts +40 -0
  63. package/src/ts/rename.ts +9 -0
@@ -13,6 +13,7 @@
13
13
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
14
14
  import { dirname, join } from 'path'
15
15
  import { defaultHubPort } from '../serve/hub-port.ts'
16
+ import { addCliKeys, cliEntry, cliPages, exampleFamily, existingCli, kitInstalled, pagesCheckFile, skeletonPages } from './cli-skeleton.ts'
16
17
 
17
18
  const FILES = join(import.meta.dir, 'files')
18
19
 
@@ -33,6 +34,8 @@ export type ScaffoldOpts = {
33
34
  * a caller that declares a house (start seeds include lines) turns them
34
35
  * on, and the grant is printed the one time it lands */
35
36
  claudePolicy?: boolean
37
+ /** false: write no project CLI (the installed rvlib-shipkit predates the kit) */
38
+ projectCli?: boolean
36
39
  }
37
40
 
38
41
  /** every dep the house toolchain needs for the scripts below to run. The
@@ -110,16 +113,20 @@ export function scaffoldProject(root: string, opts: ScaffoldOpts): string[] {
110
113
  // agent index advertising a port nothing listens on, so the caller that
111
114
  // knows the name it is about to wire passes the port it resolves
112
115
  const port = opts.port ?? defaultHubPort(packageName(root) ?? opts.name)
113
- const vars = {
114
- NAME: opts.name,
115
- PORT: String(port),
116
- DATE: opts.today ?? new Date().toISOString().slice(0, 10),
117
- }
118
116
  const written: string[] = []
119
117
  const skipped: string[] = []
120
118
  const write = (rel: string, body: string): void => { (put(root, rel, body) ? written : skipped).push(rel) }
121
119
 
122
120
  write('src/index.ts', 'export {}\n')
121
+ // the project CLI on the kit, unless the dir already carries one, and its
122
+ // essentials page loaded by the agent index
123
+ const cliKeys = opts.projectCli !== false && writeCli(root, opts.name, write, p)
124
+ const vars = {
125
+ NAME: opts.name,
126
+ PORT: String(port),
127
+ DATE: opts.today ?? new Date().toISOString().slice(0, 10),
128
+ ...cliIndexVars(opts.name, cliKeys),
129
+ }
123
130
  write('tsconfig.json', template('tsconfig.json', vars))
124
131
  write('bunfig.toml', template('bunfig.toml', vars))
125
132
  // settings.json only here: it turns eslint off and keeps tsserver from
@@ -128,7 +135,7 @@ export function scaffoldProject(root: string, opts: ScaffoldOpts): string[] {
128
135
  write('.claude/skills/verify/SKILL.md', template('verify-skill.md', vars))
129
136
  writeHouseFiles(write, vars, opts.claudePolicy === true, written, p)
130
137
 
131
- addScripts(root, written, p)
138
+ addScripts(root, written, p, cliKeys ? opts.name : null)
132
139
  addIgnoreLines(root, written)
133
140
 
134
141
  if (written.length > 0) p(`ok: scaffolded ${written.length} file(s): ${written.slice(0, 6).join(', ')}${written.length > 6 ? `, +${written.length - 6} more` : ''}`)
@@ -166,9 +173,9 @@ function writeHouseFiles(write: (rel: string, body: string) => void, vars: Recor
166
173
  * include lines pull it into the house contract, so it gets the house half
167
174
  * of the day-one shape and nothing that says what the repo IS. Create-only,
168
175
  * so every file the repo already holds wins. Returns the paths it wrote */
169
- export function adoptHouse(root: string, opts: { name: string; today?: string; print?: (line: string) => void }): string[] {
176
+ export function adoptHouse(root: string, opts: { name: string; today?: string; print?: (line: string) => void; cli?: boolean }): string[] {
170
177
  const p = opts.print ?? console.log
171
- const vars = { NAME: opts.name, PORT: String(defaultHubPort(opts.name)), DATE: opts.today ?? new Date().toISOString().slice(0, 10) }
178
+ const vars = { NAME: opts.name, PORT: String(defaultHubPort(opts.name)), DATE: opts.today ?? new Date().toISOString().slice(0, 10), ...cliIndexVars(opts.name, opts.cli === true) }
172
179
  const written: string[] = []
173
180
  writeHouseFiles((rel, body) => { if (put(root, rel, body)) written.push(rel) }, vars, true, written, p)
174
181
  if (written.length > 0) p(`ok: adopted into the house, ${written.length} file(s) seeded: ${written.join(', ')}`)
@@ -193,8 +200,78 @@ What broke, which page or lib, what you expected. Gitignored on purpose,
193
200
  transient by design: it is harvested, then deleted.
194
201
  `
195
202
 
196
- /** the baseline scripts, added only where the key is free */
197
- function addScripts(root: string, written: string[], p: (line: string) => void): void {
203
+ /** the project CLI skeleton (cli-skeleton.ts): the entry, the example family,
204
+ * and whether package.json still needs its `cli` and `bin` keys. A dir that
205
+ * already has a project CLI keeps it alone */
206
+ function writeCli(root: string, name: string, write: (rel: string, body: string) => void, p: (line: string) => void): boolean {
207
+ const pkg = readPackage(root)
208
+ const found = existingCli(pkg, cliFiles(root))
209
+ if (found != null) { p(`skip: project CLI, ${found}`); return false }
210
+ writeSkeleton(root, name, pkg?.scripts?.dev != null, write)
211
+ return true
212
+ }
213
+
214
+ const cliFiles = (root: string): { entry: boolean; cliDir: boolean } => ({ entry: existsSync(join(root, 'src/cli.ts')), cliDir: existsSync(join(root, 'src/cli')) })
215
+
216
+ /** the skeleton's five files: the entry, the example family, its two pages
217
+ * and the check that keeps them current. The entry imports its families
218
+ * the house way when tsconfig maps src/, relatively otherwise */
219
+ function writeSkeleton(root: string, name: string, devScript: boolean, write: (rel: string, body: string) => void): void {
220
+ const pages = skeletonPages(name, devScript)
221
+ let absolute = true
222
+ try { absolute = readFileSync(join(root, 'tsconfig.json'), 'utf8').includes('"src/*"') } catch { absolute = false }
223
+ write('src/cli.ts', cliEntry(name, absolute))
224
+ write('src/cli/cmd_Dev.ts', exampleFamily(name, devScript))
225
+ write('src/cli/cli-pages.check.shipkit.ts', pagesCheckFile(name))
226
+ write(cliPages(name).all, pages.all)
227
+ write(cliPages(name).essentials, pages.essentials)
228
+ }
229
+
230
+ /** the installed rvlib-shipkit's manifest, null when none is installed */
231
+ export function installedLib(root: string): { version?: string; exports?: Record<string, unknown> } | null {
232
+ try { return JSON.parse(readFileSync(join(root, 'node_modules/rvlib-shipkit/package.json'), 'utf8')) as { version?: string; exports?: Record<string, unknown> } }
233
+ catch { return null }
234
+ }
235
+
236
+ /** the agent index lines that load the essentials page and list the full one */
237
+ const cliIndexVars = (name: string, cli: boolean): { CLI_INCLUDE: string; CLI_ROW: string } => ({
238
+ CLI_INCLUDE: cli ? `\n@${name}-essentials.md` : '',
239
+ CLI_ROW: cli ? `\n| every command of the project CLI | \`${cliPages(name).all}\` |` : '',
240
+ })
241
+
242
+ /** an ADOPTED repo asking for the project CLI (`sk start <name> --cli`): the
243
+ * skeleton and its package.json keys, refused when the repo already has a
244
+ * CLI of its own, because a second one splits the one door. Returns the
245
+ * paths it wrote, null on a refusal */
246
+ export function adoptCli(root: string, opts: { name: string; print?: (line: string) => void }): string[] | null {
247
+ const p = opts.print ?? console.log
248
+ const pkg = readPackage(root)
249
+ if (pkg == null) { p(`FAIL: no readable package.json in ${root} — the project CLI needs one`); return null }
250
+ const found = existingCli(pkg, cliFiles(root))
251
+ if (found != null) { p(`skip: ${opts.name} already has a CLI (${found}) — extend it; a project CLI beside a product's bin is wired by hand (build/cli-tool.md)`); return null }
252
+ const lib = installedLib(root)
253
+ if (kitInstalled(lib) === false) { p(`skip: the installed rvlib-shipkit ${lib?.version ?? ''} predates the CLI kit — bun add -d rvlib-shipkit@latest once a release carries it, then re-run`); return null }
254
+ const written: string[] = []
255
+ const write = (rel: string, body: string): void => { if (put(root, rel, body)) written.push(rel) }
256
+ writeSkeleton(root, opts.name, pkg.scripts?.dev != null, write)
257
+ addScripts(root, written, p, opts.name, false)
258
+ const index = join(root, 'agent/index.md')
259
+ const include = `@${opts.name}-essentials.md`
260
+ if (existsSync(index) && !readFileSync(index, 'utf8').split('\n').includes(include)) p(`next: in agent/index.md, load the everyday commands with the line ${include} and list \`${cliPages(opts.name).all}\` in its table`)
261
+ const deps = { ...(pkg as { dependencies?: object }).dependencies, ...(pkg as { devDependencies?: object }).devDependencies }
262
+ p(`ok: project CLI written: ${written.join(', ')} — run \`bun run cli\``)
263
+ if (!('rvlib-shipkit' in deps)) p('next: bun add -d rvlib-shipkit (the CLI imports rvlib-shipkit/cli-kit)')
264
+ return written
265
+ }
266
+
267
+ function readPackage(root: string): { name?: string; scripts?: Record<string, string> } | null {
268
+ try { return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { name?: string; scripts?: Record<string, string> } }
269
+ catch { return null }
270
+ }
271
+
272
+ /** the baseline scripts, added only where the key is free, plus the project
273
+ * CLI's `cli` and `bin` keys when the skeleton was written */
274
+ function addScripts(root: string, written: string[], p: (line: string) => void, cliName: string | null = null, baseline = true): void {
198
275
  const path = join(root, 'package.json')
199
276
  if (!existsSync(path)) return void p('WARN no package.json here — the typecheck script was not written')
200
277
  const raw = readFileSync(path, 'utf8')
@@ -203,11 +280,12 @@ function addScripts(root: string, written: string[], p: (line: string) => void):
203
280
  catch (e) { return void p(`WARN package.json is unparseable (${String(e).slice(0, 80)}) — the typecheck script was not written, repair it then re-run`) }
204
281
  pkg.scripts ??= {}
205
282
  let added = 0
206
- for (const [k, v] of Object.entries(SCRIPTS)) if (pkg.scripts[k] == null) { pkg.scripts[k] = v; added++ }
283
+ if (baseline) for (const [k, v] of Object.entries(SCRIPTS)) if (pkg.scripts[k] == null) { pkg.scripts[k] = v; added++ }
284
+ if (cliName != null) added += addCliKeys(pkg, cliName).length
207
285
  if (added === 0) return
208
286
  const indent = raw.match(/^(\s+)"/m)?.[1] ?? ' '
209
287
  writeFileSync(path, JSON.stringify(pkg, null, indent) + '\n')
210
- written.push(`package.json (${added} script(s))`)
288
+ written.push(`package.json (${added} key(s))`)
211
289
  }
212
290
 
213
291
  function packageName(root: string): string | null {
@@ -6,13 +6,17 @@
6
6
  // supervises it, the home page switches it
7
7
  // widget({...}) html the daemon renders every few seconds, mounted
8
8
  // on the home page
9
+ // trailer({...}) what `sk trailer` cannot read from a file: the
10
+ // sounds the repo resolves by id (trailer/adapter.ts)
9
11
  //
10
12
  // the one constraint a reader breaks first: every kind is IMPORTED by the
11
13
  // process that reads it (discovery, pm's config child, the daemon), so a file
12
14
  // keeps its top level cheap and puts heavy work inside a function.
13
15
  import { check, type CheckDef } from './check.ts'
16
+ import { trailer, type TrailerDef, type TrailerUnit } from './trailer/adapter.ts'
14
17
 
15
- export { check }
18
+ export { check, trailer }
19
+ export type { TrailerDef, TrailerUnit }
16
20
 
17
21
  /** one button beside a global process: a command, or a function of the file */
18
22
  export type ProcessAction = {
@@ -69,7 +73,7 @@ export type WidgetDef = {
69
73
  export type GlobalProcessUnit = GlobalProcessDef & { kind: 'globalProcess' }
70
74
  export type WidgetUnit = WidgetDef & { kind: 'widget' }
71
75
 
72
- export const UNIT_KINDS = ['globalProcess', 'widget'] as const
76
+ export const UNIT_KINDS = ['globalProcess', 'widget', 'trailer'] as const
73
77
 
74
78
  const KEBAB = /^[a-z0-9][a-z0-9-]*$/
75
79
  const words = (v: unknown): boolean => Array.isArray(v) && v.length > 0 && v.every((w) => typeof w === 'string')
@@ -123,9 +127,9 @@ export function widget(d: WidgetDef): WidgetUnit {
123
127
  }
124
128
 
125
129
  /** the kind a default export says it is: a unit, else a check */
126
- export function unitKind(d: unknown): 'globalProcess' | 'widget' | 'check' {
130
+ export function unitKind(d: unknown): 'globalProcess' | 'widget' | 'trailer' | 'check' {
127
131
  const k = (d as { kind?: unknown } | null)?.kind
128
- return k === 'globalProcess' || k === 'widget' ? k : 'check'
132
+ return k === 'globalProcess' || k === 'widget' || k === 'trailer' ? k : 'check'
129
133
  }
130
134
 
131
135
  export type { CheckDef }
@@ -0,0 +1,67 @@
1
+ // the one way code asks for a shot or a clip: `shotUrl` and `clipUrl` post to
2
+ // the warm shot service (serve-shot.ts), start it through pm when it is down,
3
+ // and shoot in process only when the machine has no pm (CI, SHIPKIT_NO_HOME).
4
+ // `sk shot` and the trailer's `url` film both call these.
5
+ import { DEFAULT_FPS, DEFAULT_VIEWPORT, DEFAULT_WAIT_MS, type ClipAsk, type ShotAsk, shotUrlBase, type Viewport } from './shot-ask.ts'
6
+ import type { ShotDone } from './shooter.ts'
7
+
8
+ /** the global process id pm knows the service by (shot.shipkit.ts) */
9
+ export const SHOT_PROCESS = 'shot'
10
+
11
+ export type ShotInput = { url: string; out: string; viewport?: Viewport; dpr?: number; reload?: boolean; waitMs?: number }
12
+ export type ClipInput = ShotInput & { seconds: number; fps?: number; dom?: boolean }
13
+ export type ShotResult = ShotDone & { via: 'warm' | 'started' | 'local' }
14
+
15
+ const shotAsk = (i: ShotInput): ShotAsk => ({
16
+ url: i.url,
17
+ out: i.out,
18
+ viewport: i.viewport ?? DEFAULT_VIEWPORT,
19
+ dpr: i.dpr ?? 1,
20
+ reload: i.reload ?? false,
21
+ waitMs: i.waitMs ?? DEFAULT_WAIT_MS,
22
+ })
23
+
24
+ /** a png of `url` at `out` (absolute), through the warm browser */
25
+ export const shotUrl = (i: ShotInput, log: (l: string) => void = () => {}): Promise<ShotResult> => ask('/shot', shotAsk(i), log)
26
+
27
+ /** an mp4 of `seconds` of `url` at `out` (absolute): a page that is a canvas through its WebCodecs, any other page (or
28
+ * `dom`) as chromium paints it */
29
+ export const clipUrl = (i: ClipInput, log: (l: string) => void = () => {}): Promise<ShotResult> =>
30
+ ask('/clip', { ...shotAsk(i), seconds: i.seconds, fps: i.fps ?? DEFAULT_FPS, dom: i.dom ?? false } satisfies ClipAsk, log)
31
+
32
+ /** null when nothing listens (start it), else the service's answer; a refusal throws its reason */
33
+ async function post(route: string, a: ShotAsk): Promise<ShotDone | null> {
34
+ let r: Response
35
+ try {
36
+ r = await fetch(`${shotUrlBase()}${route}`, { method: 'POST', body: JSON.stringify(a), headers: { 'content-type': 'application/json' } })
37
+ } catch {
38
+ return null
39
+ }
40
+ if (!r.ok) throw new Error(await r.text())
41
+ return (await r.json()) as ShotDone
42
+ }
43
+
44
+ async function ask(route: '/shot' | '/clip', a: ShotAsk | ClipAsk, log: (l: string) => void): Promise<ShotResult> {
45
+ const warm = await post(route, a)
46
+ if (warm != null) return { ...warm, via: 'warm' }
47
+ const { pmUp, pmUrl } = await import('../home/home-paths.ts')
48
+ if (process.env['CI'] == null && process.env['SHIPKIT_NO_HOME'] == null && (await pmUp())) {
49
+ log(`shot: starting the shot service (sk gp start ${SHOT_PROCESS})`)
50
+ const { pmSwitchDeps, switchProcess } = await import('../cli/cmd_GlobalProcesses.ts')
51
+ const code = await switchProcess(SHOT_PROCESS, 'start', pmSwitchDeps(log, pmUrl()), 60_000)
52
+ if (code !== 0) throw new Error(`the shot service did not start through pm: sk gp shows where it stands`)
53
+ const started = await post(route, a)
54
+ if (started == null) throw new Error(`pm says the shot service is up and nothing answers on ${shotUrlBase()}`)
55
+ return { ...started, via: 'started' }
56
+ }
57
+ log('shot: no pm on this machine, shooting in process (a cold browser)')
58
+ const { Shooter } = await import('./shooter.ts')
59
+ const { dirname } = await import('path')
60
+ const s = new Shooter([process.cwd(), dirname(import.meta.dir)])
61
+ try {
62
+ const done = route === '/clip' ? await s.clip(a as ClipAsk) : await s.shot(a)
63
+ return { ...done, via: 'local' }
64
+ } finally {
65
+ await s.close()
66
+ }
67
+ }
@@ -0,0 +1,146 @@
1
+ // a clip filmed by the page itself: each frame of its largest canvas goes to
2
+ // the page's WebCodecs encoder (H.264, the media engine) on the page's own
3
+ // animation frames, in real time, and the stream comes back in takes of at
4
+ // most TAKE bytes, never whole (one answer of a long clip's base64 can close
5
+ // the browser). ffmpeg then muxes it into an mp4 with no second encode.
6
+ //
7
+ // the constraint a reader breaks first: `installClip`, `takeClip` and
8
+ // `CLIP_INIT` run IN the page, so they import nothing and read only their
9
+ // argument and the document. A WebGL canvas keeps no drawing buffer after it
10
+ // presents, so the clip's page is opened with CLIP_INIT, which asks every
11
+ // WebGL context for `preserveDrawingBuffer`.
12
+
13
+ /** what the page films: seconds of its largest canvas at fps, H.264 at a fixed quantizer */
14
+ export type PageClipAsk = { seconds: number; fps: number; quantizer: number; keyEvery: number }
15
+
16
+ /** what the page says once the film ran */
17
+ export type PageClipDone = { frames: number; repeated: number; width: number; height: number; canvasOfViewport: string }
18
+
19
+ /** IN THE PAGE, as an init script: every WebGL context keeps its drawing buffer, so a frame reads back after it presents */
20
+ export const CLIP_INIT = `(() => {
21
+ const get = HTMLCanvasElement.prototype.getContext
22
+ HTMLCanvasElement.prototype.getContext = function (type, attrs) {
23
+ if (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl') attrs = Object.assign({}, attrs, { preserveDrawingBuffer: true })
24
+ return get.call(this, type, attrs)
25
+ }
26
+ })()`
27
+
28
+ type ClipState = {
29
+ chunks: Uint8Array[]
30
+ next: number
31
+ failed: string | null
32
+ done: Promise<PageClipDone>
33
+ }
34
+
35
+ /** IN THE PAGE: starts filming the largest visible canvas; null once it films, else why it cannot */
36
+ export async function installClip(a: PageClipAsk): Promise<string | null> {
37
+ const w = window as unknown as { __skClip?: ClipState }
38
+ if (typeof VideoEncoder === 'undefined') return 'this page has no WebCodecs (VideoEncoder)'
39
+ const canvases = [...document.querySelectorAll('canvas')].filter((c) => c.width > 0 && c.height > 0 && c.getBoundingClientRect().width > 0)
40
+ const canvas = canvases.sort((x, y) => y.width * y.height - x.width * x.height)[0]
41
+ if (canvas == null) return 'the page draws no visible <canvas>: a clip films the page\'s canvas through WebCodecs'
42
+ const even = (v: number): number => Math.max(2, 2 * Math.floor(v / 2))
43
+ const width = even(canvas.width)
44
+ const height = even(canvas.height)
45
+ const blocks = Math.ceil(width / 16) * Math.ceil(height / 16)
46
+ const level = blocks <= 8192 ? '28' : blocks <= 36864 ? '33' : '34'
47
+ const config: VideoEncoderConfig = {
48
+ codec: `avc1.6400${level}`,
49
+ width,
50
+ height,
51
+ framerate: a.fps,
52
+ latencyMode: 'quality',
53
+ bitrateMode: 'quantizer',
54
+ hardwareAcceleration: 'prefer-hardware',
55
+ avc: { format: 'annexb' },
56
+ }
57
+ if (!(await VideoEncoder.isConfigSupported(config)).supported) return `the page's H.264 encoder takes no ${width}x${height} at a fixed quantizer`
58
+ const box = canvas.getBoundingClientRect()
59
+ const canvasOfViewport = `${Math.round(box.width)}x${Math.round(box.height)} css px of ${innerWidth}x${innerHeight}`
60
+ const st: ClipState = { chunks: [], next: 0, failed: null, done: Promise.resolve({ frames: 0, repeated: 0, width, height, canvasOfViewport }) }
61
+ const enc = new VideoEncoder({
62
+ output: (chunk) => {
63
+ const b = new Uint8Array(chunk.byteLength)
64
+ chunk.copyTo(b)
65
+ st.chunks.push(b)
66
+ },
67
+ error: (e) => (st.failed = `the page's encoder failed: ${e.message}`),
68
+ })
69
+ enc.configure(config)
70
+ const total = Math.round(a.seconds * a.fps)
71
+ const step = 1000 / a.fps
72
+ const rect = { x: 0, y: 0, width, height }
73
+ st.done = new Promise<PageClipDone>((ok, fail) => {
74
+ let n = 0
75
+ let repeated = 0
76
+ let t0 = -1
77
+ const tick = (t: number): void => {
78
+ try {
79
+ if (st.failed) throw new Error(st.failed)
80
+ if (t0 < 0) t0 = t
81
+ // a frame for every step of real time passed: a slow page repeats its last drawing, so the clip keeps its length
82
+ let made = 0
83
+ while (n < total && n * step <= t - t0) {
84
+ const frame = new VideoFrame(canvas, { timestamp: Math.round(n * step * 1000), duration: Math.round(step * 1000), visibleRect: rect })
85
+ enc.encode(frame, { keyFrame: n % a.keyEvery === 0, avc: { quantizer: a.quantizer } } as VideoEncoderEncodeOptions)
86
+ frame.close()
87
+ if (made++ > 0) repeated++
88
+ n++
89
+ }
90
+ if (n < total) requestAnimationFrame(tick)
91
+ else
92
+ void enc
93
+ .flush()
94
+ .then(() => {
95
+ enc.close()
96
+ if (st.failed) throw new Error(st.failed)
97
+ ok({ frames: n, repeated, width, height, canvasOfViewport })
98
+ })
99
+ .catch(fail)
100
+ } catch (e) {
101
+ if (enc.state !== 'closed') enc.close()
102
+ fail(e)
103
+ }
104
+ }
105
+ requestAnimationFrame(tick)
106
+ })
107
+ // a rejection is read by `clipDone`, never left unhandled in the page
108
+ st.done.catch(() => undefined)
109
+ w.__skClip = st
110
+ return null
111
+ }
112
+
113
+ /** IN THE PAGE: the stream made since the last take, at most `max` bytes (whole chunks, one at least), base64; '' when
114
+ * none waits */
115
+ export async function takeClip(max: number): Promise<string> {
116
+ const st = (window as unknown as { __skClip: ClipState }).__skClip
117
+ if (st.failed) throw new Error(st.failed)
118
+ const from = st.next
119
+ let bytes = 0
120
+ while (st.next < st.chunks.length && (st.next === from || bytes + st.chunks[st.next]!.byteLength <= max)) bytes += st.chunks[st.next++]!.byteLength
121
+ if (st.next === from) return ''
122
+ const part = st.chunks.slice(from, st.next)
123
+ for (let i = from; i < st.next; i++) st.chunks[i] = new Uint8Array(0)
124
+ const url = await new Promise<string>((ok, fail) => {
125
+ const r = new FileReader()
126
+ r.onload = () => ok(String(r.result))
127
+ r.onerror = () => fail(new Error('the stream did not read back'))
128
+ r.readAsDataURL(new Blob(part as BlobPart[]))
129
+ })
130
+ return url.slice(url.indexOf(',') + 1)
131
+ }
132
+
133
+ /** IN THE PAGE: resolves once the film ran and the encoder flushed */
134
+ export const clipDone = (): Promise<PageClipDone> => (window as unknown as { __skClip: ClipState }).__skClip.done
135
+
136
+ /** the most stream one take carries, bytes: its base64 is a third more */
137
+ export const TAKE = 8 << 20
138
+
139
+ /** H.264's quantizer, 0 to 51, lower is better: 24 films a UI or a 3D scene sharp at a few MB a minute */
140
+ export const CLIP_QUANTIZER = 24
141
+
142
+ /** a key frame a second at 30 fps: a cut into the footage seeks no further back than that */
143
+ export const KEY_EVERY = 30
144
+
145
+ /** ffmpeg's arguments to mux a raw H.264 stream (stdin) into an mp4, no second encode */
146
+ export const muxArgs = (fps: number, out: string): string[] => ['-hide_banner', '-nostdin', '-y', '-loglevel', 'error', '-fflags', '+genpts', '-r', String(fps), '-f', 'h264', '-i', 'pipe:0', '-c:v', 'copy', '-movflags', '+faststart', out]
@@ -0,0 +1,124 @@
1
+ // a clip of the page itself, for a page the canvas path cannot film: chromium
2
+ // sends each painted frame of the page (CDP `Page.startScreencast`, jpeg),
3
+ // `FrameTimeline` lays them on the clip's fixed frame grid (a frame holds
4
+ // until the next one paints, so a still page still fills its seconds), and
5
+ // ffmpeg encodes the grid to H.264 as the frames arrive.
6
+ //
7
+ // measured against Playwright's `recordVideo` on a 3 s animated page: 60
8
+ // painted frames a second and 90 of 90 distinct frames in the mp4, against
9
+ // 65 of 89 for recordVideo (25 fps vp8, then a second encode to trim it).
10
+ //
11
+ // the constraint a reader breaks first: the screencast must take EVERY frame
12
+ // (`everyNthFrame: 1`). Every second frame drops 6 to 21 distinct frames of
13
+ // 90, because its arrival jitter beats against the clip's grid.
14
+ import type { Viewport } from './shot-ask.ts'
15
+
16
+ export type ClipPath = 'canvas' | 'dom'
17
+
18
+ /** css box of the page's largest visible canvas, the one the canvas path would film */
19
+ export type CanvasBox = { width: number; height: number }
20
+
21
+ /** a canvas this share of the viewport or more is the page: the canvas path films it. Under it, the page is DOM with
22
+ * a canvas in it (a chart, a sparkline), and filming the canvas alone would drop the page */
23
+ export const CANVAS_SHARE = 0.5
24
+
25
+ export function clipPathOf(o: { dom: boolean; canvas: CanvasBox | null; viewport: Viewport }): ClipPath {
26
+ if (o.dom || o.canvas == null) return 'dom'
27
+ return o.canvas.width * o.canvas.height >= CANVAS_SHARE * o.viewport.width * o.viewport.height ? 'canvas' : 'dom'
28
+ }
29
+
30
+ /** IN THE PAGE: the css box of the largest visible canvas by drawing buffer (installClip's pick), null when none */
31
+ export const largestCanvas = (): CanvasBox | null => {
32
+ const c = [...document.querySelectorAll('canvas')].filter((x) => x.width > 0 && x.height > 0 && x.getBoundingClientRect().width > 0).sort((x, y) => y.width * y.height - x.width * x.height)[0]
33
+ if (c == null) return null
34
+ const r = c.getBoundingClientRect()
35
+ return { width: r.width, height: r.height }
36
+ }
37
+
38
+ /** the screencast request: every frame, jpeg. Its frames are css pixels whatever the device pixel ratio */
39
+ export const screencastParams = (viewport: Viewport): Record<string, unknown> => ({
40
+ format: 'jpeg',
41
+ quality: 90,
42
+ maxWidth: viewport.width,
43
+ maxHeight: viewport.height,
44
+ everyNthFrame: 1,
45
+ })
46
+
47
+ /** the painted frames on the clip's fixed grid of `fps` × `seconds` slots. Slot i shows the last frame painted at or
48
+ * before i / fps, timed from the first frame */
49
+ export class FrameTimeline {
50
+ readonly total: number
51
+ #filled = 0
52
+ #t0: number | null = null
53
+ #shown = 0
54
+
55
+ constructor(
56
+ readonly fps: number,
57
+ readonly seconds: number,
58
+ ) {
59
+ this.total = Math.round(seconds * fps)
60
+ }
61
+
62
+ /** a frame painted at `t` seconds (any clock, the same for every frame): how many slots the PREVIOUS frame fills,
63
+ * and whether this frame lands past the clip's end (it is then dropped) */
64
+ arrive(t: number): { previous: number; past: boolean } {
65
+ this.#t0 ??= t
66
+ const rel = t - this.#t0
67
+ const upTo = Math.min(this.total, Math.ceil(rel * this.fps))
68
+ const previous = this.#shown === 0 ? 0 : Math.max(0, upTo - this.#filled)
69
+ this.#filled += previous
70
+ const past = rel >= this.seconds
71
+ if (!past) this.#shown++
72
+ return { previous, past }
73
+ }
74
+
75
+ /** the slots the last frame fills once filming stops, so the clip is exactly `seconds` long */
76
+ end(): number {
77
+ if (this.#shown === 0) return 0
78
+ const rest = this.total - this.#filled
79
+ this.#filled = this.total
80
+ return rest
81
+ }
82
+
83
+ /** distinct frames the page painted inside the clip */
84
+ get shown(): number {
85
+ return this.#shown
86
+ }
87
+
88
+ /** the page's own frame rate over the clip */
89
+ get realFps(): number {
90
+ return this.#shown / this.seconds
91
+ }
92
+ }
93
+
94
+ /** ffmpeg's arguments: a stream of jpegs (stdin) at `fps` into an H.264 mp4 of exactly `frames` frames */
95
+ export const domEncodeArgs = (fps: number, frames: number, out: string): string[] => [
96
+ '-hide_banner',
97
+ '-nostdin',
98
+ '-y',
99
+ '-loglevel',
100
+ 'error',
101
+ '-f',
102
+ 'image2pipe',
103
+ '-framerate',
104
+ String(fps),
105
+ '-c:v',
106
+ 'mjpeg',
107
+ '-i',
108
+ 'pipe:0',
109
+ '-vf',
110
+ 'scale=trunc(iw/2)*2:trunc(ih/2)*2',
111
+ '-frames:v',
112
+ String(frames),
113
+ '-c:v',
114
+ 'libx264',
115
+ '-preset',
116
+ 'veryfast',
117
+ '-crf',
118
+ '20',
119
+ '-pix_fmt',
120
+ 'yuv420p',
121
+ '-movflags',
122
+ '+faststart',
123
+ out,
124
+ ]
@@ -0,0 +1,54 @@
1
+ // the warm shot service, the `shot` global process pm keeps up: an http
2
+ // door on 127.0.0.1 over one Shooter. `sk shot` and `shotUrl` post an ask
3
+ // and get back where the file landed. The browser launches on the first
4
+ // ask and closes after BROWSER_IDLE_MS; this process itself stays, idle.
5
+ import { dirname } from 'path'
6
+ import { LIB_VERSION } from '../core/lib-version.ts'
7
+ import { type ClipAsk, isClip, type ShotAsk, shotPort } from './shot-ask.ts'
8
+ import { Shooter } from './shooter.ts'
9
+
10
+ export const SHOT_SERVICE = 'shipkit-shot'
11
+
12
+ export function serveShots(port = shotPort(), roots = [process.cwd(), dirname(import.meta.dir)]): { stop(): Promise<void> } {
13
+ const shooter = new Shooter(roots)
14
+ const server = Bun.serve({
15
+ hostname: '127.0.0.1',
16
+ port,
17
+ idleTimeout: 0,
18
+ async fetch(req) {
19
+ const path = new URL(req.url).pathname
20
+ if (req.method === 'GET' && path === '/health') return Response.json({ service: SHOT_SERVICE, version: LIB_VERSION, pid: process.pid, ...shooter.warm })
21
+ if (req.method === 'POST' && (path === '/shot' || path === '/clip')) {
22
+ let a: ShotAsk | ClipAsk
23
+ try {
24
+ a = (await req.json()) as ShotAsk | ClipAsk
25
+ } catch {
26
+ return new Response('the ask is not json', { status: 400 })
27
+ }
28
+ if (typeof a?.url !== 'string' || typeof a.out !== 'string' || !a.out.startsWith('/')) return new Response('an ask needs url and an absolute out', { status: 400 })
29
+ try {
30
+ const done = path === '/clip' ? (isClip(a) ? await shooter.clip(a) : null) : await shooter.shot(a)
31
+ if (done == null) return new Response('a clip ask needs seconds and fps', { status: 400 })
32
+ return Response.json(done)
33
+ } catch (e) {
34
+ return new Response((e as Error).message ?? String(e), { status: 500 })
35
+ }
36
+ }
37
+ return new Response('not found: GET /health, POST /shot, POST /clip', { status: 404 })
38
+ },
39
+ })
40
+ return {
41
+ async stop() {
42
+ await shooter.close()
43
+ void server.stop(true)
44
+ },
45
+ }
46
+ }
47
+
48
+ if (import.meta.main) {
49
+ const s = serveShots()
50
+ console.log(`ok shots on 127.0.0.1:${shotPort()}`)
51
+ const bye = (): void => void s.stop().finally(() => process.exit(0))
52
+ process.on('SIGTERM', bye)
53
+ process.on('SIGINT', bye)
54
+ }