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.
- package/CHANGELOG.md +50 -0
- package/package.json +2 -1
- package/src/agent-time/mask.ts +43 -0
- package/src/agent-time/render.ts +187 -0
- package/src/agent-time/report.ts +335 -0
- package/src/agent-time/shape.ts +271 -0
- package/src/agent-time/signals.ts +84 -0
- package/src/agent-time/store.ts +379 -0
- package/src/agent-time/timeline.ts +218 -0
- package/src/agent-time/turns.ts +135 -0
- package/src/assets/kits/identity-kit.ts +7 -3
- package/src/assets/run-assets.ts +18 -4
- package/src/checks/SK004-gitignore-policy.check.ts +11 -6
- package/src/checks/SK013-one-way-in-policy.check.ts +13 -8
- package/src/cli/cmd_Calls.ts +53 -0
- package/src/cli/cmd_Commands.ts +163 -43
- package/src/cli/cmd_Review.ts +948 -0
- package/src/cli/cmd_Shot.ts +63 -0
- package/src/cli/cmd_Start.ts +8 -4
- package/src/cli/cmd_Time.ts +112 -0
- package/src/cli/cmd_Trailer.ts +361 -0
- package/src/cli/kit/builtins.ts +40 -0
- package/src/cli/kit/calls.ts +177 -0
- package/src/cli/kit/families.ts +67 -0
- package/src/cli/kit/index.ts +8 -0
- package/src/cli/kit/pages-check.ts +36 -0
- package/src/cli/kit/project-cli.ts +73 -0
- package/src/cli/kit/verb.ts +167 -0
- package/src/cli.ts +15 -2
- package/src/collections/collection-types.ts +6 -1
- package/src/collections/providers.ts +3 -0
- package/src/collections/review.ts +70 -0
- package/src/core/layout.ts +12 -2
- package/src/core/migrate-layout.ts +31 -8
- package/src/core/one-way-in-policy.ts +12 -4
- package/src/reflect/prompt-corpus.ts +15 -114
- package/src/reflect/transcript.ts +254 -0
- package/src/review/anim.ts +228 -0
- package/src/review/auto.ts +141 -0
- package/src/review/format.ts +150 -0
- package/src/review/http.ts +23 -0
- package/src/review/marks.ts +43 -0
- package/src/review/media.ts +45 -0
- package/src/review/model.ts +854 -0
- package/src/review/project.ts +110 -0
- package/src/review/role.ts +48 -0
- package/src/review/routes.ts +228 -0
- package/src/review/store.ts +1002 -0
- package/src/review/threads.ts +237 -0
- package/src/review/trailer-desk.ts +385 -0
- package/src/review/trailer-model.ts +183 -0
- package/src/review/trailer-routes.ts +136 -0
- package/src/review/unit.ts +165 -0
- package/src/review/watchers.ts +54 -0
- package/src/scaffold/cli-skeleton.ts +149 -0
- package/src/scaffold/files/agent-index.md +2 -2
- package/src/scaffold/files/tsconfig.json +3 -0
- package/src/scaffold/files/verify-skill.md +1 -1
- package/src/scaffold/project-scaffold.ts +90 -12
- package/src/serve/embed.ts +35 -0
- package/src/serve/hub-app/activity-view.tsx +1 -1
- package/src/serve/hub-app/activity.css +0 -4
- package/src/serve/hub-app/dock-view.tsx +5 -4
- package/src/serve/hub-app/hash-route.ts +28 -1
- package/src/serve/hub-app/header-bar.tsx +3 -1
- package/src/serve/hub-app/home-st.ts +10 -2
- package/src/serve/hub-app/home-strip.tsx +3 -2
- package/src/serve/hub-app/home.html +1 -0
- package/src/serve/hub-app/hub-app.tsx +80 -11
- package/src/serve/hub-app/hub.css +45 -13
- package/src/serve/hub-app/icons.tsx +9 -0
- package/src/serve/hub-app/index.html +1 -0
- package/src/serve/hub-app/insights-view.tsx +4 -3
- package/src/serve/hub-app/keys.ts +14 -10
- package/src/serve/hub-app/layout-st.ts +63 -2
- package/src/serve/hub-app/planned-view.tsx +3 -2
- package/src/serve/hub-app/planned.css +0 -1
- package/src/serve/hub-app/project-list.css +22 -4
- package/src/serve/hub-app/project-list.tsx +41 -8
- package/src/serve/hub-app/projects-svc.ts +4 -0
- package/src/serve/hub-app/repo-sections.ts +15 -3
- package/src/serve/hub-app/repo-st.ts +45 -3
- package/src/serve/hub-app/review/clip-st.ts +208 -0
- package/src/serve/hub-app/review/kind-view.tsx +171 -0
- package/src/serve/hub-app/review/kit.tsx +113 -0
- package/src/serve/hub-app/review/review-answer.tsx +239 -0
- package/src/serve/hub-app/review/review-board.tsx +46 -0
- package/src/serve/hub-app/review/review-list.tsx +156 -0
- package/src/serve/hub-app/review/review-page.tsx +101 -0
- package/src/serve/hub-app/review/review-st.ts +779 -0
- package/src/serve/hub-app/review/review-views.tsx +503 -0
- package/src/serve/hub-app/review/trailer-page.tsx +416 -0
- package/src/serve/hub-app/review/trailer-st.ts +602 -0
- package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
- package/src/serve/hub-app/review.css +329 -0
- package/src/serve/hub-app/theme-st.ts +56 -0
- package/src/serve/hub-app/tree-build.ts +4 -0
- package/src/serve/hub-app/tree-row.tsx +1 -1
- package/src/serve/hub-app/tree-svc.ts +4 -0
- package/src/serve/hub-runtime.ts +5 -3
- package/src/serve/same-origin.ts +5 -1
- package/src/serve/serve-hub.ts +8 -0
- package/src/shipkit-files.ts +12 -4
- package/src/shot/client.ts +67 -0
- package/src/shot/clip-encoder.ts +146 -0
- package/src/shot/dom-clip.ts +124 -0
- package/src/shot/serve-shot.ts +54 -0
- package/src/shot/shooter.ts +343 -0
- package/src/shot/shot-ask.ts +189 -0
- package/src/shot/shot.shipkit.ts +14 -0
- package/src/smoke/run-smoke.ts +2 -2
- package/src/start-repo.ts +29 -14
- package/src/suite/judge-suites.ts +56 -10
- package/src/suite/select.ts +2 -5
- package/src/suite/suite-cli.ts +3 -2
- package/src/trailer/adapter.ts +78 -0
- package/src/trailer/build.ts +603 -0
- package/src/trailer/ffmpeg.ts +72 -0
- package/src/trailer/footage.ts +173 -0
- package/src/trailer/grammar.ts +1290 -0
- package/src/trailer/graph.ts +508 -0
- package/src/trailer/holes.ts +72 -0
- package/src/trailer/load-unit.ts +31 -0
- package/src/trailer/look.ts +124 -0
- package/src/trailer/passage.ts +129 -0
- package/src/trailer/plan.ts +192 -0
- package/src/trailer/prune.ts +193 -0
- package/src/trailer/score/compose.ts +240 -0
- package/src/trailer/score/dsp.ts +272 -0
- package/src/trailer/score/palette.ts +188 -0
- package/src/trailer/score/pattern.ts +91 -0
- package/src/trailer/score/score.ts +214 -0
- package/src/trailer/score/sound.ts +112 -0
- package/src/trailer/score/synth.ts +361 -0
- package/src/trailer/states.ts +76 -0
- package/src/trailer/stitch.ts +233 -0
- package/src/trailer/titles.ts +379 -0
- package/src/trailer/url.ts +108 -0
- package/src/trailer/vertical-draw.ts +286 -0
- package/src/trailer/vertical.ts +148 -0
- package/src/trailer/vocab.ts +59 -0
- package/src/trailer/words.ts +254 -0
package/src/serve/hub-runtime.ts
CHANGED
|
@@ -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
|
|
351
|
-
//
|
|
352
|
-
|
|
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
|
package/src/serve/same-origin.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/serve/serve-hub.ts
CHANGED
|
@@ -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 })
|
package/src/shipkit-files.ts
CHANGED
|
@@ -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):
|
|
134
|
+
export function unitKind(d: unknown): (typeof UNIT_KINDS)[number] | 'check' {
|
|
127
135
|
const k = (d as { kind?: unknown } | null)?.kind
|
|
128
|
-
return k
|
|
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
|
+
}
|