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.
Files changed (107) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/package.json +1 -1
  3. package/src/agent-time/mask.ts +43 -0
  4. package/src/agent-time/render.ts +187 -0
  5. package/src/agent-time/report.ts +335 -0
  6. package/src/agent-time/shape.ts +271 -0
  7. package/src/agent-time/signals.ts +84 -0
  8. package/src/agent-time/store.ts +379 -0
  9. package/src/agent-time/timeline.ts +218 -0
  10. package/src/agent-time/turns.ts +135 -0
  11. package/src/cli/cmd_Commands.ts +5 -0
  12. package/src/cli/cmd_Review.ts +948 -0
  13. package/src/cli/cmd_Time.ts +112 -0
  14. package/src/cli/cmd_Trailer.ts +259 -6
  15. package/src/cli/kit/verb.ts +14 -4
  16. package/src/cli.ts +6 -4
  17. package/src/collections/collection-types.ts +6 -1
  18. package/src/collections/providers.ts +3 -0
  19. package/src/collections/review.ts +70 -0
  20. package/src/core/layout.ts +2 -0
  21. package/src/reflect/prompt-corpus.ts +15 -119
  22. package/src/reflect/transcript.ts +254 -0
  23. package/src/review/anim.ts +228 -0
  24. package/src/review/auto.ts +141 -0
  25. package/src/review/format.ts +150 -0
  26. package/src/review/http.ts +23 -0
  27. package/src/review/marks.ts +43 -0
  28. package/src/review/media.ts +45 -0
  29. package/src/review/model.ts +854 -0
  30. package/src/review/project.ts +110 -0
  31. package/src/review/role.ts +48 -0
  32. package/src/review/routes.ts +228 -0
  33. package/src/review/store.ts +1002 -0
  34. package/src/review/threads.ts +237 -0
  35. package/src/review/trailer-desk.ts +385 -0
  36. package/src/review/trailer-model.ts +183 -0
  37. package/src/review/trailer-routes.ts +136 -0
  38. package/src/review/unit.ts +165 -0
  39. package/src/review/watchers.ts +54 -0
  40. package/src/serve/embed.ts +35 -0
  41. package/src/serve/hub-app/activity-view.tsx +1 -1
  42. package/src/serve/hub-app/activity.css +0 -4
  43. package/src/serve/hub-app/dock-view.tsx +5 -4
  44. package/src/serve/hub-app/hash-route.ts +28 -1
  45. package/src/serve/hub-app/header-bar.tsx +3 -1
  46. package/src/serve/hub-app/home-st.ts +10 -2
  47. package/src/serve/hub-app/home-strip.tsx +3 -2
  48. package/src/serve/hub-app/home.html +1 -0
  49. package/src/serve/hub-app/hub-app.tsx +80 -11
  50. package/src/serve/hub-app/hub.css +45 -13
  51. package/src/serve/hub-app/icons.tsx +9 -0
  52. package/src/serve/hub-app/index.html +1 -0
  53. package/src/serve/hub-app/insights-view.tsx +4 -3
  54. package/src/serve/hub-app/keys.ts +14 -10
  55. package/src/serve/hub-app/layout-st.ts +63 -2
  56. package/src/serve/hub-app/planned-view.tsx +3 -2
  57. package/src/serve/hub-app/planned.css +0 -1
  58. package/src/serve/hub-app/project-list.css +22 -4
  59. package/src/serve/hub-app/project-list.tsx +41 -8
  60. package/src/serve/hub-app/projects-svc.ts +4 -0
  61. package/src/serve/hub-app/repo-sections.ts +15 -3
  62. package/src/serve/hub-app/repo-st.ts +45 -3
  63. package/src/serve/hub-app/review/clip-st.ts +208 -0
  64. package/src/serve/hub-app/review/kind-view.tsx +171 -0
  65. package/src/serve/hub-app/review/kit.tsx +113 -0
  66. package/src/serve/hub-app/review/review-answer.tsx +239 -0
  67. package/src/serve/hub-app/review/review-board.tsx +46 -0
  68. package/src/serve/hub-app/review/review-list.tsx +156 -0
  69. package/src/serve/hub-app/review/review-page.tsx +101 -0
  70. package/src/serve/hub-app/review/review-st.ts +779 -0
  71. package/src/serve/hub-app/review/review-views.tsx +503 -0
  72. package/src/serve/hub-app/review/trailer-page.tsx +416 -0
  73. package/src/serve/hub-app/review/trailer-st.ts +602 -0
  74. package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
  75. package/src/serve/hub-app/review.css +329 -0
  76. package/src/serve/hub-app/theme-st.ts +56 -0
  77. package/src/serve/hub-app/tree-build.ts +4 -0
  78. package/src/serve/hub-app/tree-row.tsx +1 -1
  79. package/src/serve/hub-app/tree-svc.ts +4 -0
  80. package/src/serve/hub-runtime.ts +5 -3
  81. package/src/serve/same-origin.ts +5 -1
  82. package/src/serve/serve-hub.ts +8 -0
  83. package/src/shipkit-files.ts +9 -5
  84. package/src/trailer/adapter.ts +58 -3
  85. package/src/trailer/build.ts +300 -41
  86. package/src/trailer/ffmpeg.ts +22 -0
  87. package/src/trailer/footage.ts +109 -40
  88. package/src/trailer/grammar.ts +444 -69
  89. package/src/trailer/graph.ts +199 -14
  90. package/src/trailer/holes.ts +72 -0
  91. package/src/trailer/load-unit.ts +20 -3
  92. package/src/trailer/look.ts +17 -3
  93. package/src/trailer/passage.ts +129 -0
  94. package/src/trailer/plan.ts +80 -21
  95. package/src/trailer/prune.ts +193 -0
  96. package/src/trailer/score/compose.ts +240 -0
  97. package/src/trailer/score/dsp.ts +272 -0
  98. package/src/trailer/score/palette.ts +188 -0
  99. package/src/trailer/score/pattern.ts +91 -0
  100. package/src/trailer/score/score.ts +214 -0
  101. package/src/trailer/score/sound.ts +112 -0
  102. package/src/trailer/score/synth.ts +361 -0
  103. package/src/trailer/states.ts +76 -0
  104. package/src/trailer/stitch.ts +92 -9
  105. package/src/trailer/titles.ts +123 -18
  106. package/src/trailer/vocab.ts +23 -4
  107. package/src/trailer/words.ts +254 -0
