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.
Files changed (80) hide show
  1. package/CHANGELOG.md +11 -2
  2. package/package.json +1 -1
  3. package/src/assets/kits/identity-kit.ts +5 -2
  4. package/src/cli/cmd_Commands.ts +4 -0
  5. package/src/cli/cmd_Review.ts +36 -10
  6. package/src/cli/cmd_Sound.ts +209 -0
  7. package/src/cli/cmd_Trailer.ts +7 -2
  8. package/src/cli/kit/verb.ts +11 -3
  9. package/src/cli.ts +2 -0
  10. package/src/core/app-icon.ts +10 -6
  11. package/src/repo-config.ts +3 -0
  12. package/src/review/format.ts +11 -3
  13. package/src/review/model.ts +47 -5
  14. package/src/review/project.ts +20 -4
  15. package/src/review/routes.ts +12 -1
  16. package/src/review/store.ts +15 -6
  17. package/src/review/unit.ts +19 -1
  18. package/src/serve/hub-app/admin-svc.ts +1 -1
  19. package/src/serve/hub-app/admin-view.tsx +1 -1
  20. package/src/serve/hub-app/hash-route.ts +12 -4
  21. package/src/serve/hub-app/home-st.ts +7 -1
  22. package/src/serve/hub-app/hub.css +38 -1
  23. package/src/serve/hub-app/icons.tsx +6 -0
  24. package/src/serve/hub-app/layout-st.ts +2 -2
  25. package/src/serve/hub-app/md.ts +32 -0
  26. package/src/serve/hub-app/project-list.css +9 -7
  27. package/src/serve/hub-app/project-list.tsx +5 -2
  28. package/src/serve/hub-app/projects-svc.ts +3 -2
  29. package/src/serve/hub-app/repo-st.ts +22 -7
  30. package/src/serve/hub-app/review/clip-st.ts +57 -7
  31. package/src/serve/hub-app/review/kind-view.tsx +78 -9
  32. package/src/serve/hub-app/review/review-answer.tsx +83 -53
  33. package/src/serve/hub-app/review/review-page.tsx +9 -14
  34. package/src/serve/hub-app/review/review-st.ts +141 -18
  35. package/src/serve/hub-app/review/review-views.tsx +154 -9
  36. package/src/serve/hub-app/review.css +70 -10
  37. package/src/serve/hub-app/wiki-st.ts +190 -0
  38. package/src/serve/hub-app/wikis-view.tsx +134 -5
  39. package/src/serve/hub-runtime.ts +4 -4
  40. package/src/serve/project-gutter-route.ts +25 -1
  41. package/src/serve/project-gutter.ts +1 -1
  42. package/src/serve/serve-hub.ts +7 -0
  43. package/src/shot/client.ts +17 -5
  44. package/src/shot/shooter.ts +19 -3
  45. package/src/shot/shot-ask.ts +9 -1
  46. package/src/shot/stepped-clip.ts +53 -0
  47. package/src/trailer/adapter.ts +33 -4
  48. package/src/trailer/build.ts +170 -29
  49. package/src/trailer/fonts.ts +55 -0
  50. package/src/trailer/footage.ts +4 -4
  51. package/src/trailer/grammar.ts +429 -67
  52. package/src/trailer/graph.ts +130 -56
  53. package/src/trailer/look.ts +3 -0
  54. package/src/trailer/motion.ts +207 -0
  55. package/src/trailer/plan.ts +2 -2
  56. package/src/trailer/prune.ts +13 -2
  57. package/src/trailer/score/compose.ts +18 -2
  58. package/src/trailer/score/gm-render.ts +43 -0
  59. package/src/trailer/score/gm.ts +90 -0
  60. package/src/trailer/score/midi.ts +143 -0
  61. package/src/trailer/score/pattern.ts +13 -6
  62. package/src/trailer/score/score.ts +28 -14
  63. package/src/trailer/score/sound.ts +12 -1
  64. package/src/trailer/score/synth.ts +24 -2
  65. package/src/trailer/sound/audio.ts +148 -0
  66. package/src/trailer/sound/brand.ts +42 -0
  67. package/src/trailer/sound/comfy.ts +38 -0
  68. package/src/trailer/sound/eleven.ts +72 -0
  69. package/src/trailer/sound/fluid.ts +71 -0
  70. package/src/trailer/sound/gen.ts +139 -0
  71. package/src/trailer/sound/index.ts +60 -0
  72. package/src/trailer/sound/presets.ts +69 -0
  73. package/src/trailer/sound/run.ts +189 -0
  74. package/src/trailer/titles.ts +451 -235
  75. package/src/trailer/url.ts +18 -3
  76. package/src/trailer/vocab.ts +12 -1
  77. package/src/trailer/words.ts +29 -8
  78. package/src/wiki/graph-mount.ts +3 -0
  79. package/src/wiki/graph-page.ts +45 -9
  80. package/src/wiki/graph-pages.ts +128 -0
@@ -1,9 +1,10 @@
1
1
  // the stitch's ffmpeg arguments, as data: a piece of the cut (its shots cut to their frames, joined by their cuts, the
2
2
  // titles laid over) and the mix of the sound. Pure: stitch.ts runs them, keyed on these very arguments
3
3
  import type { CutKind, Shot, Trailer } from './grammar.ts'
4
- import { GROW_S, musicCues, starts, total } from './grammar.ts'
4
+ import { CUTS, GROW_S, musicCues, starts, total } from './grammar.ts'
5
5
  import type { TrailerLook } from './look.ts'
6
6
  import { frameAt, frameSpans, type Piece } from './plan.ts'
