rvlib-shipkit 2.1.1 → 2.3.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 (142) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/package.json +2 -1
  3. package/src/agent-time/mask.ts +43 -0
  4. package/src/agent-time/render.ts +187 -0
  5. package/src/agent-time/report.ts +335 -0
  6. package/src/agent-time/shape.ts +271 -0
  7. package/src/agent-time/signals.ts +84 -0
  8. package/src/agent-time/store.ts +379 -0
  9. package/src/agent-time/timeline.ts +218 -0
  10. package/src/agent-time/turns.ts +135 -0
  11. package/src/assets/kits/identity-kit.ts +7 -3
  12. package/src/assets/run-assets.ts +18 -4
  13. package/src/checks/SK004-gitignore-policy.check.ts +11 -6
  14. package/src/checks/SK013-one-way-in-policy.check.ts +13 -8
  15. package/src/cli/cmd_Calls.ts +53 -0
  16. package/src/cli/cmd_Commands.ts +163 -43
  17. package/src/cli/cmd_Review.ts +948 -0
  18. package/src/cli/cmd_Shot.ts +63 -0
  19. package/src/cli/cmd_Start.ts +8 -4
  20. package/src/cli/cmd_Time.ts +112 -0
  21. package/src/cli/cmd_Trailer.ts +361 -0
  22. package/src/cli/kit/builtins.ts +40 -0
  23. package/src/cli/kit/calls.ts +177 -0
  24. package/src/cli/kit/families.ts +67 -0
  25. package/src/cli/kit/index.ts +8 -0
  26. package/src/cli/kit/pages-check.ts +36 -0
  27. package/src/cli/kit/project-cli.ts +73 -0
  28. package/src/cli/kit/verb.ts +167 -0
  29. package/src/cli.ts +15 -2
  30. package/src/collections/collection-types.ts +6 -1
  31. package/src/collections/providers.ts +3 -0
  32. package/src/collections/review.ts +70 -0
  33. package/src/core/layout.ts +12 -2
  34. package/src/core/migrate-layout.ts +31 -8
  35. package/src/core/one-way-in-policy.ts +12 -4
  36. package/src/reflect/prompt-corpus.ts +15 -114
  37. package/src/reflect/transcript.ts +254 -0
  38. package/src/review/anim.ts +228 -0
  39. package/src/review/auto.ts +141 -0
  40. package/src/review/format.ts +150 -0
  41. package/src/review/http.ts +23 -0
  42. package/src/review/marks.ts +43 -0
  43. package/src/review/media.ts +45 -0
  44. package/src/review/model.ts +854 -0
  45. package/src/review/project.ts +110 -0
  46. package/src/review/role.ts +48 -0
  47. package/src/review/routes.ts +228 -0
  48. package/src/review/store.ts +1002 -0
  49. package/src/review/threads.ts +237 -0
  50. package/src/review/trailer-desk.ts +385 -0
  51. package/src/review/trailer-model.ts +183 -0
  52. package/src/review/trailer-routes.ts +136 -0
  53. package/src/review/unit.ts +165 -0
  54. package/src/review/watchers.ts +54 -0
  55. package/src/scaffold/cli-skeleton.ts +149 -0
  56. package/src/scaffold/files/agent-index.md +2 -2
  57. package/src/scaffold/files/tsconfig.json +3 -0
  58. package/src/scaffold/files/verify-skill.md +1 -1
  59. package/src/scaffold/project-scaffold.ts +90 -12
  60. package/src/serve/embed.ts +35 -0
  61. package/src/serve/hub-app/activity-view.tsx +1 -1
  62. package/src/serve/hub-app/activity.css +0 -4
  63. package/src/serve/hub-app/dock-view.tsx +5 -4
  64. package/src/serve/hub-app/hash-route.ts +28 -1
  65. package/src/serve/hub-app/header-bar.tsx +3 -1
  66. package/src/serve/hub-app/home-st.ts +10 -2
  67. package/src/serve/hub-app/home-strip.tsx +3 -2
  68. package/src/serve/hub-app/home.html +1 -0
  69. package/src/serve/hub-app/hub-app.tsx +80 -11
  70. package/src/serve/hub-app/hub.css +45 -13
  71. package/src/serve/hub-app/icons.tsx +9 -0
  72. package/src/serve/hub-app/index.html +1 -0
  73. package/src/serve/hub-app/insights-view.tsx +4 -3
  74. package/src/serve/hub-app/keys.ts +14 -10
  75. package/src/serve/hub-app/layout-st.ts +63 -2
  76. package/src/serve/hub-app/planned-view.tsx +3 -2
  77. package/src/serve/hub-app/planned.css +0 -1
  78. package/src/serve/hub-app/project-list.css +22 -4
  79. package/src/serve/hub-app/project-list.tsx +41 -8
  80. package/src/serve/hub-app/projects-svc.ts +4 -0
  81. package/src/serve/hub-app/repo-sections.ts +15 -3
  82. package/src/serve/hub-app/repo-st.ts +45 -3
  83. package/src/serve/hub-app/review/clip-st.ts +208 -0
  84. package/src/serve/hub-app/review/kind-view.tsx +171 -0
  85. package/src/serve/hub-app/review/kit.tsx +113 -0
  86. package/src/serve/hub-app/review/review-answer.tsx +239 -0
  87. package/src/serve/hub-app/review/review-board.tsx +46 -0
  88. package/src/serve/hub-app/review/review-list.tsx +156 -0
  89. package/src/serve/hub-app/review/review-page.tsx +101 -0
  90. package/src/serve/hub-app/review/review-st.ts +779 -0
  91. package/src/serve/hub-app/review/review-views.tsx +503 -0
  92. package/src/serve/hub-app/review/trailer-page.tsx +416 -0
  93. package/src/serve/hub-app/review/trailer-st.ts +602 -0
  94. package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
  95. package/src/serve/hub-app/review.css +329 -0
  96. package/src/serve/hub-app/theme-st.ts +56 -0
  97. package/src/serve/hub-app/tree-build.ts +4 -0
  98. package/src/serve/hub-app/tree-row.tsx +1 -1
  99. package/src/serve/hub-app/tree-svc.ts +4 -0
  100. package/src/serve/hub-runtime.ts +5 -3
  101. package/src/serve/same-origin.ts +5 -1
  102. package/src/serve/serve-hub.ts +8 -0
  103. package/src/shipkit-files.ts +12 -4
  104. package/src/shot/client.ts +67 -0
  105. package/src/shot/clip-encoder.ts +146 -0
  106. package/src/shot/dom-clip.ts +124 -0
  107. package/src/shot/serve-shot.ts +54 -0
  108. package/src/shot/shooter.ts +343 -0
  109. package/src/shot/shot-ask.ts +189 -0
  110. package/src/shot/shot.shipkit.ts +14 -0
  111. package/src/smoke/run-smoke.ts +2 -2
  112. package/src/start-repo.ts +29 -14
  113. package/src/suite/judge-suites.ts +56 -10
  114. package/src/suite/select.ts +2 -5
  115. package/src/suite/suite-cli.ts +3 -2
  116. package/src/trailer/adapter.ts +78 -0
  117. package/src/trailer/build.ts +603 -0
  118. package/src/trailer/ffmpeg.ts +72 -0
  119. package/src/trailer/footage.ts +173 -0
  120. package/src/trailer/grammar.ts +1290 -0
  121. package/src/trailer/graph.ts +508 -0
  122. package/src/trailer/holes.ts +72 -0
  123. package/src/trailer/load-unit.ts +31 -0
  124. package/src/trailer/look.ts +124 -0
  125. package/src/trailer/passage.ts +129 -0
  126. package/src/trailer/plan.ts +192 -0
  127. package/src/trailer/prune.ts +193 -0
  128. package/src/trailer/score/compose.ts +240 -0
  129. package/src/trailer/score/dsp.ts +272 -0
  130. package/src/trailer/score/palette.ts +188 -0
  131. package/src/trailer/score/pattern.ts +91 -0
  132. package/src/trailer/score/score.ts +214 -0
  133. package/src/trailer/score/sound.ts +112 -0
  134. package/src/trailer/score/synth.ts +361 -0
  135. package/src/trailer/states.ts +76 -0
  136. package/src/trailer/stitch.ts +233 -0
  137. package/src/trailer/titles.ts +379 -0
  138. package/src/trailer/url.ts +108 -0
  139. package/src/trailer/vertical-draw.ts +286 -0
  140. package/src/trailer/vertical.ts +148 -0
  141. package/src/trailer/vocab.ts +59 -0
  142. package/src/trailer/words.ts +254 -0
