rvlib-shipkit 2.2.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/package.json +1 -1
- package/src/agent-time/mask.ts +43 -0
- package/src/agent-time/render.ts +187 -0
- package/src/agent-time/report.ts +335 -0
- package/src/agent-time/shape.ts +271 -0
- package/src/agent-time/signals.ts +84 -0
- package/src/agent-time/store.ts +379 -0
- package/src/agent-time/timeline.ts +218 -0
- package/src/agent-time/turns.ts +135 -0
- package/src/cli/cmd_Commands.ts +5 -0
- package/src/cli/cmd_Review.ts +948 -0
- package/src/cli/cmd_Time.ts +112 -0
- package/src/cli/cmd_Trailer.ts +259 -6
- package/src/cli/kit/verb.ts +14 -4
- package/src/cli.ts +6 -4
- package/src/collections/collection-types.ts +6 -1
- package/src/collections/providers.ts +3 -0
- package/src/collections/review.ts +70 -0
- package/src/core/layout.ts +2 -0
- package/src/reflect/prompt-corpus.ts +15 -119
- package/src/reflect/transcript.ts +254 -0
- package/src/review/anim.ts +228 -0
- package/src/review/auto.ts +141 -0
- package/src/review/format.ts +150 -0
- package/src/review/http.ts +23 -0
- package/src/review/marks.ts +43 -0
- package/src/review/media.ts +45 -0
- package/src/review/model.ts +854 -0
- package/src/review/project.ts +110 -0
- package/src/review/role.ts +48 -0
- package/src/review/routes.ts +228 -0
- package/src/review/store.ts +1002 -0
- package/src/review/threads.ts +237 -0
- package/src/review/trailer-desk.ts +385 -0
- package/src/review/trailer-model.ts +183 -0
- package/src/review/trailer-routes.ts +136 -0
- package/src/review/unit.ts +165 -0
- package/src/review/watchers.ts +54 -0
- package/src/serve/embed.ts +35 -0
- package/src/serve/hub-app/activity-view.tsx +1 -1
- package/src/serve/hub-app/activity.css +0 -4
- package/src/serve/hub-app/dock-view.tsx +5 -4
- package/src/serve/hub-app/hash-route.ts +28 -1
- package/src/serve/hub-app/header-bar.tsx +3 -1
- package/src/serve/hub-app/home-st.ts +10 -2
- package/src/serve/hub-app/home-strip.tsx +3 -2
- package/src/serve/hub-app/home.html +1 -0
- package/src/serve/hub-app/hub-app.tsx +80 -11
- package/src/serve/hub-app/hub.css +45 -13
- package/src/serve/hub-app/icons.tsx +9 -0
- package/src/serve/hub-app/index.html +1 -0
- package/src/serve/hub-app/insights-view.tsx +4 -3
- package/src/serve/hub-app/keys.ts +14 -10
- package/src/serve/hub-app/layout-st.ts +63 -2
- package/src/serve/hub-app/planned-view.tsx +3 -2
- package/src/serve/hub-app/planned.css +0 -1
- package/src/serve/hub-app/project-list.css +22 -4
- package/src/serve/hub-app/project-list.tsx +41 -8
- package/src/serve/hub-app/projects-svc.ts +4 -0
- package/src/serve/hub-app/repo-sections.ts +15 -3
- package/src/serve/hub-app/repo-st.ts +45 -3
- package/src/serve/hub-app/review/clip-st.ts +208 -0
- package/src/serve/hub-app/review/kind-view.tsx +171 -0
- package/src/serve/hub-app/review/kit.tsx +113 -0
- package/src/serve/hub-app/review/review-answer.tsx +239 -0
- package/src/serve/hub-app/review/review-board.tsx +46 -0
- package/src/serve/hub-app/review/review-list.tsx +156 -0
- package/src/serve/hub-app/review/review-page.tsx +101 -0
- package/src/serve/hub-app/review/review-st.ts +779 -0
- package/src/serve/hub-app/review/review-views.tsx +503 -0
- package/src/serve/hub-app/review/trailer-page.tsx +416 -0
- package/src/serve/hub-app/review/trailer-st.ts +602 -0
- package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
- package/src/serve/hub-app/review.css +329 -0
- package/src/serve/hub-app/theme-st.ts +56 -0
- package/src/serve/hub-app/tree-build.ts +4 -0
- package/src/serve/hub-app/tree-row.tsx +1 -1
- package/src/serve/hub-app/tree-svc.ts +4 -0
- package/src/serve/hub-runtime.ts +5 -3
- package/src/serve/same-origin.ts +5 -1
- package/src/serve/serve-hub.ts +8 -0
- package/src/shipkit-files.ts +9 -5
- package/src/trailer/adapter.ts +58 -3
- package/src/trailer/build.ts +300 -41
- package/src/trailer/ffmpeg.ts +22 -0
- package/src/trailer/footage.ts +109 -40
- package/src/trailer/grammar.ts +444 -69
- package/src/trailer/graph.ts +199 -14
- package/src/trailer/holes.ts +72 -0
- package/src/trailer/load-unit.ts +20 -3
- package/src/trailer/look.ts +17 -3
- package/src/trailer/passage.ts +129 -0
- package/src/trailer/plan.ts +80 -21
- package/src/trailer/prune.ts +193 -0
- package/src/trailer/score/compose.ts +240 -0
- package/src/trailer/score/dsp.ts +272 -0
- package/src/trailer/score/palette.ts +188 -0
- package/src/trailer/score/pattern.ts +91 -0
- package/src/trailer/score/score.ts +214 -0
- package/src/trailer/score/sound.ts +112 -0
- package/src/trailer/score/synth.ts +361 -0
- package/src/trailer/states.ts +76 -0
- package/src/trailer/stitch.ts +92 -9
- package/src/trailer/titles.ts +123 -18
- package/src/trailer/vocab.ts +23 -4
- package/src/trailer/words.ts +254 -0
package/src/trailer/titles.ts
CHANGED
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
// the trailer's drawn frames: one transparent PNG per title, cut to what it draws, and the card's ground, in the repo's
|
|
2
|
-
// look (look.ts).
|
|
2
|
+
// look (look.ts). Seven styles: logo, big, caption, corner, line, and a short's words (short, progress) in the look's
|
|
3
|
+
// short face. Cached on what they draw, so a title edit draws one PNG
|
|
3
4
|
import { createCanvas, GlobalFonts, loadImage, type Image, type SKRSContext2D } from '@napi-rs/canvas'
|
|
4
5
|
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
5
6
|
import { join } from 'node:path'
|
|
6
7
|
import sharp from 'sharp'
|
|
7
8
|
import type { Title, TitleStyle } from './grammar.ts'
|
|
8
|
-
import { alpha, darker, type TrailerLook } from './look.ts'
|
|
9
|
+
import { alpha, darker, shortLook, type TrailerLook } from './look.ts'
|
|
9
10
|
|
|
10
11
|
/** bump when a style's drawing changes: every cached title draws again */
|
|
11
12
|
const DRAWING = 1
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
/** where a title stands: as `place` says; a short's caption in the frame's middle, a progress bar at the top; else
|
|
15
|
+
* the bottom */
|
|
16
|
+
export type TitlePlace = 'top' | 'middle' | 'bottom'
|
|
17
|
+
export const placeOf = (ti: Pick<Title, 'place' | 'style'>): TitlePlace => ti.place ?? (ti.style === 'short' ? 'middle' : ti.style === 'progress' ? 'top' : 'bottom')
|
|
18
|
+
|
|
19
|
+
/** a short's words centred on these shares of the height, inside the phones' safe zone: under the lockup (`top`), the
|
|
20
|
+
* frame's middle, where the end card's lines stand (`bottom`) */
|
|
21
|
+
export const SHORT_Y: Record<TitlePlace, number> = { top: 0.31, middle: 0.5, bottom: 0.645 }
|
|
15
22
|
|
|
16
23
|
/** a title's PNG cut to what it draws, and where it sits on the frame: the stitch blends that box, never the whole frame */
|
|
17
24
|
export interface TitleImage {
|
|
@@ -23,13 +30,15 @@ export interface TitleImage {
|
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
const registered = new Set<string>()
|
|
26
|
-
/** the look's font
|
|
33
|
+
/** the look's font files registered once under their families */
|
|
27
34
|
export function registerFace(look: TrailerLook, root: string): void {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
35
|
+
for (const f of [look.font, look.short.font]) {
|
|
36
|
+
if (!f.file || registered.has(f.file)) continue
|
|
37
|
+
const abs = join(root, f.file)
|
|
38
|
+
if (!existsSync(abs)) throw new Error(`the brand's trailer font ${f.file} is not in the repo`)
|
|
39
|
+
GlobalFonts.registerFromPath(abs, f.family)
|
|
40
|
+
registered.add(f.file)
|
|
41
|
+
}
|
|
33
42
|
}
|
|
34
43
|
|
|
35
44
|
export const face = (g: SKRSContext2D, look: TrailerLook, px: number, weight = look.font.weight): void => {
|
|
@@ -72,8 +81,93 @@ function plate(g: SKRSContext2D, x: number, y: number, w: number, h: number, r:
|
|
|
72
81
|
g.stroke()
|
|
73
82
|
}
|
|
74
83
|
|
|
84
|
+
/** words in the light on a thick edge outline, centred on `x`, its top at `y` */
|
|
85
|
+
function outlined(g: SKRSContext2D, look: TrailerLook, text: string, x: number, y: number, size: number, k: number, fill: string): void {
|
|
86
|
+
face(g, look, size)
|
|
87
|
+
g.textAlign = 'center'
|
|
88
|
+
g.textBaseline = 'top'
|
|
89
|
+
g.lineJoin = 'round'
|
|
90
|
+
g.lineWidth = Math.round(size * 0.16)
|
|
91
|
+
g.strokeStyle = look.edge
|
|
92
|
+
g.shadowColor = alpha(look.edge, 0.5)
|
|
93
|
+
g.shadowBlur = 18 * k
|
|
94
|
+
g.shadowOffsetY = 4 * k
|
|
95
|
+
g.strokeText(text, x, y)
|
|
96
|
+
g.shadowColor = 'transparent'
|
|
97
|
+
g.fillStyle = fill
|
|
98
|
+
g.fillText(text, x, y)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** a line of words, `*word*` in the accent, the whole line centred on `top` */
|
|
102
|
+
function starred(g: SKRSContext2D, look: TrailerLook, line: string, W: number, top: number, size: number, k: number): void {
|
|
103
|
+
const words = line.split(' ').map((x) => ({ text: x.replaceAll('*', ''), key: x.includes('*') }))
|
|
104
|
+
face(g, look, size)
|
|
105
|
+
const space = g.measureText(' ').width
|
|
106
|
+
let x = (W - (words.reduce((n, w) => n + g.measureText(w.text).width, 0) + space * (words.length - 1))) / 2
|
|
107
|
+
for (const w of words) {
|
|
108
|
+
const ww = g.measureText(w.text).width
|
|
109
|
+
outlined(g, look, w.text, x + ww / 2, top, size, k, w.key ? look.accent : look.light)
|
|
110
|
+
x += ww + space
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** a short's caption: the words large, centred on `y` (a share of the height), a `\n` breaking the line, over a soft
|
|
115
|
+
* band of shade; sized by the frame's width */
|
|
116
|
+
function drawShort(g: SKRSContext2D, look: TrailerLook, text: string, y: number, W: number, H: number): void {
|
|
117
|
+
const k = W / 1080
|
|
118
|
+
const size = Math.round(100 * k)
|
|
119
|
+
face(g, look, size)
|
|
120
|
+
const lines = text.split('\\n').flatMap((part) => wrap(g, part, W - 100 * k))
|
|
121
|
+
const lead = Math.round(size * 1.14)
|
|
122
|
+
const top = y * H - (lines.length * lead) / 2
|
|
123
|
+
const band = g.createLinearGradient(0, top - 140 * k, 0, top + lines.length * lead + 140 * k)
|
|
124
|
+
band.addColorStop(0, alpha(look.edge, 0))
|
|
125
|
+
band.addColorStop(0.5, alpha(look.edge, 0.42))
|
|
126
|
+
band.addColorStop(1, alpha(look.edge, 0))
|
|
127
|
+
g.fillStyle = band
|
|
128
|
+
g.fillRect(0, top - 140 * k, W, lines.length * lead + 280 * k)
|
|
129
|
+
lines.forEach((line, i) => starred(g, look, line, W, top + i * lead, size, k))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** a progress step: its name centred on `y` (a share of the height), a row of notches under it, `i` of `n` filled in
|
|
133
|
+
* the accent, the newest lit; measured at 1080 px wide */
|
|
134
|
+
function drawProgress(g: SKRSContext2D, look: TrailerLook, name: string, i: number, n: number, y: number, W: number, H: number): void {
|
|
135
|
+
const k = W / 1080
|
|
136
|
+
const size = Math.round(84 * k)
|
|
137
|
+
const lead = Math.round(size * 1.14)
|
|
138
|
+
const [width, gap, notch, under] = [700 * k, 14 * k, 28 * k, 24 * k]
|
|
139
|
+
const top = y * H - (lead + under + notch) / 2
|
|
140
|
+
const band = g.createLinearGradient(0, top - 110 * k, 0, top + lead + under + notch + 110 * k)
|
|
141
|
+
band.addColorStop(0, alpha(look.edge, 0))
|
|
142
|
+
band.addColorStop(0.5, alpha(look.edge, 0.36))
|
|
143
|
+
band.addColorStop(1, alpha(look.edge, 0))
|
|
144
|
+
g.fillStyle = band
|
|
145
|
+
g.fillRect(0, top - 110 * k, W, lead + under + notch + 220 * k)
|
|
146
|
+
starred(g, look, name, W, top, size, k)
|
|
147
|
+
const nw = (width - gap * (n - 1)) / n
|
|
148
|
+
const bar = top + lead + under
|
|
149
|
+
for (let j = 0; j < n; j++) {
|
|
150
|
+
const nx = (W - width) / 2 + j * (nw + gap)
|
|
151
|
+
g.save()
|
|
152
|
+
if (j === i - 1) {
|
|
153
|
+
g.shadowColor = alpha(look.accent, 0.9)
|
|
154
|
+
g.shadowBlur = 22 * k
|
|
155
|
+
}
|
|
156
|
+
g.beginPath()
|
|
157
|
+
g.roundRect(nx, bar, nw, notch, notch / 2)
|
|
158
|
+
g.fillStyle = j < i ? look.accent : alpha(look.light, 0.32)
|
|
159
|
+
g.fill()
|
|
160
|
+
g.restore()
|
|
161
|
+
g.lineWidth = 5 * k
|
|
162
|
+
g.strokeStyle = look.edge
|
|
163
|
+
g.beginPath()
|
|
164
|
+
g.roundRect(nx, bar, nw, notch, notch / 2)
|
|
165
|
+
g.stroke()
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
75
169
|
/** one title over a transparent frame. A title's `\n` breaks its line; a logo's second line is its subtitle */
|
|
76
|
-
export function drawTitle(text: string, style: TitleStyle, [W, H]: readonly [number, number], place: TitlePlace, look: TrailerLook, logo: Image | null): Buffer {
|
|
170
|
+
export function drawTitle(text: string, style: TitleStyle, [W, H]: readonly [number, number], place: TitlePlace, look: TrailerLook, logo: Image | null, step?: Title['step']): Buffer {
|
|
77
171
|
const top = place === 'top'
|
|
78
172
|
const c = createCanvas(W, H)
|
|
79
173
|
const g = c.getContext('2d')
|
|
@@ -81,7 +175,11 @@ export function drawTitle(text: string, style: TitleStyle, [W, H]: readonly [num
|
|
|
81
175
|
g.textAlign = 'center'
|
|
82
176
|
g.textBaseline = 'middle'
|
|
83
177
|
const [first = '', ...more] = text.split('\\n')
|
|
84
|
-
if (style === '
|
|
178
|
+
if (style === 'short' || style === 'progress') {
|
|
179
|
+
const sl = shortLook(look)
|
|
180
|
+
if (style === 'short') drawShort(g, sl, text, SHORT_Y[place], W, H)
|
|
181
|
+
else drawProgress(g, sl, text, step?.i ?? 1, step?.n ?? 1, SHORT_Y[place], W, H)
|
|
182
|
+
} else if (style === 'logo') {
|
|
85
183
|
// the logo image over the word, the word in the accent over the edge and its soft drop, the subtitle spaced under it
|
|
86
184
|
const glow = g.createRadialGradient(W / 2, H * 0.48, 0, W / 2, H * 0.48, Math.max(W, H) * 0.45)
|
|
87
185
|
glow.addColorStop(0, alpha(look.edge, 0.45))
|
|
@@ -241,21 +339,25 @@ const key = (parts: unknown): string => new Bun.CryptoHasher('sha1').update(JSON
|
|
|
241
339
|
|
|
242
340
|
/** what the look reads from disk, as it stands: a new font or logo file draws every title again */
|
|
243
341
|
export const lookFiles = (look: TrailerLook, root: string): unknown[] =>
|
|
244
|
-
[look.font.file, look.logo].map((f) => {
|
|
342
|
+
[look.font.file, look.logo, look.short.font.file].map((f) => {
|
|
245
343
|
if (!f) return null
|
|
246
344
|
const abs = join(root, f)
|
|
247
345
|
return existsSync(abs) ? [f, statSync(abs).size, statSync(abs).mtimeMs] : [f, 'missing']
|
|
248
346
|
})
|
|
249
347
|
|
|
250
|
-
/**
|
|
251
|
-
export
|
|
252
|
-
|
|
348
|
+
/** a title's PNG file, named by what it draws */
|
|
349
|
+
export const titleFile = (dir: string, root: string, text: string, style: TitleStyle, size: readonly [number, number], place: TitlePlace, look: TrailerLook, step?: Title['step']): string =>
|
|
350
|
+
join(dir, `title-${key([text, style, size, place, look, lookFiles(look, root), ...(step ? [step.i, step.n] : [])])}.png`)
|
|
351
|
+
|
|
352
|
+
/** the PNG of a title, drawn once per text, style, frame, place, look and step; its box on the frame in `<file>.json` beside it */
|
|
353
|
+
export async function titlePng(dir: string, root: string, text: string, style: TitleStyle, size: readonly [number, number], place: TitlePlace, look: TrailerLook, step?: Title['step']): Promise<TitleImage> {
|
|
354
|
+
const file = titleFile(dir, root, text, style, size, place, look, step)
|
|
253
355
|
const where = `${file}.json`
|
|
254
356
|
if (!existsSync(file) || !existsSync(where)) {
|
|
255
357
|
mkdirSync(dir, { recursive: true })
|
|
256
358
|
registerFace(look, root)
|
|
257
359
|
const logo = style === 'logo' && look.logo && existsSync(join(root, look.logo)) ? await loadImage(join(root, look.logo)) : null
|
|
258
|
-
const { data, info } = await sharp(drawTitle(text, style, size, place, look, logo)).trim({ threshold: 0 }).png().toBuffer({ resolveWithObject: true })
|
|
360
|
+
const { data, info } = await sharp(drawTitle(text, style, size, place, look, logo, step)).trim({ threshold: 0 }).png().toBuffer({ resolveWithObject: true })
|
|
259
361
|
writeFileSync(file, data)
|
|
260
362
|
writeFileSync(where, JSON.stringify({ x: -(info.trimOffsetLeft ?? 0), y: -(info.trimOffsetTop ?? 0), w: info.width, h: info.height }))
|
|
261
363
|
}
|
|
@@ -263,9 +365,12 @@ export async function titlePng(dir: string, root: string, text: string, style: T
|
|
|
263
365
|
return { file, x, y, w, h }
|
|
264
366
|
}
|
|
265
367
|
|
|
368
|
+
/** the card's ground's PNG file for a frame and a look */
|
|
369
|
+
export const cardFile = (dir: string, size: readonly [number, number], look: TrailerLook): string => join(dir, `card-${key(['card', size, look.ground])}.png`)
|
|
370
|
+
|
|
266
371
|
/** the PNG of the card's ground for a frame and a look */
|
|
267
372
|
export function cardPng(dir: string, size: readonly [number, number], look: TrailerLook): string {
|
|
268
|
-
const file =
|
|
373
|
+
const file = cardFile(dir, size, look)
|
|
269
374
|
if (!existsSync(file)) {
|
|
270
375
|
mkdirSync(dir, { recursive: true })
|
|
271
376
|
writeFileSync(file, drawCard(size, look))
|
package/src/trailer/vocab.ts
CHANGED
|
@@ -18,22 +18,41 @@ export function projectCli(root: string): string[] | null {
|
|
|
18
18
|
export function filmRows(json: unknown): FilmVerb[] {
|
|
19
19
|
const rows = Array.isArray(json) ? json : Array.isArray((json as { commands?: unknown })?.commands) ? (json as { commands: unknown[] }).commands : []
|
|
20
20
|
return rows.flatMap((r) => {
|
|
21
|
-
const o = r as { path?: unknown; film?: unknown; description?: unknown }
|
|
21
|
+
const o = r as { path?: unknown; film?: unknown; version?: unknown; length?: unknown; description?: unknown }
|
|
22
22
|
if (o.film !== 'still' && o.film !== 'clip') return []
|
|
23
23
|
const path = Array.isArray(o.path) ? o.path.map(String) : typeof o.path === 'string' ? o.path.trim().split(/\s+/) : []
|
|
24
24
|
if (path.length === 0) return []
|
|
25
|
-
return [
|
|
25
|
+
return [
|
|
26
|
+
{
|
|
27
|
+
path,
|
|
28
|
+
film: o.film,
|
|
29
|
+
...(typeof o.version === 'number' || typeof o.version === 'string' ? { version: o.version } : {}),
|
|
30
|
+
...(o.length === 'own' ? { length: 'own' as const } : {}),
|
|
31
|
+
...(typeof o.description === 'string' ? { description: o.description } : {}),
|
|
32
|
+
},
|
|
33
|
+
]
|
|
26
34
|
})
|
|
27
35
|
}
|
|
28
36
|
|
|
29
|
-
|
|
37
|
+
const asked = new Map<string, FilmVerb[]>()
|
|
38
|
+
|
|
39
|
+
/** the rows asked again on the next read: a watch builds as a fresh process would */
|
|
40
|
+
export const forgetVocab = (): void => asked.clear()
|
|
41
|
+
|
|
42
|
+
/** the project CLI's film rows; null when the repo has no project CLI. A CLI that does not answer is an error. Asked
|
|
43
|
+
* once per process: the prune and a watch read many cuts */
|
|
30
44
|
export function readVocab(root: string, cli: readonly string[] | null): FilmVerb[] | null {
|
|
31
45
|
if (cli === null) return null
|
|
46
|
+
const k = `${root}\0${cli.join(' ')}`
|
|
47
|
+
const had = asked.get(k)
|
|
48
|
+
if (had) return had
|
|
32
49
|
const r = Bun.spawnSync([...cli, 'commands', '--json'], { cwd: root, stdout: 'pipe', stderr: 'pipe' })
|
|
33
50
|
const text = r.stdout.toString()
|
|
34
51
|
try {
|
|
35
52
|
if (r.exitCode !== 0) throw new Error(`exit ${r.exitCode}`)
|
|
36
|
-
|
|
53
|
+
const rows = filmRows(JSON.parse(text))
|
|
54
|
+
asked.set(k, rows)
|
|
55
|
+
return rows
|
|
37
56
|
} catch (e) {
|
|
38
57
|
throw new Error(`the project CLI (${cli.join(' ')}) did not answer \`commands --json\` with its rows: ${(e as Error).message}${r.stderr.length ? `\n${r.stderr.toString().trim().split('\n').slice(-4).join('\n')}` : ''}`)
|
|
39
58
|
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// a film row's declared words (wiki build/trailer.md, A row's words): the row says which words it takes and their
|
|
2
|
+
// types, the core reads every word of a film line at its own line against that, refuses an unknown one with the row's
|
|
3
|
+
// list, and hands the row its values made plain (argv stays the transport). Pure: times arrive as moments the grammar
|
|
4
|
+
// resolves once the shots have landed
|
|
5
|
+
|
|
6
|
+
export type WordType = 'number' | 'int' | 'enum' | 'point' | 'time' | 'cues' | 'file' | 'text' | 'word' | 'flag'
|
|
7
|
+
export const WORD_TYPES: readonly WordType[] = ['number', 'int', 'enum', 'point', 'time', 'cues', 'file', 'text', 'word', 'flag']
|
|
8
|
+
|
|
9
|
+
/** one word a row takes: its type and bounds; `arg: n` makes it the n-th positional */
|
|
10
|
+
export interface WordSpec {
|
|
11
|
+
type: WordType
|
|
12
|
+
arg?: number
|
|
13
|
+
optional?: boolean
|
|
14
|
+
min?: number
|
|
15
|
+
max?: number
|
|
16
|
+
values?: string[]
|
|
17
|
+
/** a point's numbers, 2 by default */
|
|
18
|
+
count?: number
|
|
19
|
+
/** a word's shape, a regular expression */
|
|
20
|
+
pattern?: string
|
|
21
|
+
/** what it means, for the error that lists the row's words */
|
|
22
|
+
doc?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type Words = Record<string, WordSpec>
|
|
26
|
+
|
|
27
|
+
/** a word of a film line, with the line it sits on (an option line under the film line keeps its own) */
|
|
28
|
+
export interface LineWord {
|
|
29
|
+
text: string
|
|
30
|
+
quoted: boolean
|
|
31
|
+
line: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** a time: seconds, or a section or mark of the cut and a time from it */
|
|
35
|
+
export type Moment = number | { mark: string; offset: number }
|
|
36
|
+
|
|
37
|
+
export interface Typed {
|
|
38
|
+
/** the row's path, then its words in the order written, each value plain; a time's slot is filled by `times` */
|
|
39
|
+
argv: string[]
|
|
40
|
+
/** argv slots made of moments: a time (`name` null) or cues (`name@seconds`, comma joined) */
|
|
41
|
+
times: { index: number; parts: { name: string | null; at: Moment; line: number }[] }[]
|
|
42
|
+
/** argv slots that are files, as written: the build finds them and keys their content */
|
|
43
|
+
files: number[]
|
|
44
|
+
errors: { line: number; message: string }[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** the row's words checked as a declaration: every type known, its bounds sane; null when it is one */
|
|
48
|
+
export function wordsProblem(words: unknown): string | null {
|
|
49
|
+
if (typeof words !== 'object' || words === null) return 'words is a record of word → { type }'
|
|
50
|
+
for (const [k, v] of Object.entries(words as Record<string, unknown>)) {
|
|
51
|
+
const w = v as Partial<WordSpec> | null
|
|
52
|
+
if (!/^[a-z][a-z0-9-]*$/.test(k)) return `word ${k}: a word is lowercase letters, digits and -`
|
|
53
|
+
if (!w || !WORD_TYPES.includes(w.type as WordType)) return `word ${k}: type is one of ${WORD_TYPES.join(', ')}`
|
|
54
|
+
if (w.type === 'enum' && (!Array.isArray(w.values) || w.values.length === 0)) return `word ${k}: an enum names its values`
|
|
55
|
+
if (w.pattern !== undefined) {
|
|
56
|
+
try {
|
|
57
|
+
new RegExp(w.pattern)
|
|
58
|
+
} catch {
|
|
59
|
+
return `word ${k}: pattern ${w.pattern} is no regular expression`
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const n4 = (x: number): string => String(+x.toFixed(4))
|
|
67
|
+
|
|
68
|
+
/** one value read by its type: its plain text, a moment, or why it does not fit */
|
|
69
|
+
function valueOf(key: string, spec: WordSpec, w: LineWord, time: (text: string) => Moment | null): { text: string } | { at: Moment } | { error: string } {
|
|
70
|
+
const t = w.text
|
|
71
|
+
const what = `${key} wants ${describe(spec)}, got ${w.quoted ? `"${t}"` : t}`
|
|
72
|
+
switch (spec.type) {
|
|
73
|
+
case 'number':
|
|
74
|
+
case 'int': {
|
|
75
|
+
const v = w.quoted ? Number.NaN : Number(t)
|
|
76
|
+
if (!Number.isFinite(v) || (spec.type === 'int' && !Number.isInteger(v)) || (spec.min !== undefined && v < spec.min) || (spec.max !== undefined && v > spec.max)) return { error: what }
|
|
77
|
+
return { text: t }
|
|
78
|
+
}
|
|
79
|
+
case 'enum':
|
|
80
|
+
return !w.quoted && spec.values!.includes(t) ? { text: t } : { error: what }
|
|
81
|
+
case 'point': {
|
|
82
|
+
const parts = w.quoted ? [] : t.split(',')
|
|
83
|
+
const ok = parts.length === (spec.count ?? 2) && parts.every((p) => p !== '' && Number.isFinite(Number(p)))
|
|
84
|
+
return ok ? { text: t } : { error: what }
|
|
85
|
+
}
|
|
86
|
+
case 'time': {
|
|
87
|
+
const at = w.quoted ? null : time(t)
|
|
88
|
+
return at === null ? { error: what } : { at }
|
|
89
|
+
}
|
|
90
|
+
case 'file':
|
|
91
|
+
case 'word':
|
|
92
|
+
if (w.quoted || t === '' || (spec.pattern !== undefined && !new RegExp(spec.pattern).test(t))) return { error: what }
|
|
93
|
+
return { text: t }
|
|
94
|
+
case 'text':
|
|
95
|
+
return t.trim() === '' ? { error: `${key}: the text is empty` } : { text: t }
|
|
96
|
+
default:
|
|
97
|
+
return { error: what }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function describe(s: WordSpec): string {
|
|
102
|
+
switch (s.type) {
|
|
103
|
+
case 'number':
|
|
104
|
+
case 'int':
|
|
105
|
+
return `${s.type === 'int' ? 'a whole number' : 'a number'}${s.min !== undefined || s.max !== undefined ? ` from ${s.min ?? '-∞'} to ${s.max ?? '∞'}` : ''}`
|
|
106
|
+
case 'enum':
|
|
107
|
+
return s.values!.join('|')
|
|
108
|
+
case 'point':
|
|
109
|
+
return `${s.count ?? 2} numbers joined by commas`
|
|
110
|
+
case 'time':
|
|
111
|
+
return 'a time (1.5s, 400ms, 0:04, 2beats, a mark, mark+1beat)'
|
|
112
|
+
case 'cues':
|
|
113
|
+
return 'cues, name@<time> each'
|
|
114
|
+
case 'file':
|
|
115
|
+
return 'a file'
|
|
116
|
+
case 'word':
|
|
117
|
+
return `a word${s.pattern ? ` like ${s.pattern}` : ''}`
|
|
118
|
+
case 'text':
|
|
119
|
+
return 'a text'
|
|
120
|
+
case 'flag':
|
|
121
|
+
return 'no value'
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** the row's words, for an error: positionals in order, then keys */
|
|
126
|
+
export const wordList = (words: Words): string =>
|
|
127
|
+
[
|
|
128
|
+
...Object.entries(words)
|
|
129
|
+
.filter(([, s]) => s.arg !== undefined)
|
|
130
|
+
.sort((a, b) => a[1].arg! - b[1].arg!)
|
|
131
|
+
.map(([k, s]) => `<${k}>${s.optional ? '?' : ''}`),
|
|
132
|
+
...Object.entries(words)
|
|
133
|
+
.filter(([, s]) => s.arg === undefined)
|
|
134
|
+
.map(([k]) => k),
|
|
135
|
+
].join(' ')
|
|
136
|
+
|
|
137
|
+
interface Entry {
|
|
138
|
+
key: string
|
|
139
|
+
/** a positional's words carry no key on the line */
|
|
140
|
+
positional: boolean
|
|
141
|
+
values: LineWord[]
|
|
142
|
+
line: number
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** the entries of a run of words: a word that names a key opens it (its value words follow), any other word fills the
|
|
146
|
+
* next positional that takes it */
|
|
147
|
+
function entriesOf(path: string, words: Words, ws: LineWord[], time: (text: string) => Moment | null, errors: Typed['errors']): Entry[] {
|
|
148
|
+
const args = Object.entries(words)
|
|
149
|
+
.filter(([, s]) => s.arg !== undefined)
|
|
150
|
+
.sort((a, b) => a[1].arg! - b[1].arg!)
|
|
151
|
+
let next = 0
|
|
152
|
+
const out: Entry[] = []
|
|
153
|
+
for (let i = 0; i < ws.length; i++) {
|
|
154
|
+
const w = ws[i]!
|
|
155
|
+
const spec = !w.quoted && words[w.text] && words[w.text]!.arg === undefined ? words[w.text]! : null
|
|
156
|
+
if (spec) {
|
|
157
|
+
if (spec.type === 'flag') out.push({ key: w.text, positional: false, values: [], line: w.line })
|
|
158
|
+
else if (spec.type === 'cues') {
|
|
159
|
+
const values: LineWord[] = []
|
|
160
|
+
while (i + 1 < ws.length && !ws[i + 1]!.quoted && /^[^@\s]+@\S+$/.test(ws[i + 1]!.text) && !words[ws[i + 1]!.text]) {
|
|
161
|
+
const c = ws[++i]!
|
|
162
|
+
for (const text of c.text.split(',')) values.push({ ...c, text })
|
|
163
|
+
}
|
|
164
|
+
if (values.length === 0) errors.push({ line: w.line, message: `film ${path} ${w.text} wants cues, name@<time> each` })
|
|
165
|
+
out.push({ key: w.text, positional: false, values, line: w.line })
|
|
166
|
+
} else {
|
|
167
|
+
const v = ws[i + 1]
|
|
168
|
+
if (!v || v.line !== w.line) errors.push({ line: w.line, message: `film ${path} ${w.text} wants ${describe(spec)}` })
|
|
169
|
+
else {
|
|
170
|
+
out.push({ key: w.text, positional: false, values: [v], line: w.line })
|
|
171
|
+
i++
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
// the next positional that takes this word; an optional one that does not is passed over
|
|
177
|
+
let placed = false
|
|
178
|
+
while (next < args.length) {
|
|
179
|
+
const [k, s] = args[next++]!
|
|
180
|
+
if (!('error' in valueOf(k, s, w, time))) {
|
|
181
|
+
out.push({ key: k, positional: true, values: [w], line: w.line })
|
|
182
|
+
placed = true
|
|
183
|
+
break
|
|
184
|
+
}
|
|
185
|
+
if (!s.optional) {
|
|
186
|
+
errors.push({ line: w.line, message: `film ${path}: ${w.quoted ? `"${w.text}"` : w.text} fits no word here, <${k}> wants ${describe(s)} (${path} takes ${wordList(words)})` })
|
|
187
|
+
placed = true
|
|
188
|
+
break
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (!placed) {
|
|
192
|
+
errors.push({ line: w.line, message: `film ${path}: unknown word ${w.quoted ? `"${w.text}"` : w.text} (${path} takes ${wordList(words)})` })
|
|
193
|
+
// its value goes with it: one error a word
|
|
194
|
+
const v = ws[i + 1]
|
|
195
|
+
if (v && v.line === w.line && !(!v.quoted && words[v.text])) i++
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return out
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* a film line of a row that declares its words: `ws` its words after the row's path, `defaults` its section's (a key
|
|
203
|
+
* the line gives itself wins). The plain argv, the slots that wait on a moment, the file slots, every error at its line
|
|
204
|
+
*/
|
|
205
|
+
export function typedFilm(path: string[], words: Words, ws: LineWord[], defaults: LineWord[], time: (text: string) => Moment | null): Typed {
|
|
206
|
+
const errors: Typed['errors'] = []
|
|
207
|
+
const own = entriesOf(path.join(' '), words, ws, time, errors)
|
|
208
|
+
const given = new Set(own.map((e) => e.key))
|
|
209
|
+
const fromDefaults = defaults.length > 0 ? entriesOf(path.join(' '), words, defaults, time, errors).filter((e) => !given.has(e.key)) : []
|
|
210
|
+
const all = [...fromDefaults, ...own]
|
|
211
|
+
const seen = new Set<string>()
|
|
212
|
+
const argv = [...path]
|
|
213
|
+
const times: Typed['times'] = []
|
|
214
|
+
const files: number[] = []
|
|
215
|
+
for (const e of all) {
|
|
216
|
+
if (seen.has(e.key)) {
|
|
217
|
+
errors.push({ line: e.line, message: `film ${path.join(' ')}: ${e.key} given twice` })
|
|
218
|
+
continue
|
|
219
|
+
}
|
|
220
|
+
seen.add(e.key)
|
|
221
|
+
const spec = words[e.key]!
|
|
222
|
+
if (!e.positional) argv.push(e.key)
|
|
223
|
+
if (spec.type === 'flag') continue
|
|
224
|
+
if (spec.type === 'cues') {
|
|
225
|
+
const parts: Typed['times'][number]['parts'] = []
|
|
226
|
+
for (const v of e.values) {
|
|
227
|
+
const at = v.text.indexOf('@')
|
|
228
|
+
const name = v.text.slice(0, at)
|
|
229
|
+
const m = time(v.text.slice(at + 1))
|
|
230
|
+
if (m === null) errors.push({ line: v.line, message: `film ${path.join(' ')} ${e.key}: ${v.text}, the time after @ is no time (1.5s, 2beats, a mark, mark+1beat)` })
|
|
231
|
+
else parts.push({ name, at: m, line: v.line })
|
|
232
|
+
}
|
|
233
|
+
times.push({ index: argv.length, parts })
|
|
234
|
+
argv.push('')
|
|
235
|
+
continue
|
|
236
|
+
}
|
|
237
|
+
const got = valueOf(e.key, spec, e.values[0]!, time)
|
|
238
|
+
if ('error' in got) {
|
|
239
|
+
errors.push({ line: e.values[0]!.line, message: `film ${path.join(' ')}: ${got.error}` })
|
|
240
|
+
argv.push(e.values[0]!.text)
|
|
241
|
+
} else if ('at' in got) {
|
|
242
|
+
times.push({ index: argv.length, parts: [{ name: null, at: got.at, line: e.values[0]!.line }] })
|
|
243
|
+
argv.push('')
|
|
244
|
+
} else {
|
|
245
|
+
if (spec.type === 'file') files.push(argv.length)
|
|
246
|
+
argv.push(got.text)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
for (const [k, s] of Object.entries(words)) if (s.arg !== undefined && !s.optional && !seen.has(k)) errors.push({ line: ws[0]?.line ?? defaults[0]?.line ?? 0, message: `film ${path.join(' ')}: <${k}> is missing (${path.join(' ')} takes ${wordList(words)})` })
|
|
250
|
+
return { argv, times, files, errors }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** a slot's text once its moments are seconds: a time, or cues joined by commas */
|
|
254
|
+
export const slotText = (parts: readonly { name: string | null; seconds: number }[]): string => parts.map((p) => (p.name === null ? n4(p.seconds) : `${p.name}@${n4(p.seconds)}`)).join(',')
|