@@ -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). Five styles: logo, big, caption, corner, line. Cached on what they draw, so a title edit draws one PNG
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
- export type TitlePlace = 'top' | 'bottom'
14
- export const placeOf = (ti: Title): TitlePlace => ti.place ?? 'bottom'
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 file registered once under its family */
33
+ /** the look's font files registered once under their families */
27
34
  export function registerFace(look: TrailerLook, root: string): void {
28
- if (!look.font.file || registered.has(look.font.file)) return
29
- const abs = join(root, look.font.file)
30
- if (!existsSync(abs)) throw new Error(`the brand's trailer font ${look.font.file} is not in the repo`)
31
- GlobalFonts.registerFromPath(abs, look.font.family)
32
- registered.add(look.font.file)
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 === 'logo') {
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
- /** the PNG of a title, drawn once per text, style, frame, place and look; its box on the frame in `<file>.json` beside it */
251
- export async function titlePng(dir: string, root: string, text: string, style: TitleStyle, size: readonly [number, number], place: TitlePlace, look: TrailerLook): Promise<TitleImage> {
252
- const file = join(dir, `title-${key([text, style, size, place, look, lookFiles(look, root)])}.png`)
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 = join(dir, `card-${key(['card', size, look.ground])}.png`)
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))
@@ -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 [{ path, film: o.film, ...(typeof o.description === 'string' ? { description: o.description } : {}) }]
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
- /** the project CLI's film rows; null when the repo has no project CLI. A CLI that does not answer is an error */
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
- return filmRows(JSON.parse(text))
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(',')