@@ -58,6 +58,7 @@ import { hubHandler, type CheckTap, type HubDeps, type HubHandler } from './serv
58
58
  import type { projectGutter } from './project-gutter-route.ts'
59
59
  import { defaultHubPort } from './hub-port.ts'
60
60
  import { LAYOUT } from '../core/layout.ts'
61
+ import { storeDir, storeStamp } from '../review/store.ts'
61
62
  export { defaultHubPort }
62
63
 
63
64
  export type ServeIo = { print: (l: string) => void; printErr: (l: string) => void }
@@ -347,9 +348,10 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
347
348
  graphs,
348
349
  studio,
349
350
  refresh,
350
- // the config and the run record, by their stats: what the warm rows
351
- // and the runs page read besides the tracked files the watch sees
352
- stamp: () => `${configStamp(root)}|${record === '' ? '' : recordStamp(record)}`,
351
+ // the config, the run record and the review store (gitignored, its
352
+ // own watch counts its writes): what the warm rows and the runs page
353
+ // read besides the tracked files the watch sees
354
+ stamp: () => `${configStamp(root)}|${record === '' ? '' : recordStamp(record)}|${storeStamp(storeDir(root))}`,
353
355
  runCli,
354
356
  // the run happens IN THIS PROCESS, so every check result reaches the