7
+ import { inFrames, KNOCK_S, outFrames, type TitleMotion, titleMotion } from './motion.ts'
7
8
  import type { TitleImage } from './titles.ts'
8
9
 
9
10
  /** a wipe's edge, as xfade draws it: along x or y, starting from the far side (`from`) or the near one */
@@ -14,24 +15,6 @@ const SEAM_EDGE: Partial<Record<CutKind, { axis: 'x' | 'y'; from: boolean }>> =
14
15
  'wipe-down': { axis: 'y', from: false },
15
16
  }
16
17
 
17
- const XFADE: Record<Exclude<CutKind, 'hard'>, string> = {
18
- fade: 'fade',
19
- 'dip-black': 'fadeblack',
20
- 'dip-white': 'fadewhite',
21
- 'slide-left': 'slideleft',
22
- 'slide-right': 'slideright',
23
- 'slide-up': 'slideup',
24
- 'slide-down': 'slidedown',
25
- 'whip-left': 'smoothleft',
26
- 'whip-right': 'smoothright',
27
- 'wipe-left': 'wipeleft',
28
- 'wipe-right': 'wiperight',
29
- 'wipe-up': 'wipeup',
30
- 'wipe-down': 'wipedown',
31
- circle: 'circleopen',
32
- pixel: 'pixelize',
33
- }
34
-
35
18
  /** a grid's or a split's cell: a shot's or a footage block's take, from its `from`, cropped by its `frame` */
