rvlib-shipkit 2.3.0 → 2.5.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 +19 -2
- package/package.json +2 -2
- package/src/assets/kits/identity-kit.ts +5 -2
- package/src/cli/cmd_Commands.ts +30 -5
- package/src/cli/cmd_FleetPush.ts +47 -0
- package/src/cli/cmd_GlobalProcesses.ts +5 -5
- package/src/cli/cmd_Review.ts +52 -11
- package/src/cli/cmd_Sound.ts +209 -0
- package/src/cli/cmd_Trailer.ts +11 -3
- package/src/cli/family-pages.ts +49 -0
- package/src/cli/kit/builtins.ts +1 -0
- package/src/cli/kit/verb.ts +11 -3
- package/src/cli/registry.ts +137 -0
- package/src/cli.ts +47 -133
- package/src/core/app-icon.ts +10 -6
- package/src/core/last-good.ts +217 -0
- package/src/core/load-probe.ts +71 -0
- package/src/core/reexec.ts +5 -3
- package/src/fleet/push-fleet.ts +80 -0
- package/src/home/home-routes.ts +12 -2
- package/src/home/lib-watch.ts +110 -20
- package/src/home/process-rows.ts +4 -2
- package/src/home/run-daemon.ts +1 -1
- package/src/repo-config.ts +3 -0
- package/src/review/format.ts +11 -3
- package/src/review/model.ts +66 -5
- package/src/review/paste.ts +30 -0
- package/src/review/project.ts +20 -4
- package/src/review/routes.ts +22 -4
- package/src/review/store.ts +15 -6
- package/src/review/unit.ts +19 -1
- package/src/scaffold/files/agent-index.md +2 -2
- package/src/scaffold/project-scaffold.ts +2 -2
- package/src/serve/hub-app/activity-st.ts +31 -0
- package/src/serve/hub-app/activity-view.tsx +9 -0
- package/src/serve/hub-app/activity.css +6 -0
- package/src/serve/hub-app/admin-svc.ts +1 -1
- package/src/serve/hub-app/admin-view.tsx +1 -1
- package/src/serve/hub-app/hash-route.ts +12 -4
- package/src/serve/hub-app/home-st.ts +7 -1
- package/src/serve/hub-app/home-strip.tsx +3 -1
- package/src/serve/hub-app/hub.css +38 -1
- package/src/serve/hub-app/icons.tsx +6 -0
- package/src/serve/hub-app/layout-st.ts +2 -2
- package/src/serve/hub-app/machine-st.ts +1 -1
- package/src/serve/hub-app/md.ts +32 -0
- package/src/serve/hub-app/project-list.css +9 -7
- package/src/serve/hub-app/project-list.tsx +5 -2
- package/src/serve/hub-app/projects-svc.ts +3 -2
- package/src/serve/hub-app/repo-st.ts +22 -7
- package/src/serve/hub-app/review/clip-st.ts +57 -7
- package/src/serve/hub-app/review/kind-view.tsx +78 -9
- package/src/serve/hub-app/review/review-answer.tsx +67 -58
- package/src/serve/hub-app/review/review-page.tsx +17 -15
- package/src/serve/hub-app/review/review-st.ts +191 -20
- package/src/serve/hub-app/review/review-views.tsx +154 -9
- package/src/serve/hub-app/review.css +73 -13
- package/src/serve/hub-app/wiki-st.ts +190 -0
- package/src/serve/hub-app/wikis-view.tsx +134 -5
- package/src/serve/hub-runtime.ts +4 -4
- package/src/serve/project-gutter-route.ts +25 -1
- package/src/serve/project-gutter.ts +1 -1
- package/src/serve/run-serve.ts +25 -0
- package/src/serve/serve-hub.ts +15 -1
- package/src/serve/stale-restart.ts +19 -2
- package/src/shot/client.ts +17 -5
- package/src/shot/shooter.ts +19 -3
- package/src/shot/shot-ask.ts +9 -1
- package/src/shot/stepped-clip.ts +53 -0
- package/src/trailer/adapter.ts +33 -4
- package/src/trailer/build.ts +182 -33
- package/src/trailer/ffmpeg.ts +21 -1
- package/src/trailer/fonts.ts +55 -0
- package/src/trailer/footage.ts +4 -4
- package/src/trailer/grammar.ts +429 -67
- package/src/trailer/graph.ts +130 -56
- package/src/trailer/look.ts +3 -0
- package/src/trailer/motion.ts +207 -0
- package/src/trailer/plan.ts +2 -2
- package/src/trailer/prune.ts +13 -2
- package/src/trailer/score/compose.ts +18 -2
- package/src/trailer/score/gm-render.ts +43 -0
- package/src/trailer/score/gm.ts +90 -0
- package/src/trailer/score/midi.ts +143 -0
- package/src/trailer/score/pattern.ts +13 -6
- package/src/trailer/score/score.ts +28 -14
- package/src/trailer/score/sound.ts +12 -1
- package/src/trailer/score/synth.ts +24 -2
- package/src/trailer/sound/audio.ts +148 -0
- package/src/trailer/sound/brand.ts +42 -0
- package/src/trailer/sound/comfy.ts +38 -0
- package/src/trailer/sound/eleven.ts +72 -0
- package/src/trailer/sound/fluid.ts +71 -0
- package/src/trailer/sound/gen.ts +139 -0
- package/src/trailer/sound/index.ts +60 -0
- package/src/trailer/sound/presets.ts +69 -0
- package/src/trailer/sound/run.ts +189 -0
- package/src/trailer/titles.ts +451 -235
- package/src/trailer/url.ts +18 -3
- package/src/trailer/vocab.ts +12 -1
- package/src/trailer/words.ts +29 -8
- package/src/wiki/graph-mount.ts +3 -0
- package/src/wiki/graph-page.ts +45 -9
- package/src/wiki/graph-pages.ts +128 -0
package/src/serve/serve-hub.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { existsSync } from 'fs'
|
|
8
8
|
import { join } from 'path'
|
|
9
9
|
import { positionsFile } from 'rvlib-shipkit/src/wiki/graph-positions.ts'
|
|
10
|
+
import { pageOf, searchWiki } from 'rvlib-shipkit/src/wiki/graph-pages.ts'
|
|
10
11
|
import { graphRoutes } from 'rvlib-shipkit/src/wiki/serve-graph.ts'
|
|
11
12
|
import type { StudioRoutes } from '../assets/server/studio-server.ts'
|
|
12
13
|
import { comfyBoxUp, type ComfyBox, type ComfyPanelUp } from '../comfy/comfy-panel.ts'
|
|
@@ -30,6 +31,7 @@ import { RUN_EXIT } from '../home/home-words.ts'
|
|
|
30
31
|
import { projectGutter } from './project-gutter-route.ts'
|
|
31
32
|
import { sameOrigin } from './same-origin.ts'
|
|
32
33
|
import { LAYOUT } from '../core/layout.ts'
|
|
34
|
+
import { heldNow, heldText } from '../core/last-good.ts'
|
|
33
35
|
import { WarmCell } from './warm-cell.ts'
|
|
34
36
|
import { readmeRoutes } from './readme-route.ts'
|
|
35
37
|
import { reviewRoutes } from '../review/routes.ts'
|
|
@@ -245,7 +247,7 @@ export function hubHandler(d: HubDeps, o: { base?: string; projects?: Gutter } =
|
|
|
245
247
|
collections: r.collections,
|
|
246
248
|
resources: r.resources,
|
|
247
249
|
planned: d.planned?.() ?? null,
|
|
248
|
-
hub: d.hubProblems?.list() ?? [],
|
|
250
|
+
hub: [...(d.hubProblems?.list() ?? []), ...heldProblem()],
|
|
249
251
|
})
|
|
250
252
|
}
|
|
251
253
|
// the previous address, answered while pages opened before the
|
|
@@ -411,6 +413,12 @@ export function hubHandler(d: HubDeps, o: { base?: string; projects?: Gutter } =
|
|
|
411
413
|
if (path === `/graph/${g.id}`) return page(routes.page())
|
|
412
414
|
if (path === `/graph/${g.id}/graph.json`) return Response.json(held)
|
|
413
415
|
if (path === `/graph/${g.id}/positions`) return routes.save(req)
|
|
416
|
+
// the wikis page's search bar and its preview panel: only a node of the graph is read
|
|
417
|
+
if (path === `/graph/${g.id}/search`) return g.dir == null ? new Response('this wiki names no folder to read', { status: 404 }) : Response.json(searchWiki(held, g.dir, new URL(req.url).searchParams.get('q') ?? ''))
|
|
418
|
+
if (path === `/graph/${g.id}/page`) {
|
|
419
|
+
const p = g.dir == null ? null : pageOf(held, g.dir, new URL(req.url).searchParams.get('node') ?? '')
|
|
420
|
+
return p == null ? new Response('not a page of this wiki', { status: 404 }) : Response.json(p)
|
|
421
|
+
}
|
|
414
422
|
}
|
|
415
423
|
// the media API namespace — the studio route table, ONE prefix strip
|
|
416
424
|
if (d.studio != null && path.startsWith('/media/')) return d.studio.handle(req, path.slice('/media'.length))
|
|
@@ -420,6 +428,12 @@ export function hubHandler(d: HubDeps, o: { base?: string; projects?: Gutter } =
|
|
|
420
428
|
}
|
|
421
429
|
}
|
|
422
430
|
|
|
431
|
+
/** the machine runs older code than its tree: every hub's header says so */
|
|
432
|
+
const heldProblem = (): { id: string; text: string; severity: 'yellow' }[] => {
|
|
433
|
+
const h = heldNow()
|
|
434
|
+
return h == null ? [] : [{ id: 'last-good', text: heldText(h), severity: 'yellow' }]
|
|
435
|
+
}
|
|
436
|
+
|
|
423
437
|
export function serveHub(d: HubDeps, h: HubHandler = hubHandler(d)): Hub {
|
|
424
438
|
const server: Server = Bun.serve({
|
|
425
439
|
hostname: '127.0.0.1',
|
|
@@ -7,10 +7,14 @@
|
|
|
7
7
|
// watch fds, and a full table fails every spawn with EBADF. Either way it
|
|
8
8
|
// re-executes in place (core/reexec.ts) once no run is in flight, which is a
|
|
9
9
|
// clean table too. Only a restart that throws reaches a human, once, through
|
|
10
|
-
// `say`.
|
|
10
|
+
// `say`. With `loads`, it restarts only onto code that loads: while the tree
|
|
11
|
+
// fails, it keeps the running image and tells `hold` why.
|
|
11
12
|
import { assertLeakHealable, FD_HEALED_ENV, fdTableLeak } from '../core/fd-table.ts'
|
|
12
13
|
|
|
13
|
-
export type StaleTick = 'fresh' | 'busy' | 'restarted' | 'impossible'
|
|
14
|
+
export type StaleTick = 'fresh' | 'busy' | 'probing' | 'held' | 'restarted' | 'impossible'
|
|
15
|
+
|
|
16
|
+
/** does the code a restart would run load: `pending` while a probe runs */
|
|
17
|
+
export type LoadVerdict = 'ok' | 'pending' | { file: string; error: string }
|
|
14
18
|
|
|
15
19
|
export type StaleRestartOpts = {
|
|
16
20
|
/** the version this process booted with */
|
|
@@ -30,6 +34,10 @@ export type StaleRestartOpts = {
|
|
|
30
34
|
/** the hub's status line: a text when the restart is impossible, null
|
|
31
35
|
* once there is nothing to say */
|
|
32
36
|
say: (text: string | null) => void
|
|
37
|
+
/** asked once a restart is due, never before (core/load-probe.ts) */
|
|
38
|
+
loads?: () => LoadVerdict
|
|
39
|
+
/** why the due restart waits: a file of the tree fails to load. Null once it loads */
|
|
40
|
+
hold?: (why: { file: string; error: string } | null) => void
|
|
33
41
|
everyMs?: number
|
|
34
42
|
}
|
|
35
43
|
|
|
@@ -47,6 +55,15 @@ export class StaleRestart {
|
|
|
47
55
|
}
|
|
48
56
|
if (this.failed != null) return 'impossible'
|
|
49
57
|
if (this.o.busy()) return 'busy'
|
|
58
|
+
if (this.o.loads != null) {
|
|
59
|
+
const v = this.o.loads()
|
|
60
|
+
if (v === 'pending') return 'probing'
|
|
61
|
+
if (v !== 'ok') {
|
|
62
|
+
this.o.hold?.(v)
|
|
63
|
+
return 'held'
|
|
64
|
+
}
|
|
65
|
+
this.o.hold?.(null)
|
|
66
|
+
}
|
|
50
67
|
try {
|
|
51
68
|
if (stale.leak) {
|
|
52
69
|
assertLeakHealable()
|
package/src/shot/client.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { ShotDone } from './shooter.ts'
|
|
|
8
8
|
/** the global process id pm knows the service by (shot.shipkit.ts) */
|
|
9
9
|
export const SHOT_PROCESS = 'shot'
|
|
10
10
|
|
|
11
|
-
export type ShotInput = { url: string; out: string; viewport?: Viewport; dpr?: number; reload?: boolean; waitMs?: number }
|
|
11
|
+
export type ShotInput = { url: string; out: string; viewport?: Viewport; dpr?: number; reload?: boolean; waitMs?: number; step?: string; at?: number }
|
|
12
12
|
export type ClipInput = ShotInput & { seconds: number; fps?: number; dom?: boolean }
|
|
13
13
|
export type ShotResult = ShotDone & { via: 'warm' | 'started' | 'local' }
|
|
14
14
|
|
|
@@ -21,13 +21,25 @@ const shotAsk = (i: ShotInput): ShotAsk => ({
|
|
|
21
21
|
waitMs: i.waitMs ?? DEFAULT_WAIT_MS,
|
|
22
22
|
})
|
|
23
23
|
|
|
24
|
-
/** a
|
|
25
|
-
|
|
24
|
+
/** a service of an older shipkit takes a stepped ask, ignores the step and shoots the page as it loads: never kept */
|
|
25
|
+
function stepped(i: { step?: string }, r: ShotResult): void {
|
|
26
|
+
if (i.step && r.stepped !== true) throw new Error(`the shot service on ${shotUrlBase()} did not step the page (it says: ${r.note}); it runs an older shipkit: sk gp restart ${SHOT_PROCESS}`)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** a png of `url` at `out` (absolute), through the warm browser; with `step`, the page drawn at `at` first */
|
|
30
|
+
export async function shotUrl(i: ShotInput, log: (l: string) => void = () => {}): Promise<ShotResult> {
|
|
31
|
+
const r = await ask('/shot', { ...shotAsk(i), ...(i.step ? { step: i.step, at: i.at ?? 0 } : {}) }, log)
|
|
32
|
+
stepped(i, r)
|
|
33
|
+
return r
|
|
34
|
+
}
|
|
26
35
|
|
|
27
36
|
/** an mp4 of `seconds` of `url` at `out` (absolute): a page that is a canvas through its WebCodecs, any other page (or
|
|
28
37
|
* `dom`) as chromium paints it */
|
|
29
|
-
export
|
|
30
|
-
ask('/clip', { ...shotAsk(i), seconds: i.seconds, fps: i.fps ?? DEFAULT_FPS, dom: i.dom ?? false } satisfies ClipAsk, log)
|
|
38
|
+
export async function clipUrl(i: ClipInput, log: (l: string) => void = () => {}): Promise<ShotResult> {
|
|
39
|
+
const r = await ask('/clip', { ...shotAsk(i), seconds: i.seconds, fps: i.fps ?? DEFAULT_FPS, dom: i.dom ?? false, ...(i.step ? { step: i.step } : {}) } satisfies ClipAsk, log)
|
|
40
|
+
stepped(i, r)
|
|
41
|
+
return r
|
|
42
|
+
}
|
|
31
43
|
|
|
32
44
|
/** null when nothing listens (start it), else the service's answer; a refusal throws its reason */
|
|
33
45
|
async function post(route: string, a: ShotAsk): Promise<ShotDone | null> {
|
package/src/shot/shooter.ts
CHANGED
|
@@ -59,7 +59,7 @@ export const SHOT_NICE = 10
|
|
|
59
59
|
/** a page that paints nothing once the screencast starts is filmed from one still taken after this */
|
|
60
60
|
const FIRST_FRAME_MS = 1000
|
|
61
61
|
|
|
62
|
-
export type ShotDone = { out: string; ms: number; phases: Record<string, number>; errors: string[]; note: string }
|
|
62
|
+
export type ShotDone = { out: string; ms: number; phases: Record<string, number>; errors: string[]; note: string; stepped?: true }
|
|
63
63
|
|
|
64
64
|
type Slot = { key: string; url: string; ctx: Context; page: Page; cdp: Cdp; errors: string[]; frozen: boolean }
|
|
65
65
|
|
|
@@ -169,6 +169,8 @@ export class Shooter {
|
|
|
169
169
|
const phases: Record<string, number> = {}
|
|
170
170
|
const t0 = performance.now()
|
|
171
171
|
let move = pageMove(this.#slot == null || this.#slot.page.isClosed() ? null : { url: this.#slot.url, key: this.#slot.key }, a)
|
|
172
|
+
// a stepped still draws its moment on a fresh load, never on a page some other shot left at another time
|
|
173
|
+
if (a.step && move === 'none') move = 'reload'
|
|
172
174
|
if (move === 'boot') {
|
|
173
175
|
const old = this.#slot
|
|
174
176
|
this.#slot = null
|
|
@@ -200,13 +202,22 @@ export class Shooter {
|
|
|
200
202
|
let t = performance.now()
|
|
201
203
|
const how = await waitReady(() => slot.page.evaluate(readMark), move === 'none' ? 0 : a.waitMs, { now: () => performance.now(), sleep: (n) => slot.page.waitForTimeout(n) })
|
|
202
204
|
phases['ready'] = ms(t)
|
|
205
|
+
if (a.step) {
|
|
206
|
+
t = performance.now()
|
|
207
|
+
const { stepTo } = await import('./stepped-clip.ts')
|
|
208
|
+
if (!(await slot.page.evaluate(stepTo, [a.step, a.at ?? 0] as [string, number]))) throw new Error(`the page has no window.${a.step}(t): a stepped page draws the time it is given there`)
|
|
209
|
+
await waitReady(() => slot.page.evaluate(readMark), 0, { now: () => performance.now(), sleep: (n) => slot.page.waitForTimeout(n) })
|
|
210
|
+
// the warm page now shows that moment: the next plain shot of this url loads it again
|
|
211
|
+
slot.url = ''
|
|
212
|
+
phases['step'] = ms(t)
|
|
213
|
+
}
|
|
203
214
|
t = performance.now()
|
|
204
215
|
const r = await slot.cdp.send('Page.captureScreenshot', { format: 'png', optimizeForSpeed: true })
|
|
205
216
|
phases['capture'] = ms(t)
|
|
206
217
|
mkdirSync(dirname(a.out), { recursive: true })
|
|
207
218
|
await Bun.write(a.out, Buffer.from(String(r['data']), 'base64'))
|
|
208
|
-
const note = `${move === 'none' ? 'warm page' : thawed ? 'frozen page reloaded' : move}, ${how === 'ready' ? 'ready mark' : move === 'none' ? 'no wait' : `${a.waitMs} ms after load`}`
|
|
209
|
-
return { out: a.out, ms: ms(t0), phases, errors: [...slot.errors], note }
|
|
219
|
+
const note = `${move === 'none' ? 'warm page' : thawed ? 'frozen page reloaded' : move}, ${how === 'ready' ? 'ready mark' : move === 'none' ? 'no wait' : `${a.waitMs} ms after load`}${a.step ? `, drawn at ${a.at ?? 0}s through window.${a.step}(t)` : ''}`
|
|
220
|
+
return { out: a.out, ms: ms(t0), phases, errors: [...slot.errors], note, ...(a.step ? { stepped: true as const } : {}) }
|
|
210
221
|
}
|
|
211
222
|
|
|
212
223
|
async #clip(a: ClipAsk): Promise<ShotDone> {
|
|
@@ -223,6 +234,11 @@ export class Shooter {
|
|
|
223
234
|
await waitReady(() => page.evaluate(readMark), a.waitMs, { now: () => performance.now(), sleep: (n) => page.waitForTimeout(n) })
|
|
224
235
|
phases['load'] = ms(t)
|
|
225
236
|
t = performance.now()
|
|
237
|
+
if (a.step) {
|
|
238
|
+
const note = await (await import('./stepped-clip.ts')).stepPage(await ctx.newCDPSession(page), page, a as ClipAsk & { step: string })
|
|
239
|
+
phases['film'] = ms(t)
|
|
240
|
+
return { out: a.out, ms: ms(t0), phases, errors, note, stepped: true }
|
|
241
|
+
}
|
|
226
242
|
const path: ClipPath = clipPathOf({ dom: a.dom === true, canvas: await page.evaluate(largestCanvas), viewport: a.viewport })
|
|
227
243
|
const why = path === 'canvas' ? await page.evaluate(installClip, { seconds: a.seconds, fps: a.fps, quantizer: CLIP_QUANTIZER, keyEvery: KEY_EVERY } satisfies PageClipAsk) : null
|
|
228
244
|
if (path === 'dom' || why != null) {
|
package/src/shot/shot-ask.ts
CHANGED
|
@@ -17,6 +17,9 @@ export type ShotAsk = {
|
|
|
17
17
|
reload: boolean
|
|
18
18
|
/** a page with no ready mark is shot this long after its load (a warm page at once) */
|
|
19
19
|
waitMs: number
|
|
20
|
+
/** a still of a stepped page: `window[step](at)` on a fresh load, then its ready mark, then the shot */
|
|
21
|
+
step?: string
|
|
22
|
+
at?: number
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
export type ClipAsk = ShotAsk & {
|
|
@@ -24,6 +27,9 @@ export type ClipAsk = ShotAsk & {
|
|
|
24
27
|
fps: number
|
|
25
28
|
/** film the page itself even when a canvas fills it (dom-clip.ts); a page with no big canvas is filmed so anyway */
|
|
26
29
|
dom?: boolean
|
|
30
|
+
/** step the page frame by frame (stepped-clip.ts): before each frame `window[step](t)`, t its time in seconds, then
|
|
31
|
+
* its ready mark; a slow page films as smooth as a fast one */
|
|
32
|
+
step?: string
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
export const DEFAULT_SHOT_PORT = 4796
|
|
@@ -113,7 +119,9 @@ export const isClip = (a: ShotAsk | ClipAsk): a is ClipAsk => 'seconds' in a
|
|
|
113
119
|
|
|
114
120
|
/** a request held past this (a clip: past its own seconds) fails loud and frees the turn */
|
|
115
121
|
export const REQUEST_MAX_MS = 120_000
|
|
116
|
-
export const capMsOf = (a: ShotAsk | ClipAsk): number => REQUEST_MAX_MS + (isClip(a) ? a.seconds * 1000 : 0)
|
|
122
|
+
export const capMsOf = (a: ShotAsk | ClipAsk): number => REQUEST_MAX_MS + (isClip(a) ? (a.step ? a.seconds * a.fps * STEP_FRAME_MS : a.seconds * 1000) : 0)
|
|
123
|
+
/** a stepped frame's budget in the request's cap: a page that draws a frame slower than this is no page to step */
|
|
124
|
+
export const STEP_FRAME_MS = 1000
|
|
117
125
|
|
|
118
126
|
// ── the ready mark ──────────────────────────────────────────────────────────
|
|
119
127
|
// A page that knows when it is drawn says so: `<meta name="shot-ready"
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// a clip filmed frame by frame (wiki build/trailer.md, film page): before each frame the page is asked to draw the
|
|
2
|
+
// frame's time, `window[step](t)`, then the shot service waits for its ready mark and two painted frames, takes one
|
|
3
|
+
// screenshot, and ffmpeg encodes the frames in order. The page's own speed never shows: a frame that takes a second to
|
|
4
|
+
// draw sits beside one that took a millisecond. The page owns its clock while it is stepped
|
|
5
|
+
import { mkdirSync } from 'fs'
|
|
6
|
+
import { dirname } from 'path'
|
|
7
|
+
import { domEncodeArgs } from './dom-clip.ts'
|
|
8
|
+
import { type ClipAsk, readMark, waitReady } from './shot-ask.ts'
|
|
9
|
+
|
|
10
|
+
type Cdp = { send(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>> }
|
|
11
|
+
type Page = { evaluate<R, A>(fn: (a: A) => R | Promise<R>, arg: A): Promise<R>; waitForTimeout(ms: number): Promise<void> }
|
|
12
|
+
|
|
13
|
+
/** the frames a stepped clip holds, and each one's time */
|
|
14
|
+
export const stepTimes = (seconds: number, fps: number): number[] => Array.from({ length: Math.max(1, Math.round(seconds * fps)) }, (_, i) => +(i / fps).toFixed(6))
|
|
15
|
+
|
|
16
|
+
/** IN THE PAGE: the hook asked to draw time t; false when the page has no such hook */
|
|
17
|
+
export const stepTo = async ([hook, t]: [string, number]): Promise<boolean> => {
|
|
18
|
+
const f = (window as unknown as Record<string, unknown>)[hook]
|
|
19
|
+
if (typeof f !== 'function') return false
|
|
20
|
+
await (f as (t: number) => unknown)(t)
|
|
21
|
+
await new Promise<void>((ok) => requestAnimationFrame(() => requestAnimationFrame(() => ok())))
|
|
22
|
+
return true
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function stepPage(cdp: Cdp, page: Page, a: ClipAsk & { step: string }): Promise<string> {
|
|
26
|
+
const ffmpeg = Bun.which('ffmpeg') ?? (process.platform === 'darwin' ? '/opt/homebrew/bin/ffmpeg' : null)
|
|
27
|
+
if (ffmpeg == null) throw new Error('ffmpeg is not installed: brew install ffmpeg')
|
|
28
|
+
mkdirSync(dirname(a.out), { recursive: true })
|
|
29
|
+
const times = stepTimes(a.seconds, a.fps)
|
|
30
|
+
const p = Bun.spawn([ffmpeg, ...domEncodeArgs(a.fps, times.length, a.out)], { stdin: 'pipe', stdout: 'ignore', stderr: 'pipe' })
|
|
31
|
+
const stderr = new Response(p.stderr).text()
|
|
32
|
+
const t0 = performance.now()
|
|
33
|
+
let px = ''
|
|
34
|
+
try {
|
|
35
|
+
for (const t of times) {
|
|
36
|
+
if (!(await page.evaluate(stepTo, [a.step, t] as [string, number]))) throw new Error(`the page has no window.${a.step}(t): a stepped page draws the time it is given there`)
|
|
37
|
+
await waitReady(() => page.evaluate(readMark, undefined), 0, { now: () => performance.now(), sleep: (n) => page.waitForTimeout(n) })
|
|
38
|
+
const shot = await cdp.send('Page.captureScreenshot', { format: 'jpeg', quality: 92 })
|
|
39
|
+
const jpeg = Buffer.from(String(shot['data']), 'base64')
|
|
40
|
+
// the frame's pixels, from the jpeg's start of frame
|
|
41
|
+
const sof = jpeg.indexOf(Buffer.from([0xff, 0xc0]))
|
|
42
|
+
if (px === '' && sof >= 0) px = `${jpeg.readUInt16BE(sof + 7)}x${jpeg.readUInt16BE(sof + 5)}`
|
|
43
|
+
p.stdin.write(jpeg)
|
|
44
|
+
await p.stdin.flush()
|
|
45
|
+
}
|
|
46
|
+
} finally {
|
|
47
|
+
await Promise.resolve(p.stdin.end()).catch(() => undefined)
|
|
48
|
+
}
|
|
49
|
+
const err = await stderr
|
|
50
|
+
if ((await p.exited) !== 0) throw new Error(`ffmpeg encode → ${a.out}: ${err.trim().split('\n').slice(-6).join('\n') || `exit ${p.exitCode}`}`)
|
|
51
|
+
const s = (performance.now() - t0) / 1000
|
|
52
|
+
return `${times.length} frames ${px} at ${a.fps} fps, each stepped through window.${a.step}(t) and its ready mark (${(s / times.length * 1000).toFixed(0)} ms a frame), H.264 by ffmpeg`
|
|
53
|
+
}
|
package/src/trailer/adapter.ts
CHANGED
|
@@ -3,15 +3,38 @@
|
|
|
3
3
|
// resolves the sounds that are not files, names the paths a take's world is, checks the content the language cannot
|
|
4
4
|
// know, refines one film line's take, and turns on the guards that read the repo's own words. Discovered like every
|
|
5
5
|
// unit file (load-unit.ts)
|
|
6
|
-
import type { TakeLength } from './grammar.ts'
|
|
6
|
+
import type { SoundRun, TakeLength } from './grammar.ts'
|
|
7
|
+
import { type Words, wordsProblem } from './words.ts'
|
|
7
8
|
|
|
9
|
+
/** the repo's sounds by id; null: not the repo's, the house makes it (src/trailer/sound/, design call 9) */
|
|
8
10
|
export interface TrailerSounds {
|
|
9
11
|
/** a music id to an audio file of at least `seconds`, cached by the repo */
|
|
10
|
-
music?(id: string, o: { take?: string; seconds: number }): Promise<string>
|
|
12
|
+
music?(id: string, o: { take?: string; seconds: number }): Promise<string | null>
|
|
11
13
|
/** an sfx id to an audio file */
|
|
12
|
-
sfx?(id: string): Promise<string>
|
|
14
|
+
sfx?(id: string): Promise<string | null>
|
|
13
15
|
/** a voice line, found by its text, to an audio file */
|
|
14
|
-
voice?(text: string): Promise<string>
|
|
16
|
+
voice?(text: string): Promise<string | null>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** a sound line the house makes (`resolveSound` and `soundStates` of src/trailer/sound/index.ts, the shape it reads):
|
|
20
|
+
* the line read, the prompt, or a `.mid` file's path (`text`), how it is made (`seed`, `by`, `for`, `hit`), what a music
|
|
21
|
+
* cue covers (`seconds`), or the line's own `run` command */
|
|
22
|
+
export interface SoundRequest {
|
|
23
|
+
kind: 'music' | 'sfx' | 'voice'
|
|
24
|
+
text: string
|
|
25
|
+
seed?: number
|
|
26
|
+
by?: string
|
|
27
|
+
for?: number
|
|
28
|
+
hit?: boolean
|
|
29
|
+
seconds?: number
|
|
30
|
+
line: number
|
|
31
|
+
run?: SoundRun
|
|
32
|
+
/** the cut's folder: a `reads` file is found beside it first, then from the repo root */
|
|
33
|
+
dir: string
|
|
34
|
+
/** the text is a quoted prompt, never an id */
|
|
35
|
+
prompt?: boolean
|
|
36
|
+
/** music: the take of a song id */
|
|
37
|
+
take?: string
|
|
15
38
|
}
|
|
16
39
|
|
|
17
40
|
/** a film line of the project CLI as the unit sees it: its words as written, the text of each `@<file>` word */
|
|
@@ -66,6 +89,8 @@ export interface TrailerDef {
|
|
|
66
89
|
states?: { ignore?: string[]; underWay?: string[] }
|
|
67
90
|
/** a clip take that drops its picture for a few frames is filmed again once, then refused (holes.ts) */
|
|
68
91
|
holes?: boolean
|
|
92
|
+
/** the words a `film page` of this repo takes (words.ts): each checked at its line, its query made plain */
|
|
93
|
+
page?: { words: Words }
|
|
69
94
|
}
|
|
70
95
|
|
|
71
96
|
export type TrailerUnit = TrailerDef & { kind: 'trailer' }
|
|
@@ -73,6 +98,10 @@ export type TrailerUnit = TrailerDef & { kind: 'trailer' }
|
|
|
73
98
|
export function trailer(d: TrailerDef): TrailerUnit {
|
|
74
99
|
for (const [k, f] of Object.entries(d.sound ?? {})) if (typeof f !== 'function') throw new Error(`trailer: sound.${k} is a function returning an audio file`)
|
|
75
100
|
for (const k of ['check', 'take'] as const) if (d[k] !== undefined && typeof d[k] !== 'function') throw new Error(`trailer: ${k} is a function`)
|
|
101
|
+
if (d.page !== undefined) {
|
|
102
|
+
const why = wordsProblem(d.page.words) ?? Object.entries(d.page.words).flatMap(([k, w]) => (w.arg !== undefined ? [`page word ${k} declares arg: a page word has a key (it is the url's query)`] : []))[0] ?? null
|
|
103
|
+
if (why !== null) throw new Error(`trailer: ${why}`)
|
|
104
|
+
}
|
|
76
105
|
if (d.stamp !== undefined && (!Array.isArray(d.stamp) || d.stamp.some((p) => typeof p !== 'string' || p === '' || p.startsWith('/')))) throw new Error('trailer: stamp is a list of repo relative paths')
|
|
77
106
|
return { ...d, kind: 'trailer' }
|
|
78
107
|
}
|