355
357
  // page as it finishes. A shelled run could only ever deliver one wall
@@ -10,5 +10,9 @@ export function sameOrigin(req: Request): boolean {
10
10
  if (site != null && site !== 'same-origin' && site !== 'none') return false
11
11
  const origin = req.headers.get('origin')
12
12
  if (origin == null) return true
13
- try { return new URL(origin).host === new URL(req.url).host } catch { return false }
13
+ // behind a proxy that frames the page under its own origin (pkm-multi's admin), the host the browser asked is the
14
+ // proxy's, which it passes on. A browser cannot forge the header: a page of another site that sets it is held at
15
+ // its preflight, which this server never answers
16
+ const host = req.headers.get('x-forwarded-host') ?? new URL(req.url).host
17
+ try { return new URL(origin).host === host } catch { return false }
14
18
  }
@@ -32,6 +32,8 @@ import { sameOrigin } from './same-origin.ts'
32
32
  import { LAYOUT } from '../core/layout.ts'
33
33
  import { WarmCell } from './warm-cell.ts'
34
34
  import { readmeRoutes } from './readme-route.ts'
35
+ import { reviewRoutes } from '../review/routes.ts'
36
+ import { embedRoute } from './embed.ts'
35
37
 
36
38
  export { sameOrigin }
37
39
 
@@ -338,6 +340,12 @@ export function hubHandler(d: HubDeps, o: { base?: string; projects?: Gutter } =
338
340
  // the empty page shows the README, its images served from the repo
339
341
  const readme = await readmeRoutes(d.root, path)
340
342
  if (readme != null) return readme
343
+ // the review or trailer page alone, for another tool's frame (embed.ts)
344
+ const embed = await embedRoute(path)
345
+ if (embed != null) return embed
346
+ // the one feedback store and the trailer view (review/routes.ts)
347
+ const review = await reviewRoutes(d.root, req, path)
348
+ if (review != null) return review
341
349
  if (path === '/api/status.json') {
342
350
  const p = join(d.root, LAYOUT.status)
343
351
  return Response.json({ statusMd: existsSync(p) ? await Bun.file(p).text() : null })
@@ -6,13 +6,21 @@
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)
11
+ // review({...}) the repo's parts of the review store: its subject
12
+ // kinds, topics, sources, auto rules, the dispatcher
13
+ // (review/unit.ts)
9
14
  //
10
15
  // the one constraint a reader breaks first: every kind is IMPORTED by the
11
16
  // process that reads it (discovery, pm's config child, the daemon), so a file
12
17
  // keeps its top level cheap and puts heavy work inside a function.
13
18
  import { check, type CheckDef } from './check.ts'
19
+ import { review, type AutoRule, type KindDef, type ReviewDef, type ReviewSource, type ReviewUnit } from './review/unit.ts'
20
+ import { trailer, type TrailerDef, type TrailerUnit } from './trailer/adapter.ts'
14
21
 
15
- export { check }
22
+ export { check, review, trailer }
23
+ export type { AutoRule, KindDef, ReviewDef, ReviewSource, ReviewUnit, TrailerDef, TrailerUnit }
16
24
 
17
25
  /** one button beside a global process: a command, or a function of the file */
18
26
  export type ProcessAction = {
@@ -69,7 +77,7 @@ export type WidgetDef = {
69
77
  export type GlobalProcessUnit = GlobalProcessDef & { kind: 'globalProcess' }
70
78
  export type WidgetUnit = WidgetDef & { kind: 'widget' }
71
79
 
72
- export const UNIT_KINDS = ['globalProcess', 'widget'] as const
80
+ export const UNIT_KINDS = ['globalProcess', 'widget', 'trailer', 'review'] as const
73
81
 
74
82
  const KEBAB = /^[a-z0-9][a-z0-9-]*$/
75
83
  const words = (v: unknown): boolean => Array.isArray(v) && v.length > 0 && v.every((w) => typeof w === 'string')
@@ -123,9 +131,9 @@ export function widget(d: WidgetDef): WidgetUnit {
123
131
  }
124
132
 
125
133
  /** the kind a default export says it is: a unit, else a check */
126
- export function unitKind(d: unknown): 'globalProcess' | 'widget' | 'check' {
134
+ export function unitKind(d: unknown): (typeof UNIT_KINDS)[number] | 'check' {
127
135
  const k = (d as { kind?: unknown } | null)?.kind
128
- return k === 'globalProcess' || k === 'widget' ? k : 'check'
136
+ return UNIT_KINDS.includes(k as (typeof UNIT_KINDS)[number]) ? (k as (typeof UNIT_KINDS)[number]) : 'check'
129
137
  }
130
138
 
131
139
  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
+ }