36
19
  export interface GridCell {
37
20
  footage: string
@@ -198,9 +181,107 @@ function flashF(s: Shot): string {
198
181
  .join('')
199
182
  }
200
183
 
201
- /** a knocked title flies out of the frame in this long, turning this many radians */
202
- const KNOCK_S = 0.4
203
- const KNOCK_TURN = 1.4
184
+ /** a frame's own pseudo random number, 0 to 1: the same cut draws the same frames */
185
+ const rnd = (...xs: number[]): number => {
186
+ const v = Math.sin(xs.reduce((n, x, j) => n + x * [12.9898, 78.233, 37.719, 4.581][j % 4]!, 0)) * 43758.5453
187
+ return v - Math.floor(v)
188
+ }
189
+
190
+ /** the cuts the core draws, frame by frame over the frames both shots hold: `a` the shot leaving, `b` the one coming,
191
+ * `n` frames, the result labelled `out`. A whip pans both shots past at speed, blurred along the way they fly; a
192
+ * glitch tears the picture (colours split, bands torn sideways, grain) round a flicker from one shot to the next; a
193
+ * leak burns two soft lights of the look across a dissolve */
194
+ export function drawnCut(kind: CutKind, a: string, b: string, out: string, n: number, i: number, [W, H]: readonly [number, number], fps: number, look: Pick<TrailerLook, 'accent' | 'light'>): string[] {
195
+ const d = n / fps
196
+ if (kind === 'leak') {
197
+ const big = `s=${2 * W}x${H}:r=${fps}:d=${n3(d + 0.2)}:type=radial:speed=0`
198
+ const [y1, y2, r1, r2] = [Math.round(H * 0.4), Math.round(H * 0.85), Math.round(W * 0.45), Math.round(W * 0.3)]
199
+ return [
200
+ `[${a}][${b}]xfade=transition=fade:duration=${n3(d)}:offset=0,format=gbrp[lx${i}]`,
201
+ `gradients=${big}:n=3:c0=0xffdcaa:c1=${ff(look.accent)}:c2=0x000000:x0=${W}:y0=${y1}:x1=${W + r1}:y1=${y1},format=gbrp,crop=w=${W}:h=${H}:x='${W}*(1.1-t/${n3(d)})':y=0[la${i}]`,
202
+ `gradients=${big}:n=2:c0=0xff5a46:c1=0x000000:x0=${W}:y0=${y2}:x1=${W + r2}:y1=${y2},format=gbrp,crop=w=${W}:h=${H}:x='${W}*(0.25+0.6*t/${n3(d)})':y=0[lb${i}]`,
203
+ `[la${i}][lb${i}]blend=all_mode=screen,colorchannelmixer=rr=0.8:gg=0.8:bb=0.8,fade=t=in:st=0:d=${n3(d / 2)},fade=t=out:st=${n3(d / 2)}:d=${n3(d / 2)}[ll${i}]`,
204
+ `[lx${i}][ll${i}]blend=all_mode=screen:shortest=1,format=yuv420p[${out}]`,
205
+ ]
206
+ }
207
+ const ks = Array.from({ length: n }, (_, k) => k)
208
+ const g = [
209
+ `[${a}]split=${n}${ks.map((k) => `[ca${i}_${k}x]`).join('')}`,
210
+ `[${b}]split=${n}${ks.map((k) => `[cb${i}_${k}x]`).join('')}`,
211
+ ...ks.flatMap((k) => [`[ca${i}_${k}x]trim=start_frame=${k}:end_frame=${k + 1},setpts=PTS-STARTPTS[ca${i}_${k}]`, `[cb${i}_${k}x]trim=start_frame=${k}:end_frame=${k + 1},setpts=PTS-STARTPTS[cb${i}_${k}]`]),
212
+ ]
213
+ for (const k of ks) {
214
+ const [fa, fb, fo] = [`ca${i}_${k}`, `cb${i}_${k}`, `co${i}_${k}`]
215
+ const p = (k + 0.5) / n
216
+ if (kind.startsWith('whip-')) {
217
+ // eased in and out: the speed peaks mid way, and the blur with it (a box as wide as a frame's travel)
218
+ const u = p < 0.5 ? 4 * p ** 3 : 1 - (-2 * p + 2) ** 3 / 2
219
+ const across = kind === 'whip-left' || kind === 'whip-right'
220
+ const span = across ? W : H
221
+ const sigma = Math.min(600, (12 * Math.min(p, 1 - p) ** 2 * span * 0.35) / n)
222
+ const forward = kind === 'whip-left' || kind === 'whip-up'
223
+ const off = Math.round((forward ? u : 1 - u) * span)
224
+ const pair = forward ? `[${fa}][${fb}]` : `[${fb}][${fa}]`
225
+ g.push(`${pair}${across ? 'hstack' : 'vstack'}=inputs=2,crop=${W}:${H}:${across ? off : 0}:${across ? 0 : off},gblur=sigma=${n3(across ? sigma : 0)}:sigmaV=${n3(across ? 0 : sigma)}[${fo}]`)
226
+ continue
227
+ }
228
+ // glitch: the picture flickers from one shot to the other round the middle, torn hardest there
229
+ const env = Math.sin(Math.PI * p) ** 0.7
230
+ const flick = p > 0.3 && p < 0.7 && rnd(i, k, 9) < 0.45
231
+ const src = (p < 0.5) !== flick ? fa : fb
232
+ g.push(`[${src === fa ? fb : fa}]nullsink`)
233
+ const dx = Math.round(W * 0.016 * env * (0.4 + rnd(i, k, 1)))
234
+ const bands = 1 + Math.floor(rnd(i, k, 2) * 3)
235
+ let cur = `[${src}]format=gbrp,rgbashift=rh=${dx}:bh=${-dx}:gv=${Math.round(dx / 3)}:edge=smear`
236
+ const parts: string[] = []
237
+ for (let j = 0; j < bands; j++) {
238
+ const bh = 2 * Math.round((H * (0.03 + 0.1 * rnd(i, k, j, 3))) / 2)
239
+ const by = Math.round((H - bh) * rnd(i, k, j, 4))
240
+ const shift = Math.round((rnd(i, k, j, 5) - 0.5) * W * 0.16 * env)
241
+ parts.push(`${cur},split[gb${i}_${k}_${j}][gs${i}_${k}_${j}]`, `[gs${i}_${k}_${j}]crop=${W}:${bh}:0:${by}[gt${i}_${k}_${j}]`)
242
+ cur = `[gb${i}_${k}_${j}][gt${i}_${k}_${j}]overlay=x=${shift}:y=${by}`
243
+ }
244
+ g.push(...parts, `${cur},noise=alls=${Math.round(28 * env)}:allf=t+u:all_seed=${i * 977 + k},format=yuv420p[${fo}]`)
245
+ }
246
+ g.push(`${ks.map((k) => `[co${i}_${k}]`).join('')}concat=n=${n}:v=1:a=0,settb=1/${fps},setpts=N,format=yuv420p[${out}]`)
247
+ return g
248
+ }
249
+
250
+ /** each title's span on the cut, shot by shot: its first and last frame (inclusive) and its seconds, a knock's flight
251
+ * in them. A progress step ends the frame before the next one starts: two steps never stand on one frame */
252
+ export function titleSpans(t: Pick<Trailer, 'shots' | 'fps'> & Parameters<typeof starts>[0]): { ta: number; te: number; seconds: number }[][] {
253
+ const st = starts(t)
254
+ return t.shots.map((s, i) =>
255
+ s.titles.map((ti) => {
256
+ const at = st[i]! + ti.at
257
+ const seconds = Math.min(ti.for ?? s.length - ti.at, ti.knock === undefined ? Infinity : ti.knock - ti.at + KNOCK_S)
258
+ const handOff = ti.style === 'progress' && ti.step !== undefined && ti.step.i < ti.step.n ? 1 : 0
259
+ return { ta: frameAt(at, t.fps), te: Math.floor((at + seconds) * t.fps + 1e-6) - handOff, seconds }
260
+ }),
261
+ )
262
+ }
263
+
264
+ /** each title's entrance and exit in frames (motion.ts), shot by shot: what the build draws and the stitch lays */
265
+ export function titleMotions(t: Pick<Trailer, 'shots' | 'fps'> & Parameters<typeof starts>[0]): TitleMotion[][] {
266
+ const spans = titleSpans(t)
267
+ return t.shots.map((s, i) =>
268
+ s.titles.map((ti, j) => {
269
+ const sp = spans[i]![j]!
270
+ return titleMotion({
271
+ style: ti.style,
272
+ text: ti.text,
273
+ ...(ti.in ? { in: ti.in } : {}),
274
+ ...(ti.out ? { out: ti.out } : {}),
275
+ ...(ti.knock !== undefined ? { knock: ti.knock - ti.at } : {}),
276
+ ...(ti.step ? { step: ti.step } : {}),
277
+ seconds: sp.seconds,
278
+ frames: Math.max(0, sp.te - sp.ta + 1),
279
+ fps: t.fps,
280
+ cover: ti.style === 'short' && sp.ta === 0,
281
+ })
282
+ }),
283
+ )
284
+ }
204
285
 
205
286
  /** the ffmpeg arguments that render a piece's frames, no sound, and the row of its shots' middle frames for the
206
287
  * contact strip (`thumbs`, when a middle falls in it) */
@@ -340,7 +421,9 @@ export function pieceArgs(a: Pick<StitchInput, 't' | 'sources' | 'titles' | 'loo
340
421
  const kind = t.shots[i - 1]!.cut.kind as Exclude<CutKind, 'hard'>
341
422
  const d = head / fps
342
423
  const edge = SEAM_EDGE[kind]
343
- if (edge === undefined) graph.push(`[e${i - 1}][h${i}]xfade=transition=${XFADE[kind]}:duration=${n3(d)}:offset=0,format=yuv420p[j${i}]`)
424
+ const how = CUTS[kind] as string
425
+ if (how === 'drawn') graph.push(...drawnCut(kind, `e${i - 1}`, `h${i}`, `j${i}`, head, i, [W, H], fps, a.look))
426
+ else if (edge === undefined) graph.push(`[e${i - 1}][h${i}]xfade=transition=${how}:duration=${n3(d)}:offset=0,format=yuv420p[j${i}]`)
344
427
  else {
345
428
  // a wipe's seam rides its edge: a soft shadow of the look's edge, then a line of its light
346
429
  const across = edge.axis === 'x'
@@ -349,7 +432,7 @@ export function pieceArgs(a: Pick<StitchInput, 't' | 'sources' | 'titles' | 'loo
349
432
  const at = (w: number): string => (across ? `x='${pos}-${w / 2}':y=0` : `x=0:y='${pos}-${w / 2}'`)
350
433
  const [sw, lw] = [Math.round(H / 64), Math.max(4, Math.round(H / 300))]
351
434
  graph.push(
352
- `[e${i - 1}][h${i}]xfade=transition=${XFADE[kind]}:duration=${n3(d)}:offset=0[jw${i}]`,
435
+ `[e${i - 1}][h${i}]xfade=transition=${how}:duration=${n3(d)}:offset=0[jw${i}]`,
353
436
  bar(`${ff(a.look.edge)}@0.35`, sw, 'ws'),
354
437
  bar(ff(a.look.light), lw, 'wl'),
355
438
  `[jw${i}][ws${i}]overlay=${at(sw)}:eval=frame:shortest=1[jv${i}]`,
@@ -373,43 +456,34 @@ export function pieceArgs(a: Pick<StitchInput, 't' | 'sources' | 'titles' | 'loo
373
456
  cur = 'ends'
374
457
  }
375
458
 
376
- // the titles that show in the piece, wherever their shot is: each faded in and out over its own span
459
+ // the titles that show in the piece, wherever their shot is: each its entrance's frames, its still held, its exit's
460
+ // frames (titles.ts drew them all in one box), laid at that box
461
+ const tspans = titleSpans(t)
377
462
  t.shots.forEach((s, i) => {
378
- s.titles.forEach((ti, j) => {
379
- const at = st[i]! + ti.at
380
- const dur = Math.min(ti.for ?? s.length - ti.at, ti.knock === undefined ? Infinity : ti.knock - ti.at + KNOCK_S)
381
- // a progress step ends the frame before the next one starts: two steps never stand on one frame
382
- const handOff = ti.style === 'progress' && ti.step !== undefined && ti.step.i < ti.step.n ? 1 : 0
383
- const [ta, te] = [frameAt(at, fps), Math.floor((at + dur) * fps + 1e-6) - handOff]
463
+ s.titles.forEach((_, j) => {
464
+ const { ta, te } = tspans[i]![j]!
384
465
  if (te < p.from || ta >= p.to || te < ta) return
385
- // a tagline and the logo breathe in slowly; the plates pop; a short's words come quick
386
- const short = ti.style === 'short' || ti.style === 'progress'
387
- const f = Math.min(ti.style === 'line' || ti.style === 'logo' ? 0.7 : short ? 0.15 : 0.25, dur / 3)
388
- // the steps of a progress swap on their beat: the first alone fades in, the last alone fades out
389
- const step = ti.style === 'progress' ? ti.step : undefined
390
- const fadeIn = step === undefined || step.i === 1
391
- const fadeOut = step === undefined || step.i === step.n
392
- // a short's caption on the cut's first frame is there whole: frame 0 is the cover the platforms take
393
- const cover = ti.style === 'short' && ta === 0
394
466
  const img = a.titles[i]![j]!
395
- const k = input('-i', img.file)
467
+ const [nIn, nOut] = [img.in ?? 0, img.out ?? 0]
468
+ const hold = te - ta + 1 - nIn - nOut
396
469
  const [from, to] = [Math.max(ta, base), Math.min(te, p.to - 1)]
397
- const kn = ti.knock === undefined ? null : ti.knock - ti.at
398
- const hyp = kn === null ? 0 : Math.ceil(Math.hypot(img.w, img.h))
399
- const tip = kn === null ? '' : `rotate=a='-${KNOCK_TURN}*pow(max(0,t-${n3(kn)})/${KNOCK_S},2)':ow=${hyp}:oh=${hyp}:c=none,`
400
- graph.push(
401
- `[${k}:v]format=rgba,loop=loop=${to - ta}:size=1:start=0,setpts=N/${fps}/TB,${tip}${cover || !fadeIn ? '' : `fade=t=in:st=0:d=${n3(f)}:alpha=1,`}${fadeOut && kn === null ? `fade=t=out:st=${n3(dur - f)}:d=${n3(f)}:alpha=1,` : ''}` +
402
- `trim=start_frame=${from - ta},settb=1/${fps},setpts=N+${from - base}[t${i}_${j}]`,
403
- )
404
- // it rises into place over its fade in, eased, by a fortieth of the frame; counted in frames. A short's
405
- // caption slides in from the left instead, eased out over 0.15 s
406
- const rise = cover || short ? '0' : `${n3(H / 40)}*pow(max(0,1-(n-${n3(at * fps - base)})/${n3(Math.max(f, 0.05) * fps)}),2)`
407
- const slide = cover || ti.style !== 'short' ? '' : `-${n3(H / 32)}*pow(max(0,1-(n-${n3(at * fps - base)})/${n3(0.15 * fps)}),3)`
470
+ const segs: string[] = []
471
+ const seq = (pattern: string, n: number, tag: string): void => {
472
+ const k = input('-framerate', String(fps), '-start_number', '0', '-i', pattern)
473
+ graph.push(`[${k}:v]format=rgba,trim=end_frame=${n},settb=1/${fps},setpts=N[t${i}_${j}${tag}]`)
474
+ segs.push(`[t${i}_${j}${tag}]`)
475
+ }
476
+ if (nIn > 0) seq(inFrames(img.file), nIn, 'i')
477
+ if (hold > 0) {
478
+ const k = input('-i', img.file)
479
+ graph.push(`[${k}:v]format=rgba,loop=loop=${hold - 1}:size=1:start=0,settb=1/${fps},setpts=N[t${i}_${j}h]`)
480
+ segs.push(`[t${i}_${j}h]`)
481
+ }
482
+ if (nOut > 0) seq(outFrames(img.file), nOut, 'o')
483
+ const join = segs.length > 1 ? `${segs.join('')}concat=n=${segs.length}:v=1:a=0,` : `${segs[0]}`
484
+ graph.push(`${join}${segs.length > 1 ? '' : 'null,'}trim=start_frame=${from - ta}:end_frame=${to - ta + 1},settb=1/${fps},setpts=N+${from - base}[t${i}_${j}]`)
408
485
  const on = `between(t,${n3((ta - base - 0.5) / fps)},${n3((te - base + 0.5) / fps)})`
409
- const flown = kn === null ? '' : `pow(max(0,n-${n3((at + kn) * fps - base)})/${n3(KNOCK_S * fps)},2)`
410
- const x = kn === null ? `${img.x}${slide}` : `${img.x - (hyp - img.w) / 2}-${n3(W * 1.1)}*${flown}`
411
- const y = kn === null ? `${img.y}+${rise}` : `${img.y - (hyp - img.h) / 2}+${rise}-${n3(H * 0.45)}*${flown}`
412
- graph.push(`[${cur}][t${i}_${j}]overlay=x='${x}':y='${y}':eof_action=pass:enable='${on}'[v${i}_${j}]`)
486
+ graph.push(`[${cur}][t${i}_${j}]overlay=x=${img.x}:y=${img.y}:eof_action=pass:enable='${on}'[v${i}_${j}]`)
413
487
  cur = `v${i}_${j}`
414
488
  })
415
489
  })
@@ -22,6 +22,9 @@ export interface TrailerLookInput {
22
22
  short?: { font?: { file?: string; family?: string; weight?: number }; light?: string; edge?: string; accent?: string }
23
23
  /** the vertical short's look (a `vertical` cut), each part `false` to leave it out (vertical.ts) */
24
24
  vertical?: VerticalLookInput
25
+ /** text styles by name, each a title line's words (`style big font "Bebas Neue" in letters`): a title's `style`
26
+ * names one; one named like a built in style gives that style its defaults (grammar.ts resolveStyles) */
27
+ styles?: Record<string, string>
25
28
  }
26
29
 
27
30
  /** what a vertical short wears, image paths repo relative; its colours and face are the look's */
@@ -0,0 +1,207 @@
1
+ // a title's entrance and exit, frame by frame: what each move does to the whole title or to each of its letters at a
2
+ // point of its run. Pure: the grammar checks the words, titles.ts draws each state, graph.ts lays the frames. An exit
3
+ // is its move played backwards; the knock is the one exit of its own
4
+
5
+ export const MOVES = ['none', 'fade', 'rise', 'drop', 'slide', 'pop', 'shake', 'blur', 'wipe', 'typewriter', 'letters', 'wave'] as const
6
+ export type MoveKind = (typeof MOVES)[number]
7
+ /** the moves drawn letter by letter: the title is drawn again each frame, the rest move the drawn title */
8
+ export const LETTER_MOVES: ReadonlySet<MoveKind> = new Set(['typewriter', 'letters', 'wave'])
9
+
10
+ /** a move a title line names: `in pop 0.4s`; no seconds, its own default */
11
+ export interface Move {
12
+ kind: MoveKind
13
+ seconds?: number
14
+ }
15
+
16
+ /** the whole title at a frame: its alpha, its offset in frame pixels, its scale and turn (radians) about its box's
17
+ * middle, a blur in frame pixels, and the share of its width a wipe shows (1: all) */
18
+ export interface Whole {
19
+ a: number
20
+ dx: number
21
+ dy: number
22
+ s: number
23
+ rot: number
24
+ blur: number
25
+ wipe: number
26
+ }
27
+
28
+ /** one letter at a frame, about its own middle: alpha, offset in frame pixels, scale */
29
+ export interface Glyph {
30
+ a: number
31
+ dx: number
32
+ dy: number
33
+ s: number
34
+ }
35
+
36
+ /** what a frame draws: the whole title's state; with a letter move, each letter's and the plates' and bands' alpha */
37
+ export interface TitleFrame {
38
+ whole: Whole
39
+ glyph?: (i: number, n: number) => Glyph
40
+ decor: number
41
+ }
42
+
43
+ export const STILL: Whole = { a: 1, dx: 0, dy: 0, s: 1, rot: 0, blur: 0, wipe: 1 }
44
+ const AT_REST: TitleFrame = { whole: STILL, decor: 1 }
45
+
46
+ const clip01 = (x: number): number => Math.max(0, Math.min(1, x))
47
+ const BACK = 1.70158
48
+ /** an ease out that overshoots a little and settles */
49
+ export const backOut = (x: number): number => 1 + (BACK + 1) * (x - 1) ** 3 + BACK * (x - 1) ** 2
50
+ const inOut = (x: number): number => (x < 0.5 ? 4 * x ** 3 : 1 - (-2 * x + 2) ** 3 / 2)
51
+ const smooth = (x: number): number => x * x * (3 - 2 * x)
52
+
53
+ /** a knocked title tips and flies out over this long */
54
+ export const KNOCK_S = 0.4
55
+ const KNOCK_TURN = 1.4
56
+
57
+ /** the frame of `kind` at `p`, 0 (before the entrance) to 1 (at rest), on a frame W by H whose letters are `px` tall */
58
+ export function moveFrame(kind: MoveKind, p: number, [W, H]: readonly [number, number], px: number): TitleFrame {
59
+ void W
60
+ p = clip01(p)
61
+ const whole = (w: Partial<Whole>): TitleFrame => ({ whole: { ...STILL, ...w }, decor: 1 })
62
+ switch (kind) {
63
+ case 'none':
64
+ return AT_REST
65
+ case 'fade':
66
+ return whole({ a: p })
67
+ case 'rise':
68
+ return whole({ a: p, dy: (H / 40) * (1 - p) ** 2 })
69
+ case 'drop':
70
+ return whole({ a: p, dy: -(H / 30) * (1 - p) ** 2 })
71
+ case 'slide':
72
+ return whole({ a: p, dx: -(H / 32) * (1 - p) ** 3 })
73
+ case 'pop':
74
+ return whole({ a: clip01(p * 3), s: 0.55 + 0.45 * backOut(p) })
75
+ case 'shake': {
76
+ const amp = (H / 26) * (1 - p) ** 2
77
+ return whole({ a: clip01(p * 4), dx: (amp * (Math.sin(p * 53) + 0.5 * Math.sin(p * 31 + 1))) / 1.5, dy: (amp * (Math.cos(p * 47) + 0.5 * Math.sin(p * 23 + 2))) / 1.5, rot: (amp / H) * 0.6 * Math.sin(p * 41) })
78
+ }
79
+ case 'blur':
80
+ return whole({ a: clip01(p * 1.6), blur: (H / 40) * (1 - p) ** 2, s: 1 + 0.04 * (1 - p) })
81
+ case 'wipe':
82
+ return whole({ wipe: inOut(p) })
83
+ case 'typewriter':
84
+ return { whole: STILL, decor: clip01(p * 4), glyph: (i, n) => ({ a: p * n >= i + 1 - 1e-9 ? 1 : 0, dx: 0, dy: 0, s: 1 }) }
85
+ case 'letters':
86
+ return {
87
+ whole: STILL,
88
+ decor: clip01(p * 3),
89
+ glyph: (i, n) => {
90
+ const span = 0.45
91
+ const q = smooth(clip01((p - (n > 1 ? (i / (n - 1)) * (1 - span) : 0)) / span))
92
+ return { a: q, dx: 0, dy: px * 0.4 * (1 - q) ** 2, s: 1 }
93
+ },
94
+ }
95
+ case 'wave':
96
+ return {
97
+ whole: STILL,
98
+ decor: clip01(p * 3),
99
+ glyph: (i, n) => {
100
+ const span = 0.5
101
+ const q = clip01((p - (n > 1 ? (i / (n - 1)) * (1 - span) : 0)) / span)
102
+ return { a: clip01(q * 2.5), dx: 0, dy: px * 0.7 * (1 - backOut(q)), s: 0.7 + 0.3 * backOut(q) }
103
+ },
104
+ }
105
+ }
106
+ }
107
+
108
+ /** a knocked title at `q`, 0 (still in place) to 1 (gone): it tips and flies off left and up */
109
+ export function knockFrame(q: number, [W, H]: readonly [number, number]): TitleFrame {
110
+ const k = clip01(q) ** 2
111
+ return { whole: { ...STILL, rot: -KNOCK_TURN * k, dx: -W * 1.1 * k, dy: -H * 0.45 * k }, decor: 1 }
112
+ }
113
+
114
+ /** how many letters a title's text draws: every character but spaces, the `*` marks and the `\n` breaks */
115
+ export const letterCount = (text: string): number => [...text.replaceAll('\\n', '').replace(/[*\s]/g, '')].length
116
+
117
+ /** a move's length when its line names none; the default entrance and exit keep the style's own (`styleFade`) */
118
+ export function moveSeconds(kind: MoveKind, letters: number): number {
119
+ switch (kind) {
120
+ case 'none':
121
+ return 0
122
+ case 'typewriter':
123
+ return 0.045 * letters
124
+ case 'letters':
125
+ return 0.3 + 0.03 * letters
126
+ case 'wave':
127
+ return 0.35 + 0.035 * letters
128
+ case 'pop':
129
+ return 0.35
130
+ case 'shake':
131
+ case 'blur':
132
+ case 'wipe':
133
+ return 0.5
134
+ default:
135
+ return 0.4
136
+ }
137
+ }
138
+
139
+ /** the style's own fade: a tagline and the logo breathe in slowly, the plates pop, a short's words come quick */
140
+ export const styleFade = (style: string): number => (style === 'line' || style === 'logo' ? 0.7 : style === 'short' || style === 'progress' ? 0.15 : 0.25)
141
+
142
+ /** what a title's entrance and exit are, their frames counted */
143
+ export interface TitleMotion {
144
+ in: { kind: MoveKind; frames: number }
145
+ out: { kind: MoveKind | 'knock'; frames: number }
146
+ }
147
+
148
+ /** the title's span in seconds and frames, and what it shows: the words of its line, its style, its step */
149
+ export interface MotionInput {
150
+ style: string
151
+ text: string
152
+ in?: Move
153
+ out?: Move
154
+ /** seconds from the title's start to its knock */
155
+ knock?: number
156
+ step?: { i: number; n: number }
157
+ /** seconds it stands, a knock's flight included */
158
+ seconds: number
159
+ /** frames it stands */
160
+ frames: number
161
+ fps: number
162
+ /** a short's caption on the cut's first frame: there whole, the cover the platforms take */
163
+ cover: boolean
164
+ }
165
+
166
+ /** a title's entrance and exit in frames. With no `in`, a title rises in (a short slides, a progress fades) over its
167
+ * style's fade; with no `out`, it fades out over it; a default never takes more than a third of the title, a letter
168
+ * move more than half. A progress step swaps on its beat: only the first fades in, only the last fades out */
169
+ export function titleMotion(m: MotionInput): TitleMotion {
170
+ const fade = Math.min(styleFade(m.style), m.seconds / 3)
171
+ const letters = letterCount(m.text)
172
+ const own = (mv: Move | undefined, fallback: MoveKind): [MoveKind, number] => {
173
+ if (!mv) return [fallback, fallback === 'none' ? 0 : fade]
174
+ if (mv.seconds !== undefined) return [mv.kind, mv.seconds]
175
+ return [mv.kind, Math.min(moveSeconds(mv.kind, letters), m.seconds * (LETTER_MOVES.has(mv.kind) ? 0.5 : 1 / 3))]
176
+ }
177
+ const firstStep = m.step === undefined || m.step.i === 1
178
+ const lastStep = m.step === undefined || m.step.i === m.step.n
179
+ const defIn: MoveKind = m.style === 'short' ? 'slide' : m.style === 'progress' ? 'fade' : 'rise'
180
+ let [inKind, inS] = m.cover || !firstStep ? ['none' as MoveKind, 0] : own(m.in, defIn)
181
+ let [outKind, outS]: [MoveKind | 'knock', number] = m.knock !== undefined ? ['knock', KNOCK_S] : !lastStep ? ['none', 0] : own(m.out, 'fade')
182
+ if (inKind === 'none') inS = 0
183
+ if (outKind === 'none') outS = 0
184
+ let a = Math.round(inS * m.fps)
185
+ let b = Math.round(outS * m.fps)
186
+ // a title shorter than its moves shares its frames between them
187
+ if (a + b > m.frames) {
188
+ const k = m.frames / (a + b)
189
+ a = Math.floor(a * k)
190
+ b = Math.floor(b * k)
191
+ }
192
+ if (a === 0) inKind = 'none'
193
+ if (b === 0) outKind = 'none'
194
+ return { in: { kind: inKind, frames: a }, out: { kind: outKind, frames: b } }
195
+ }
196
+
197
+ /** the frame `k` of an entrance of `n` frames: frame 0 is before it, the frame after the last at rest */
198
+ export const inFrame = (kind: MoveKind, k: number, n: number, size: readonly [number, number], px: number): TitleFrame => moveFrame(kind, k / n, size, px)
199
+
200
+ /** the frame `k` of an exit of `n` frames: its move played backwards, the last frame gone; a knock leaves from its
201
+ * first frame, still in place */
202
+ export const outFrame = (kind: MoveKind | 'knock', k: number, n: number, size: readonly [number, number], px: number): TitleFrame =>
203
+ kind === 'knock' ? knockFrame(k / n, size) : moveFrame(kind, 1 - (k + 1) / n, size, px)
204
+
205
+ /** the files of an entrance's or an exit's frames beside a title's PNG, as ffmpeg reads a numbered run */
206
+ export const inFrames = (file: string): string => `${file}.in-%04d.png`
207
+ export const outFrames = (file: string): string => `${file}.out-%04d.png`
@@ -12,8 +12,8 @@ export function footageKey(version: unknown, recipe: unknown, size: readonly [nu
12
12
  }
13
13
 
14
14
  /** a film that writes a take of its own: the others are read where they lie (a clip, an image) or drawn (a card) */
15
- export type TakeFilm = Extract<Film, { kind: 'run' | 'cli' | 'url' }>
16
- export const isTake = (f: Film | null): f is TakeFilm => f?.kind === 'run' || f?.kind === 'cli' || f?.kind === 'url'
15
+ export type TakeFilm = Extract<Film, { kind: 'run' | 'cli' | 'url' | 'page' }>
16
+ export const isTake = (f: Film | null): f is TakeFilm => f?.kind === 'run' || f?.kind === 'cli' || f?.kind === 'url' || f?.kind === 'page'
17
17
 
18
18
  /** the take is one picture, held for the shot */
19
19
  export const takeIsStill = (f: TakeFilm): boolean => (f.kind === 'cli' ? f.output === 'still' : f.still === true)
@@ -28,7 +28,7 @@ export interface Live {
28
28
  /** the live set: each cut read, the name of every cache file its build reads, by the build's own key functions */
29
29
  export async function liveSet(root: string): Promise<Live> {
30
30
  const live: Live = { footage: new Set(), stems: new Set(), sound: new Set(), score: new Set(), titles: new Set(), broken: [] }
31
- const [{ takesOfLoaded }, { trailerCache }, { scoreWavFile }, { scoreModuleFile, scoreModule }, titles, { lookOf }, { loadBrand, identityLogo }] = await Promise.all([
31
+ const [{ fileOf, takesOfLoaded, titleRequest }, { trailerCache }, { scoreWavFile }, { scoreModuleFile, scoreModule }, titles, { lookOf }, { loadBrand, identityLogo }, fonts, { titleMotions }, { resolveStyles }] = await Promise.all([
32
32
  import('./build.ts'),
33
33
  import('./footage.ts'),
34
34
  import('./score/sound.ts'),
@@ -36,6 +36,9 @@ export async function liveSet(root: string): Promise<Live> {
36
36
  import('./titles.ts'),
37
37
  import('./look.ts'),
38
38
  import('../assets/run-assets.ts'),
39
+ import('./fonts.ts'),
40
+ import('./graph.ts'),
41
+ import('./grammar.ts'),
39
42
  ])
40
43
  const brand = await loadBrand(root)
41
44
  const look = typeof brand === 'string' ? null : lookOf(brand, await identityLogo(root))
@@ -55,7 +58,15 @@ export async function liveSet(root: string): Promise<Live> {
55
58
  if (look) {
56
59
  const dir = trailerCache(root, 'titles')
57
60
  names.titles.push(basename(titles.cardFile(dir, t.size, look)))
58
- for (const s of t.shots) for (const ti of s.titles) names.titles.push(basename(titles.titleFile(dir, root, ti.text, ti.style, t.size, titles.placeOf(ti), look, ti.step)))
61
+ resolveStyles(t, (typeof brand === 'object' && brand?.trailer?.styles) || {})
62
+ const motions = titleMotions(t)
63
+ t.shots.forEach((s, i) =>
64
+ s.titles.forEach((ti, j) => {
65
+ const f = ti.fx?.font
66
+ const font = f === undefined ? null : fonts.isFontFile(f) ? fileOf(f, l) : fonts.googleFile(f, ti.fx?.weight ?? 400)
67
+ names.titles.push(basename(titles.titleFile(dir, { ...titleRequest(l, ti, look, motions[i]![j]!), font })))
68
+ }),
69
+ )
59
70
  }
60
71
  if (t.score && l.score) {
61
72
  names.score.push(basename(scoreModuleFile(scoreModule(t.score, t.sections).text, trailerCache(root, 'score'))))
@@ -34,8 +34,9 @@ export function transpose(text: string, semis: number): string {
34
34
  .map((tok) => {
35
35
  const { mods, body } = splitMods(tok)
36
36
  const [note = '', len] = body.split('/')
37
- const midi = noteToMidi(note)
38
- return midi === null ? tok : mods + midiToNote(midi + semis) + (len === undefined ? '' : `/${len}`)
37
+ const midis = note.split('+').map(noteToMidi)
38
+ if (midis.some((m) => m === null)) return tok
39
+ return mods + midis.map((m) => midiToNote(m! + semis)).join('+') + (len === undefined ? '' : `/${len}`)
39
40
  })
40
41
  .join(' ')
41
42
  }
@@ -238,3 +239,18 @@ export function voice(text: string, which: number, floor: number, bar = 16, rest
238
239
  }
239
240
  return out.join(' ')
240
241
  }
242
+
243
+ /**
244
+ * each chord of a chart whole, as one chord token (`D4+F4+A4`): every tone placed at or above `floor` (a midi note),
245
+ * stacked upward, re-struck per segment and every `restrike` rows. For strings, brass, an organ or a stab.
246
+ */
247
+ export function chords(text: string, floor: number, bar = 16, restrike = 0): string {
248
+ const out: string[] = []
249
+ for (const seg of chart(text, bar)) {
250
+ let m = seg.chord.root
251
+ while (m < floor) m += 12
252
+ const tok = seg.chord.tones.map((iv) => midiToNote(m + iv)).join('+')
253
+ for (let k = 0; k < seg.rows; k++) out.push(k === 0 || (restrike > 0 && k % restrike === 0) ? tok : '.')
254
+ }
255
+ return out.join(' ')
256
+ }
@@ -0,0 +1,43 @@
1
+ // a score's General MIDI tracks as stems: each GM instrument's notes written as a .mid, rendered by FluidSynth with
2
+ // its own room off, read back for the synth to mix into the score's bus (its pan, its send to the room, the master)
3
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { fluidRender, readWav } from '../sound/fluid.ts'
7
+ import { stemMidi } from './midi.ts'
8
+ import type { CompiledScore } from './score.ts'
9
+
10
+ /** fluidsynth's gain for a stem: a GM instrument at vol 0.8 then sits near a synth one of the palette (measured on one
11
+ * arpeggio: GM piano -26.9 dBFS at gain 1, the palette's keys -19.5) */
12
+ const STEM_GAIN = 2.2
13
+
14
+ export interface Stem {
15
+ instrument: string
16
+ left: Float32Array
17
+ right: Float32Array
18
+ }
19
+
20
+ /** the score's GM instruments that play a note before `end` */
21
+ export const gmInstruments = (score: CompiledScore, end: number): string[] =>
22
+ [...new Set(score.notes.filter((n) => n.time < end && score.instruments[n.instrument]?.osc === 'gm').map((n) => n.instrument))]
23
+
24
+ /** one stem a GM instrument, `rate` samples a second, its notes up to `end` seconds of the render */
25
+ export function gmStems(score: CompiledScore, end: number, rate: number): Stem[] {
26
+ const names = gmInstruments(score, end)
27
+ if (names.length === 0) return []
28
+ const dir = mkdtempSync(join(tmpdir(), 'sk-gm-'))
29
+ try {
30
+ return names.map((name, i) => {
31
+ const v = score.instruments[name]!.gm!
32
+ const mid = join(dir, `${i}.mid`)
33
+ const wav = join(dir, `${i}.wav`)
34
+ writeFileSync(mid, stemMidi(score.notes.filter((n) => n.instrument === name), v, end))
35
+ fluidRender(mid, wav, { rate, room: false, gain: STEM_GAIN })
36
+ const w = readWav(wav)
37
+ if (w.rate !== rate) throw new Error(`fluidsynth rendered ${name} at ${w.rate} Hz, not ${rate}`)
38
+ return { instrument: name, left: w.left, right: w.right }
39
+ })
40
+ } finally {
41
+ rmSync(dir, { recursive: true, force: true })
42
+ }
43
+ }