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.
- package/CHANGELOG.md +18 -0
- package/package.json +2 -1
- 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_CacheAudit.ts +3 -2
- package/src/cli/cmd_Calls.ts +53 -0
- package/src/cli/cmd_Ci.ts +4 -3
- package/src/cli/cmd_Commands.ts +158 -43
- package/src/cli/cmd_Fleet.ts +3 -1
- package/src/cli/cmd_GlobalProcesses.ts +7 -2
- package/src/cli/cmd_Shot.ts +63 -0
- package/src/cli/cmd_Start.ts +8 -4
- package/src/cli/cmd_Trailer.ts +108 -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 +157 -0
- package/src/cli/list-flag.ts +3 -0
- package/src/cli.ts +13 -2
- package/src/core/layout.ts +10 -2
- package/src/core/migrate-layout.ts +31 -8
- package/src/core/one-way-in-policy.ts +12 -4
- package/src/mv/mv-md.ts +2 -1
- package/src/reflect/prompt-corpus.ts +5 -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/shipkit-files.ts +8 -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 +23 -0
- package/src/trailer/build.ts +344 -0
- package/src/trailer/ffmpeg.ts +50 -0
- package/src/trailer/footage.ts +104 -0
- package/src/trailer/grammar.ts +915 -0
- package/src/trailer/graph.ts +323 -0
- package/src/trailer/load-unit.ts +14 -0
- package/src/trailer/look.ts +110 -0
- package/src/trailer/plan.ts +133 -0
- package/src/trailer/stitch.ts +150 -0
- package/src/trailer/titles.ts +274 -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 +40 -0
- 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
|
|
197
|
-
|
|
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}
|
|
288
|
+
written.push(`package.json (${added} key(s))`)
|
|
211
289
|
}
|
|
212
290
|
|
|
213
291
|
function packageName(root: string): string | null {
|
package/src/shipkit-files.ts
CHANGED
|
@@ -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
|
+
}
|