rvlib-shipkit 2.3.0 → 2.4.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 +11 -2
- package/package.json +1 -1
- package/src/assets/kits/identity-kit.ts +5 -2
- package/src/cli/cmd_Commands.ts +4 -0
- package/src/cli/cmd_Review.ts +36 -10
- package/src/cli/cmd_Sound.ts +209 -0
- package/src/cli/cmd_Trailer.ts +7 -2
- package/src/cli/kit/verb.ts +11 -3
- package/src/cli.ts +2 -0
- package/src/core/app-icon.ts +10 -6
- package/src/repo-config.ts +3 -0
- package/src/review/format.ts +11 -3
- package/src/review/model.ts +47 -5
- package/src/review/project.ts +20 -4
- package/src/review/routes.ts +12 -1
- package/src/review/store.ts +15 -6
- package/src/review/unit.ts +19 -1
- 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/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/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 +83 -53
- package/src/serve/hub-app/review/review-page.tsx +9 -14
- package/src/serve/hub-app/review/review-st.ts +141 -18
- package/src/serve/hub-app/review/review-views.tsx +154 -9
- package/src/serve/hub-app/review.css +70 -10
- 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/serve-hub.ts +7 -0
- 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 +170 -29
- 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
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// the wikis page's state, one per repo: the search bar over the open wiki
|
|
2
|
+
// (its title and full text, graph-pages.ts on the server), the hit under the
|
|
3
|
+
// arrows, and the page open in the preview panel. The graph is an iframe that
|
|
4
|
+
// only draws (wiki/graph-page.ts): a click on a ball arrives as a message,
|
|
5
|
+
// and opening a page answers with a focus message, so the ball and the
|
|
6
|
+
// preview always show the same page.
|
|
7
|
+
import { makeAutoObservable, runInAction } from 'mobx'
|
|
8
|
+
import type { WikiHit, WikiPage } from '../../wiki/graph-pages.ts'
|
|
9
|
+
import type { RepoSt } from './repo-st.ts'
|
|
10
|
+
|
|
11
|
+
const WIDTH_KEY = 'hub:wiki-preview-width'
|
|
12
|
+
const HEIGHT_KEY = 'hub:wiki-preview-height'
|
|
13
|
+
const PLACE_KEY = 'hub:wiki-preview-place'
|
|
14
|
+
export const PREVIEW = { min: 260, max: 1200, def: 460 }
|
|
15
|
+
/** the preview below the graph: its height */
|
|
16
|
+
export const PREVIEW_H = { min: 160, max: 1200, def: 360 }
|
|
17
|
+
/** where the preview sits against the graph, toggled from its own head */
|
|
18
|
+
export const PLACES = ['left', 'right', 'bottom'] as const
|
|
19
|
+
export type PreviewPlace = (typeof PLACES)[number]
|
|
20
|
+
/** the wait after a keystroke before the search asks the server */
|
|
21
|
+
const SEARCH_MS = 120
|
|
22
|
+
|
|
23
|
+
function loadSize(key: string, b: { min: number; max: number; def: number }): number {
|
|
24
|
+
try {
|
|
25
|
+
const n = Number(globalThis.localStorage?.getItem(key))
|
|
26
|
+
return Number.isFinite(n) && n >= b.min && n <= b.max ? n : b.def
|
|
27
|
+
} catch {
|
|
28
|
+
return b.def
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function loadPlace(): PreviewPlace {
|
|
33
|
+
try {
|
|
34
|
+
const v = globalThis.localStorage?.getItem(PLACE_KEY) ?? ''
|
|
35
|
+
return (PLACES as readonly string[]).includes(v) ? (v as PreviewPlace) : 'left'
|
|
36
|
+
} catch {
|
|
37
|
+
return 'left'
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function keep(key: string, v: string): void {
|
|
42
|
+
try {
|
|
43
|
+
globalThis.localStorage?.setItem(key, v)
|
|
44
|
+
} catch { /* private window: the choice lasts the tab */ }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class WikiSt {
|
|
48
|
+
/** the open wiki's route, `/graph/<id>`: every read is under it */
|
|
49
|
+
href: string | null = null
|
|
50
|
+
query = ''
|
|
51
|
+
hits: WikiHit[] = []
|
|
52
|
+
/** the hit under the arrows */
|
|
53
|
+
cursor = 0
|
|
54
|
+
/** the dropdown shows */
|
|
55
|
+
listOpen = false
|
|
56
|
+
page: WikiPage | null = null
|
|
57
|
+
error = ''
|
|
58
|
+
width = loadSize(WIDTH_KEY, PREVIEW)
|
|
59
|
+
height = loadSize(HEIGHT_KEY, PREVIEW_H)
|
|
60
|
+
place: PreviewPlace = loadPlace()
|
|
61
|
+
/** the copy button said copied, for a moment */
|
|
62
|
+
copied = false
|
|
63
|
+
private frame: Window | null = null
|
|
64
|
+
private timer: ReturnType<typeof setTimeout> | null = null
|
|
65
|
+
private asked = 0
|
|
66
|
+
|
|
67
|
+
constructor(private readonly st: RepoSt) {
|
|
68
|
+
makeAutoObservable<this, 'st' | 'frame' | 'timer' | 'asked'>(this, { st: false, frame: false, timer: false, asked: false })
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** the wiki shown changed: its search starts over, and its index opens */
|
|
72
|
+
bind(href: string | null): void {
|
|
73
|
+
if (href === this.href) return
|
|
74
|
+
this.href = href
|
|
75
|
+
this.query = ''
|
|
76
|
+
this.hits = []
|
|
77
|
+
this.page = null
|
|
78
|
+
this.error = ''
|
|
79
|
+
this.listOpen = false
|
|
80
|
+
if (href != null) void this.open('')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** the graph's iframe, to send it focus and theme */
|
|
84
|
+
setFrame(w: Window | null): void {
|
|
85
|
+
this.frame = w
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
send(msg: { type: 'wiki-graph:focus'; id: string } | { type: 'wiki-graph:theme'; theme: 'dark' | 'light' }): void {
|
|
89
|
+
this.frame?.postMessage(msg, '*')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
setQuery(q: string): void {
|
|
93
|
+
this.query = q
|
|
94
|
+
this.cursor = 0
|
|
95
|
+
this.listOpen = q.trim() !== ''
|
|
96
|
+
if (this.timer != null) clearTimeout(this.timer)
|
|
97
|
+
if (q.trim() === '') {
|
|
98
|
+
this.hits = []
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
this.timer = setTimeout(() => void this.search(q), SEARCH_MS)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private async search(q: string): Promise<void> {
|
|
105
|
+
if (this.href == null) return
|
|
106
|
+
const n = ++this.asked
|
|
107
|
+
try {
|
|
108
|
+
const r = await fetch(this.st.url(`${this.href}/search?q=${encodeURIComponent(q)}`))
|
|
109
|
+
if (!r.ok) throw new Error(`${r.status} ${await r.text()}`)
|
|
110
|
+
const hits = (await r.json()) as WikiHit[]
|
|
111
|
+
// an answer to an older query never replaces a newer one
|
|
112
|
+
if (n === this.asked) runInAction(() => { this.hits = hits; this.error = '' })
|
|
113
|
+
} catch (e) {
|
|
114
|
+
if (n === this.asked) runInAction(() => (this.error = `search failed: ${(e as Error).message}`))
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** the search input's keys: up and down walk the hits, enter opens one, escape closes the list */
|
|
119
|
+
key(k: string): boolean {
|
|
120
|
+
if (k === 'Escape') {
|
|
121
|
+
if (!this.listOpen) return false
|
|
122
|
+
this.listOpen = false
|
|
123
|
+
return true
|
|
124
|
+
}
|
|
125
|
+
if (this.hits.length === 0) return false
|
|
126
|
+
if (k === 'ArrowDown') this.cursor = Math.min(this.hits.length - 1, this.cursor + 1)
|
|
127
|
+
else if (k === 'ArrowUp') this.cursor = Math.max(0, this.cursor - 1)
|
|
128
|
+
else if (k === 'Enter') void this.open(this.hits[this.cursor]!.id)
|
|
129
|
+
else return false
|
|
130
|
+
if (k !== 'Enter') this.listOpen = true
|
|
131
|
+
return true
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** a page opens in the preview, and its ball is focused in the graph; '' = the wiki's index */
|
|
135
|
+
async open(id: string): Promise<void> {
|
|
136
|
+
const href = this.href
|
|
137
|
+
if (href == null) return
|
|
138
|
+
this.listOpen = false
|
|
139
|
+
if (id !== '') this.send({ type: 'wiki-graph:focus', id })
|
|
140
|
+
try {
|
|
141
|
+
const r = await fetch(this.st.url(`${href}/page?node=${encodeURIComponent(id)}`))
|
|
142
|
+
if (!r.ok) throw new Error(`${r.status} ${await r.text()}`)
|
|
143
|
+
const p = (await r.json()) as WikiPage
|
|
144
|
+
// the wiki changed while the page was read: it is not this one's
|
|
145
|
+
if (href !== this.href) return
|
|
146
|
+
runInAction(() => { this.page = p; this.error = '' })
|
|
147
|
+
if (id === '') this.send({ type: 'wiki-graph:focus', id: p.id })
|
|
148
|
+
} catch (e) {
|
|
149
|
+
runInAction(() => (this.error = `${id === '' ? 'the index' : id} would not open: ${(e as Error).message}`))
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
setPlace(p: PreviewPlace): void {
|
|
154
|
+
this.place = p
|
|
155
|
+
keep(PLACE_KEY, p)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** the open page's path on disk, to the clipboard */
|
|
159
|
+
async copyPath(): Promise<void> {
|
|
160
|
+
if (this.page == null) return
|
|
161
|
+
try {
|
|
162
|
+
await navigator.clipboard.writeText(this.page.file)
|
|
163
|
+
runInAction(() => (this.copied = true))
|
|
164
|
+
setTimeout(() => runInAction(() => (this.copied = false)), 1500)
|
|
165
|
+
} catch (e) {
|
|
166
|
+
runInAction(() => (this.error = `the path was not copied: ${(e as Error).message}`))
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
close(): void {
|
|
171
|
+
this.page = null
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** a drag of the preview's separator ended at this size: its width beside the graph, its height under it */
|
|
175
|
+
setSize(px: number): void {
|
|
176
|
+
if (this.place === 'bottom') {
|
|
177
|
+
this.height = Math.round(Math.min(PREVIEW_H.max, Math.max(PREVIEW_H.min, px)))
|
|
178
|
+
keep(HEIGHT_KEY, String(this.height))
|
|
179
|
+
} else {
|
|
180
|
+
this.width = Math.round(Math.min(PREVIEW.max, Math.max(PREVIEW.min, px)))
|
|
181
|
+
keep(WIDTH_KEY, String(this.width))
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** a message from the graph: a ball was clicked */
|
|
186
|
+
onMessage(data: unknown): void {
|
|
187
|
+
const m = data as { type?: unknown; id?: unknown } | null
|
|
188
|
+
if (m != null && m.type === 'wiki-graph:pick' && typeof m.id === 'string') void this.open(m.id)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
// The wikis page: one tab per mounted markdown wiki, one link graph at a
|
|
2
2
|
// time, embedded from its own /graph/<id> route. A tab is an item of the
|
|
3
3
|
// wikis collection, so the open wiki is in the url like any other page.
|
|
4
|
+
// Over the graph a search bar (title and full text, a dropdown the arrows
|
|
5
|
+
// walk); a hit, or a click on a ball, opens the page in a resizable panel on
|
|
6
|
+
// the right, whose refs open their page in turn and focus its ball (wiki-st.ts).
|
|
4
7
|
import { observer } from 'mobx-react-lite'
|
|
8
|
+
import { useEffect, useRef, type MouseEvent } from 'react'
|
|
9
|
+
import { Group, Panel, Separator, type Layout } from 'react-resizable-panels'
|
|
10
|
+
import { renderWikiPage } from './md.ts'
|
|
11
|
+
import { editorHref } from './check-words.ts'
|
|
12
|
+
import { Icon, ICON } from './icons.tsx'
|
|
13
|
+
import { PLACES, PREVIEW, PREVIEW_H, type PreviewPlace, type WikiSt } from './wiki-st.ts'
|
|
5
14
|
import type { RepoSt } from './repo-st.ts'
|
|
6
15
|
import type { Resource } from './hub-types.ts'
|
|
7
16
|
|
|
@@ -33,8 +42,13 @@ export function openWikis(app: RepoSt): void {
|
|
|
33
42
|
else app.showCol(WIKIS)
|
|
34
43
|
}
|
|
35
44
|
|
|
45
|
+
const PANEL = { overflow: 'hidden' } as const
|
|
46
|
+
|
|
36
47
|
export const WikisView = observer(({ app, id }: { app: RepoSt; id: string }) => {
|
|
37
48
|
const i = app.cols.find(WIKIS, id)
|
|
49
|
+
const w = app.wiki
|
|
50
|
+
const href = i?.href ?? null
|
|
51
|
+
useEffect(() => w.bind(href), [w, href])
|
|
38
52
|
const tabs = (
|
|
39
53
|
<span className="seg">
|
|
40
54
|
{wikiTabs(app).map((t) => <button key={t.id} className={t.on ? 'on' : ''} onClick={t.go}><span className={`tl ${t.state}`}>{t.label}</span></button>)}
|
|
@@ -45,13 +59,128 @@ export const WikisView = observer(({ app, id }: { app: RepoSt; id: string }) =>
|
|
|
45
59
|
<div className="graphview">
|
|
46
60
|
<div className="gvhead">
|
|
47
61
|
{tabs}
|
|
62
|
+
{href == null ? null : <WikiSearch w={w} />}
|
|
48
63
|
<span className={`gvtext tl ${i.state}`}>{i.text}</span>
|
|
49
64
|
</div>
|
|
50
|
-
{
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
65
|
+
{w.error !== '' ? <div className="hnote">{w.error}</div> : null}
|
|
66
|
+
{href == null ? <div className="ph">this wiki carries no page to embed</div> : <WikiBody app={app} w={w} href={href} title={i.title} />}
|
|
67
|
+
</div>
|
|
68
|
+
)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
/** the graph and, once a page is open, its preview on the right; the preview's width is dragged and kept */
|
|
72
|
+
const WikiBody = observer(({ app, w, href, title }: { app: RepoSt; w: WikiSt; href: string; title: string }) => {
|
|
73
|
+
const el = useRef<HTMLDivElement | null>(null)
|
|
74
|
+
const frame = useRef<HTMLIFrameElement | null>(null)
|
|
75
|
+
const theme = app.home.theme.resolved
|
|
76
|
+
// the theme the graph first paints in rides its url; a later change is a message, never a reload
|
|
77
|
+
const first = useRef(theme)
|
|
78
|
+
useEffect(() => w.send({ type: 'wiki-graph:theme', theme }), [w, theme])
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
const on = (ev: MessageEvent): void => {
|
|
81
|
+
if (ev.source != null && ev.source === frame.current?.contentWindow) w.onMessage(ev.data)
|
|
82
|
+
}
|
|
83
|
+
addEventListener('message', on)
|
|
84
|
+
return () => removeEventListener('message', on)
|
|
85
|
+
}, [w])
|
|
86
|
+
const sep = href.includes('?') ? '&' : '?'
|
|
87
|
+
const below = w.place === 'bottom'
|
|
88
|
+
const graph = (
|
|
89
|
+
<Panel key="graph" id="wiki-graph" minSize={below ? 160 : 280} style={PANEL}>
|
|
90
|
+
{/* the spinner sits under the frame: the graph page paints its own background over it the moment it has something to show */}
|
|
91
|
+
<div className="gvframe"><span className="gvwait"><span className="spin" />reading the wiki…</span>
|
|
92
|
+
<iframe ref={(f) => { frame.current = f; w.setFrame(f?.contentWindow ?? null) }} src={`${app.url(href)}${sep}theme=${first.current}`} title={title}
|
|
93
|
+
onLoad={() => {
|
|
94
|
+
w.setFrame(frame.current?.contentWindow ?? null)
|
|
95
|
+
w.send({ type: 'wiki-graph:theme', theme: app.home.theme.resolved })
|
|
96
|
+
if (w.page != null) w.send({ type: 'wiki-graph:focus', id: w.page.id })
|
|
97
|
+
}} />
|
|
98
|
+
</div>
|
|
99
|
+
</Panel>
|
|
100
|
+
)
|
|
101
|
+
const preview = w.page == null ? null : (
|
|
102
|
+
<Panel key="preview" id="wiki-preview" defaultSize={below ? w.height : w.width} minSize={below ? PREVIEW_H.min : PREVIEW.min} maxSize={below ? PREVIEW_H.max : PREVIEW.max}
|
|
103
|
+
groupResizeBehavior="preserve-pixel-size" style={PANEL}>
|
|
104
|
+
<WikiPreview w={w} />
|
|
105
|
+
</Panel>
|
|
106
|
+
)
|
|
107
|
+
const line = preview == null ? null : <Separator key="sep" className={below ? 'rsep hsep' : 'rsep'} />
|
|
108
|
+
return (
|
|
109
|
+
// the group is remade when the preview moves: its panels change order and axis
|
|
110
|
+
<Group key={w.place} className="gvbody" orientation={below ? 'vertical' : 'horizontal'} elementRef={el}
|
|
111
|
+
onLayoutChanged={(layout: Layout, meta) => {
|
|
112
|
+
const pct = layout['wiki-preview']
|
|
113
|
+
const r = el.current?.getBoundingClientRect()
|
|
114
|
+
const whole = below ? r?.height : r?.width
|
|
115
|
+
if (meta.isUserInteraction && pct != null && whole != null && whole > 0) w.setSize((pct / 100) * whole)
|
|
116
|
+
}}>
|
|
117
|
+
{w.place === 'left' ? [preview, line, graph] : [graph, line, preview]}
|
|
118
|
+
</Group>
|
|
119
|
+
)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
/** the search bar: the hits drop under it, the arrows walk them, enter or a click opens one */
|
|
123
|
+
const WikiSearch = observer(({ w }: { w: WikiSt }) => (
|
|
124
|
+
<span className="wsearch">
|
|
125
|
+
<input type="search" placeholder="search the wiki: a title, a word…" aria-label="search the wiki" value={w.query}
|
|
126
|
+
onChange={(e) => w.setQuery(e.currentTarget.value)}
|
|
127
|
+
onFocus={() => { if (w.hits.length > 0) w.key('ArrowUp') }}
|
|
128
|
+
onBlur={() => w.key('Escape')}
|
|
129
|
+
onKeyDown={(e) => { if (w.key(e.key)) e.preventDefault() }} />
|
|
130
|
+
{w.listOpen && w.query.trim() !== '' ? (
|
|
131
|
+
<div className="wslist" role="listbox">
|
|
132
|
+
{w.hits.length === 0 ? <div className="wsnone">no page matches</div> : w.hits.map((h, n) => (
|
|
133
|
+
// mouse down, not click: the input's blur would close the list first
|
|
134
|
+
<div key={h.id} role="option" aria-selected={n === w.cursor} className={`wshit${n === w.cursor ? ' on' : ''}`}
|
|
135
|
+
onMouseDown={(e) => { e.preventDefault(); void w.open(h.id) }}>
|
|
136
|
+
<div className="wshead"><b>{h.title}</b><span className="wspath">{h.id}</span></div>
|
|
137
|
+
{h.snippet === '' || h.inTitle && h.snippet === '' ? null : <div className="wssnip">{h.snippet}</div>}
|
|
138
|
+
</div>
|
|
139
|
+
))}
|
|
140
|
+
</div>
|
|
141
|
+
) : null}
|
|
142
|
+
</span>
|
|
143
|
+
))
|
|
144
|
+
|
|
145
|
+
const PLACE_ICON: Record<PreviewPlace, keyof typeof ICON> = { left: 'paneLeft', right: 'paneRight', bottom: 'paneBottom' }
|
|
146
|
+
|
|
147
|
+
/** the open page: its title and path, where the panel sits, the editor and
|
|
148
|
+
* the copy, the close; then the markdown, whose refs open their own page */
|
|
149
|
+
const WikiPreview = observer(({ w }: { w: WikiSt }) => {
|
|
150
|
+
const p = w.page!
|
|
151
|
+
const editor = editorHref('', p.file)
|
|
152
|
+
const click = (e: MouseEvent<HTMLDivElement>): void => {
|
|
153
|
+
const t = e.target as HTMLElement
|
|
154
|
+
const a = t.closest('a[data-wiki]')
|
|
155
|
+
if (a != null) {
|
|
156
|
+
e.preventDefault()
|
|
157
|
+
void w.open(a.getAttribute('data-wiki')!)
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
// a ref in a code span opens its page by its text
|
|
161
|
+
const code = t.closest('code')
|
|
162
|
+
const to = code == null ? undefined : p.links[code.textContent ?? '']
|
|
163
|
+
if (to != null) void w.open(to)
|
|
164
|
+
}
|
|
165
|
+
return (
|
|
166
|
+
<div className="wprev">
|
|
167
|
+
<div className="wphead">
|
|
168
|
+
<b className="wptitle">{p.title}</b>
|
|
169
|
+
<span className="wspath" title={p.file}>{p.id}</span>
|
|
170
|
+
<span className="seg wpplace" aria-label="where the preview sits">
|
|
171
|
+
{PLACES.map((pl) => (
|
|
172
|
+
<button key={pl} className={w.place === pl ? 'on' : ''} aria-label={`the preview on the ${pl}`} title={`the preview on the ${pl}`} onClick={() => w.setPlace(pl)}>
|
|
173
|
+
<Icon d={ICON[PLACE_ICON[pl]]} />
|
|
174
|
+
</button>
|
|
175
|
+
))}
|
|
176
|
+
</span>
|
|
177
|
+
{editor == null ? null : (
|
|
178
|
+
<a className="wpbtn" href={editor} title={`open ${p.file} in VS Code`}><Icon d={ICON.editor} /><span>VS Code</span></a>
|
|
179
|
+
)}
|
|
180
|
+
<button className="wpbtn" title={`copy ${p.file}`} onClick={() => void w.copyPath()}><Icon d={ICON.copy} /><span>{w.copied ? 'copied' : 'copy path'}</span></button>
|
|
181
|
+
<button className="ghost wpclose" aria-label="close the preview" title="close the preview" onClick={() => w.close()}>×</button>
|
|
182
|
+
</div>
|
|
183
|
+
<div className="wpbody md readme-body" onClick={click} dangerouslySetInnerHTML={{ __html: renderWikiPage(p.md, p.links) }} />
|
|
55
184
|
</div>
|
|
56
185
|
)
|
|
57
186
|
})
|
package/src/serve/hub-runtime.ts
CHANGED
|
@@ -146,15 +146,15 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
|
|
|
146
146
|
// agent working here obeys that wiki, so a reader can open it.
|
|
147
147
|
const graphs: GraphMount[] = []
|
|
148
148
|
if (c.serve?.wikiGraph != null)
|
|
149
|
-
graphs.push({ id: 'wiki', label: WIKI_LABEL, title: c.serve.wikiGraph.title ?? `${c.name} wiki`, load: c.serve.wikiGraph.load, lint: c.serve.wikiGraph.lint != null ? { checks: c.serve.wikiGraph.lint } : { root } })
|
|
149
|
+
graphs.push({ id: 'wiki', label: WIKI_LABEL, title: c.serve.wikiGraph.title ?? `${c.name} wiki`, load: c.serve.wikiGraph.load, dir: c.serve.wikiGraph.dir ?? root, lint: c.serve.wikiGraph.lint != null ? { checks: c.serve.wikiGraph.lint } : { root } })
|
|
150
150
|
else if (existsSync(join(root, 'wiki')))
|
|
151
|
-
graphs.push({ id: 'wiki', label: WIKI_LABEL, title: `${c.name} wiki`, load: () => scanMdGraph({ root }), lint: { root } })
|
|
151
|
+
graphs.push({ id: 'wiki', label: WIKI_LABEL, title: `${c.name} wiki`, load: () => scanMdGraph({ root }), dir: root, lint: { root } })
|
|
152
152
|
const baseWiki = baseWikiRoot(root)
|
|
153
153
|
if (baseWiki != null)
|
|
154
|
-
graphs.push({ id: 'base', label: wikiLabel(baseWiki), title: wikiLabel(baseWiki), load: () => scanMdGraph({ root: baseWiki }), lint: null })
|
|
154
|
+
graphs.push({ id: 'base', label: wikiLabel(baseWiki), title: wikiLabel(baseWiki), load: () => scanMdGraph({ root: baseWiki }), dir: baseWiki, lint: null })
|
|
155
155
|
const agentRoot = join(root, 'agent')
|
|
156
156
|
if (existsSync(agentRoot))
|
|
157
|
-
graphs.push({ id: 'agent', label: 'agent/', title: `${c.name} agent/`, load: () => scanMdGraph({ root: agentRoot, index: c.serve?.agentIndex }), lint: { root: agentRoot, index: c.serve?.agentIndex } })
|
|
157
|
+
graphs.push({ id: 'agent', label: 'agent/', title: `${c.name} agent/`, load: () => scanMdGraph({ root: agentRoot, index: c.serve?.agentIndex }), dir: agentRoot, lint: { root: agentRoot, index: c.serve?.agentIndex } })
|
|
158
158
|
// ONE page for every repo: the studio ALWAYS
|
|
159
159
|
// mounts — kit-less repos get an empty media section, never a second
|
|
160
160
|
// page. A broken kit degrades to the same empty kit, loudly, and a repo
|
|
@@ -30,6 +30,8 @@ export type { Gate }
|
|
|
30
30
|
export const POLL_MS = 5_000
|
|
31
31
|
/** the app icon preview: sharp on a 2x screen at the admin page's 128px */
|
|
32
32
|
const PREVIEW_PX = 256
|
|
33
|
+
/** the rail's app icon, cropped to the plate: 40px at 2x, the largest rail size */
|
|
34
|
+
const RAIL_PX = 96
|
|
33
35
|
/** how long a release answer holds for a checkout whose `.git` is not a
|
|
34
36
|
* folder, since no stat can say when it moved */
|
|
35
37
|
export const RELEASE_TTL_MS = 60_000
|
|
@@ -74,6 +76,8 @@ export type ProjectRow = {
|
|
|
74
76
|
live: string | null
|
|
75
77
|
/** the colours its repo.config.ts declares (SK024), null when it declares none */
|
|
76
78
|
identity: Identity | null
|
|
79
|
+
/** its app icon cropped to the plate (core/app-icon.ts), what the folded rail draws; null with no mark */
|
|
80
|
+
appIcon: string | null
|
|
77
81
|
/** epoch ms of the newest of: the last commit, the last shipkit run, the
|
|
78
82
|
* newest Claude Code transcript. null when none of them exists */
|
|
79
83
|
activity: number | null
|
|
@@ -298,6 +302,15 @@ export function projectGutter(d: GutterDeps) {
|
|
|
298
302
|
}
|
|
299
303
|
|
|
300
304
|
const iconUrl = (r: WiredRepo): string | null => (r.icon == null ? null : `/api/projects/icon/${encodeURIComponent(r.name)}?v=${iconStamp(join(r.path, r.icon))}`)
|
|
305
|
+
// the url names the mark's stamp and both colours, so a new mark or a colour change is a new url, cached a day
|
|
306
|
+
const railIconUrl = (r: WiredRepo, id: Identity): string | null => {
|
|
307
|
+
const mark = appIconOf(r.path)
|
|
308
|
+
if (mark == null) return null
|
|
309
|
+
const q = new URLSearchParams({ plate: '1', light: id.colorLight, dark: id.colorDark, v: String(iconStamp(mark)) })
|
|
310
|
+
return `/api/projects/app-icon/${encodeURIComponent(r.name)}?${q}`
|
|
311
|
+
}
|
|
312
|
+
/** drawn plates, by url: a page load asks for every repo's at once */
|
|
313
|
+
const plates = new Map<string, Promise<Buffer>>()
|
|
301
314
|
|
|
302
315
|
async function measure(): Promise<ProjectsAnswer> {
|
|
303
316
|
const t0 = performance.now()
|
|
@@ -334,6 +347,7 @@ export function projectGutter(d: GutterDeps) {
|
|
|
334
347
|
release: facts[i]!.release,
|
|
335
348
|
live: liveUrlOf(config),
|
|
336
349
|
identity: identityOfText(config),
|
|
350
|
+
appIcon: railIconUrl(r, identityOfRoot(r.path)),
|
|
337
351
|
activity: lastActivity(facts[i]!.commitAt, lastRunAt(r.path), transcripts.get(r.path)),
|
|
338
352
|
commit: facts[i]!.commitAt,
|
|
339
353
|
}
|
|
@@ -386,12 +400,22 @@ export function projectGutter(d: GutterDeps) {
|
|
|
386
400
|
const r = findAny(decodeURIComponent(path.slice('/api/projects/app-icon/'.length)))
|
|
387
401
|
if (r == null) return new Response('not a house repo', { status: 404 })
|
|
388
402
|
const icon = appIconOf(r.path)
|
|
389
|
-
if (icon == null) return new Response('no
|
|
403
|
+
if (icon == null) return new Response('no mark: the app wears the default icon', { status: 404 })
|
|
390
404
|
// the colours a picker is on right now, before they are saved; else the config's
|
|
391
405
|
const q = new URL(req.url).searchParams
|
|
392
406
|
const asked = { colorLight: q.get('light') ?? '', colorDark: q.get('dark') ?? '' }
|
|
393
407
|
const id = identityProblem(asked) == null ? asked : identityOfRoot(r.path)
|
|
394
408
|
try {
|
|
409
|
+
if (q.get('plate') === '1') {
|
|
410
|
+
const key = `${r.path}?${q}`
|
|
411
|
+
let png = plates.get(key)
|
|
412
|
+
if (png == null) {
|
|
413
|
+
png = readFile(icon).then(async (b) => (await appIconPngs(b, id, [RAIL_PX], 'plate'))[0]!)
|
|
414
|
+
plates.set(key, png)
|
|
415
|
+
png.catch(() => plates.delete(key))
|
|
416
|
+
}
|
|
417
|
+
return new Response(new Uint8Array(await png), { headers: { 'content-type': 'image/png', 'cache-control': 'max-age=86400' } })
|
|
418
|
+
}
|
|
395
419
|
const [png] = await appIconPngs(await readFile(icon), id, [PREVIEW_PX])
|
|
396
420
|
return new Response(new Uint8Array(png!), { headers: { 'content-type': 'image/png', 'cache-control': 'max-age=60' } })
|
|
397
421
|
} catch (e) {
|
|
@@ -48,7 +48,7 @@ export function houseIndex(repo: string, home: string): string | null {
|
|
|
48
48
|
|
|
49
49
|
/** a repo's own icon, first hit wins. `.shipkit/identity/icon.png` is the rail
|
|
50
50
|
* icon a repo with no public surface carries, the identity kit writes public/ */
|
|
51
|
-
export const ICON_FILES = ['.shipkit/identity/icon.png', 'public/favicon.png', 'public/favicon.svg', 'public/favicon.ico', 'favicon.ico', 'public/logo.png']
|
|
51
|
+
export const ICON_FILES = ['.shipkit/identity/icon.png', '.shipkit/identity/icon.svg', 'public/favicon.png', 'public/favicon.svg', 'public/favicon.ico', 'favicon.ico', 'public/logo.png']
|
|
52
52
|
|
|
53
53
|
export const ICON_TYPES: Record<string, string> = { png: 'image/png', svg: 'image/svg+xml', ico: 'image/x-icon' }
|
|
54
54
|
|
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'
|
|
@@ -411,6 +412,12 @@ export function hubHandler(d: HubDeps, o: { base?: string; projects?: Gutter } =
|
|
|
411
412
|
if (path === `/graph/${g.id}`) return page(routes.page())
|
|
412
413
|
if (path === `/graph/${g.id}/graph.json`) return Response.json(held)
|
|
413
414
|
if (path === `/graph/${g.id}/positions`) return routes.save(req)
|
|
415
|
+
// the wikis page's search bar and its preview panel: only a node of the graph is read
|
|
416
|
+
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') ?? ''))
|
|
417
|
+
if (path === `/graph/${g.id}/page`) {
|
|
418
|
+
const p = g.dir == null ? null : pageOf(held, g.dir, new URL(req.url).searchParams.get('node') ?? '')
|
|
419
|
+
return p == null ? new Response('not a page of this wiki', { status: 404 }) : Response.json(p)
|
|
420
|
+
}
|
|
414
421
|
}
|
|
415
422
|
// the media API namespace — the studio route table, ONE prefix strip
|
|
416
423
|
if (d.studio != null && path.startsWith('/media/')) return d.studio.handle(req, path.slice('/media'.length))
|
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"
|