rvlib-shipkit 2.3.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -2
- package/package.json +2 -2
- package/src/assets/kits/identity-kit.ts +5 -2
- package/src/cli/cmd_Commands.ts +30 -5
- package/src/cli/cmd_FleetPush.ts +47 -0
- package/src/cli/cmd_GlobalProcesses.ts +5 -5
- package/src/cli/cmd_Review.ts +52 -11
- package/src/cli/cmd_Sound.ts +209 -0
- package/src/cli/cmd_Trailer.ts +11 -3
- package/src/cli/family-pages.ts +49 -0
- package/src/cli/kit/builtins.ts +1 -0
- package/src/cli/kit/verb.ts +11 -3
- package/src/cli/registry.ts +137 -0
- package/src/cli.ts +47 -133
- package/src/core/app-icon.ts +10 -6
- package/src/core/last-good.ts +217 -0
- package/src/core/load-probe.ts +71 -0
- package/src/core/reexec.ts +5 -3
- package/src/fleet/push-fleet.ts +80 -0
- package/src/home/home-routes.ts +12 -2
- package/src/home/lib-watch.ts +110 -20
- package/src/home/process-rows.ts +4 -2
- package/src/home/run-daemon.ts +1 -1
- package/src/repo-config.ts +3 -0
- package/src/review/format.ts +11 -3
- package/src/review/model.ts +66 -5
- package/src/review/paste.ts +30 -0
- package/src/review/project.ts +20 -4
- package/src/review/routes.ts +22 -4
- package/src/review/store.ts +15 -6
- package/src/review/unit.ts +19 -1
- package/src/scaffold/files/agent-index.md +2 -2
- package/src/scaffold/project-scaffold.ts +2 -2
- package/src/serve/hub-app/activity-st.ts +31 -0
- package/src/serve/hub-app/activity-view.tsx +9 -0
- package/src/serve/hub-app/activity.css +6 -0
- package/src/serve/hub-app/admin-svc.ts +1 -1
- package/src/serve/hub-app/admin-view.tsx +1 -1
- package/src/serve/hub-app/hash-route.ts +12 -4
- package/src/serve/hub-app/home-st.ts +7 -1
- package/src/serve/hub-app/home-strip.tsx +3 -1
- package/src/serve/hub-app/hub.css +38 -1
- package/src/serve/hub-app/icons.tsx +6 -0
- package/src/serve/hub-app/layout-st.ts +2 -2
- package/src/serve/hub-app/machine-st.ts +1 -1
- package/src/serve/hub-app/md.ts +32 -0
- package/src/serve/hub-app/project-list.css +9 -7
- package/src/serve/hub-app/project-list.tsx +5 -2
- package/src/serve/hub-app/projects-svc.ts +3 -2
- package/src/serve/hub-app/repo-st.ts +22 -7
- package/src/serve/hub-app/review/clip-st.ts +57 -7
- package/src/serve/hub-app/review/kind-view.tsx +78 -9
- package/src/serve/hub-app/review/review-answer.tsx +67 -58
- package/src/serve/hub-app/review/review-page.tsx +17 -15
- package/src/serve/hub-app/review/review-st.ts +191 -20
- package/src/serve/hub-app/review/review-views.tsx +154 -9
- package/src/serve/hub-app/review.css +73 -13
- package/src/serve/hub-app/wiki-st.ts +190 -0
- package/src/serve/hub-app/wikis-view.tsx +134 -5
- package/src/serve/hub-runtime.ts +4 -4
- package/src/serve/project-gutter-route.ts +25 -1
- package/src/serve/project-gutter.ts +1 -1
- package/src/serve/run-serve.ts +25 -0
- package/src/serve/serve-hub.ts +15 -1
- package/src/serve/stale-restart.ts +19 -2
- package/src/shot/client.ts +17 -5
- package/src/shot/shooter.ts +19 -3
- package/src/shot/shot-ask.ts +9 -1
- package/src/shot/stepped-clip.ts +53 -0
- package/src/trailer/adapter.ts +33 -4
- package/src/trailer/build.ts +182 -33
- package/src/trailer/ffmpeg.ts +21 -1
- package/src/trailer/fonts.ts +55 -0
- package/src/trailer/footage.ts +4 -4
- package/src/trailer/grammar.ts +429 -67
- package/src/trailer/graph.ts +130 -56
- package/src/trailer/look.ts +3 -0
- package/src/trailer/motion.ts +207 -0
- package/src/trailer/plan.ts +2 -2
- package/src/trailer/prune.ts +13 -2
- package/src/trailer/score/compose.ts +18 -2
- package/src/trailer/score/gm-render.ts +43 -0
- package/src/trailer/score/gm.ts +90 -0
- package/src/trailer/score/midi.ts +143 -0
- package/src/trailer/score/pattern.ts +13 -6
- package/src/trailer/score/score.ts +28 -14
- package/src/trailer/score/sound.ts +12 -1
- package/src/trailer/score/synth.ts +24 -2
- package/src/trailer/sound/audio.ts +148 -0
- package/src/trailer/sound/brand.ts +42 -0
- package/src/trailer/sound/comfy.ts +38 -0
- package/src/trailer/sound/eleven.ts +72 -0
- package/src/trailer/sound/fluid.ts +71 -0
- package/src/trailer/sound/gen.ts +139 -0
- package/src/trailer/sound/index.ts +60 -0
- package/src/trailer/sound/presets.ts +69 -0
- package/src/trailer/sound/run.ts +189 -0
- package/src/trailer/titles.ts +451 -235
- package/src/trailer/url.ts +18 -3
- package/src/trailer/vocab.ts +12 -1
- package/src/trailer/words.ts +29 -8
- package/src/wiki/graph-mount.ts +3 -0
- package/src/wiki/graph-page.ts +45 -9
- package/src/wiki/graph-pages.ts +128 -0
package/src/trailer/build.ts
CHANGED
|
@@ -2,14 +2,16 @@
|
|
|
2
2
|
// stitch the pieces, lay the strip, write the montage page. `sk trailer` and its verbs call these
|
|
3
3
|
import { constants, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
4
4
|
import { basename, dirname, extname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
5
|
-
import type { TrailerCheckInput, TrailerUnit } from './adapter.ts'
|
|
6
|
-
import { type FilmVerb, type Film, gridCells, parseTrailer, SCORE, SCORE_LEAD, type Shot, starts, total, type Trailer, type TrailerError, musicCues, NO_MUSIC } from './grammar.ts'
|
|
5
|
+
import type { SoundRequest, TrailerCheckInput, TrailerUnit } from './adapter.ts'
|
|
6
|
+
import { type FilmVerb, type Film, gridCells, namedStyles, parseTrailer, placeOf, resolveStyles, type Title, type SoundGen, type SoundRun, SCORE, SCORE_LEAD, type Shot, starts, total, type Trailer, type TrailerError, musicCues, NO_MUSIC } from './grammar.ts'
|
|
7
7
|
import { filmLineOf, type Recipe, takesOf, trailerCache } from './footage.ts'
|
|
8
8
|
import type { BrandLike, TrailerLook } from './look.ts'
|
|
9
9
|
import { fresh, isTake, type Refilm, recipeWords, type Take, takeIsStill, takesToFilm, type World, worldFor } from './plan.ts'
|
|
10
10
|
import { decodeStates, edgeChanges } from './states.ts'
|
|
11
11
|
import { type CompiledScore, compileScore } from './score/score.ts'
|
|
12
|
-
import { GROW_RES, type Source } from './graph.ts'
|
|
12
|
+
import { GROW_RES, type Source, titleMotions } from './graph.ts'
|
|
13
|
+
import type { TitleMotion } from './motion.ts'
|
|
14
|
+
import type { TitleRequest } from './titles.ts'
|
|
13
15
|
import type { Stitched } from './stitch.ts'
|
|
14
16
|
import { projectCli, readVocab } from './vocab.ts'
|
|
15
17
|
|
|
@@ -85,7 +87,7 @@ export function fileOf(path: string, l: Pick<Loaded, 'dir' | 'root'>): string |
|
|
|
85
87
|
|
|
86
88
|
/** the file parsed and checked against the repo: every error at its line, all at once; the trailer unit's check with
|
|
87
89
|
* it unless `o.check` is false (a cut read only for its keys); `o.fresh` reads the rows and the unit again */
|
|
88
|
-
export async function load(file: string, root: string, o: { text?: string; dir?: string; check?: boolean; fresh?: boolean } = {}): Promise<Loaded> {
|
|
90
|
+
export async function load(file: string, root: string, o: { text?: string; dir?: string; check?: boolean; fresh?: boolean; variant?: string } = {}): Promise<Loaded> {
|
|
89
91
|
const text = o.text ?? readFileSync(file, 'utf8')
|
|
90
92
|
// a watch's build reads the rows and the unit again: a bumped version or an edited unit keys as a fresh run would
|
|
91
93
|
if (o.fresh) {
|
|
@@ -95,21 +97,25 @@ export async function load(file: string, root: string, o: { text?: string; dir?:
|
|
|
95
97
|
const cli = projectCli(root)
|
|
96
98
|
let verbs: FilmVerb[] | null = null
|
|
97
99
|
const errors: TrailerError[] = []
|
|
98
|
-
|
|
100
|
+
const { loadTrailerUnit } = await import('./load-unit.ts')
|
|
101
|
+
const unit = await loadTrailerUnit(root)
|
|
102
|
+
const variant = { ...(o.variant !== undefined ? { variant: o.variant } : {}), ...(unit?.page ? { pageWords: unit.page.words } : {}) }
|
|
103
|
+
let parsed = parseTrailer(text, { verbs: null, ...variant })
|
|
99
104
|
// the project CLI is asked only when a film line does not read as a core film
|
|
100
105
|
const lines = text.split('\n')
|
|
101
106
|
if (cli !== null && parsed.errors.some((e) => /^\s*film\s/.test(lines[e.line - 1] ?? ''))) {
|
|
102
107
|
try {
|
|
103
108
|
verbs = readVocab(root, cli)
|
|
104
|
-
parsed = parseTrailer(text, { verbs })
|
|
109
|
+
parsed = parseTrailer(text, { verbs, ...variant })
|
|
105
110
|
} catch (e) {
|
|
106
111
|
errors.push({ line: 1, message: (e as Error).message })
|
|
107
112
|
}
|
|
108
113
|
}
|
|
109
114
|
const t = parsed.trailer
|
|
110
|
-
const {
|
|
111
|
-
const l: Loaded = { root, file, dir: o.dir ?? dirname(file), t, errors: [...errors, ...parsed.errors], warnings: [], cli, verbs, score: null, recipes: new Map(), unit: await loadTrailerUnit(root) }
|
|
115
|
+
const l: Loaded = { root, file, dir: o.dir ?? dirname(file), t, errors: [...errors, ...parsed.errors], warnings: [], cli, verbs, score: null, recipes: new Map(), unit }
|
|
112
116
|
l.errors.push(...findRecipes(l))
|
|
117
|
+
// the brand is read only for a cut that names one of its text styles: a check stays quick
|
|
118
|
+
if (namedStyles(t).length > 0) l.errors.push(...resolveStyles(t, (await brandStyles(root)) ?? {}))
|
|
113
119
|
if (l.errors.length === 0) l.errors.push(...checkFiles(l))
|
|
114
120
|
if (l.errors.length === 0 && t.score) {
|
|
115
121
|
const c = compileScore(t.score, t.sections, trailerCache(root, 'score'))
|
|
@@ -165,7 +171,7 @@ export function checkInput(l: Loaded): TrailerCheckInput {
|
|
|
165
171
|
fps: l.t.fps,
|
|
166
172
|
films: [...l.t.shots, ...l.t.footage].flatMap((s) => (s.film?.kind === 'cli' ? [filmLineOf(s, s.film, l)] : [])),
|
|
167
173
|
music: [l.t.music, ...l.t.shots.map((s) => s.music)].flatMap((m) =>
|
|
168
|
-
m && m.song !== NO_MUSIC && !isFileWord(m.song) ? [{ line: m.line, id: m.song, ...(m.take ? { take: m.take } : {}), from: m.from, opening: m === l.t.music }] : [],
|
|
174
|
+
m && m.song !== NO_MUSIC && !m.prompt && !m.run && !isFileWord(m.song) ? [{ line: m.line, id: m.song, ...(m.take ? { take: m.take } : {}), from: m.from, opening: m === l.t.music }] : [],
|
|
169
175
|
),
|
|
170
176
|
sfx: ids.filter((x) => x.kind === 'sfx').map((x) => ({ line: x.line, id: x.id })),
|
|
171
177
|
voices: ids.filter((x) => x.kind === 'voice').map((x) => ({ line: x.line, text: x.id })),
|
|
@@ -188,6 +194,56 @@ export async function worldOf(l: Pick<Loaded, 'root' | 'unit'>): Promise<(tk: Ta
|
|
|
188
194
|
}
|
|
189
195
|
}
|
|
190
196
|
|
|
197
|
+
/** the brand's text styles, null with no brand */
|
|
198
|
+
async function brandStyles(root: string): Promise<Record<string, string> | null> {
|
|
199
|
+
const brand = await (await import('../assets/run-assets.ts')).loadBrand(root)
|
|
200
|
+
if (typeof brand === 'string') throw new Refused(brand)
|
|
201
|
+
return brand?.trailer?.styles ?? null
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** what a title's `font` word names: a font file found beside the .trailer or from the repo root, else a Google
|
|
205
|
+
* Fonts family at its weight (400 unless it says) */
|
|
206
|
+
function fontOf(l: Pick<Loaded, 'dir' | 'root'>, ti: Title): { file: string | null; family?: string; weight: number } | null {
|
|
207
|
+
const f = ti.fx?.font
|
|
208
|
+
if (f === undefined) return null
|
|
209
|
+
const weight = ti.fx?.weight ?? 400
|
|
210
|
+
return /\.(ttf|otf|woff2?)$/i.test(f) ? { file: fileOf(f, l), weight } : { file: null, family: f, weight }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** the font file a title draws with, fetched from Google Fonts once when it names a family; a family Google lacks
|
|
214
|
+
* fails at the title's line */
|
|
215
|
+
async function titleFont(l: Loaded, ti: Title): Promise<string | null> {
|
|
216
|
+
const f = fontOf(l, ti)
|
|
217
|
+
if (!f) return null
|
|
218
|
+
if (f.file) return f.file
|
|
219
|
+
if (!f.family) throw new Refused(`${relative(l.root, l.file)}:${ti.line}: title font ${ti.fx?.font}: no such font file beside the .trailer or from the repo root`)
|
|
220
|
+
const { googleFont } = await import('./fonts.ts')
|
|
221
|
+
try {
|
|
222
|
+
return await googleFont(f.family, f.weight)
|
|
223
|
+
} catch (e) {
|
|
224
|
+
throw new Refused(`${relative(l.root, l.file)}:${ti.line}: ${(e as Error).message}`)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** what a title draws, but its font file (titleFont, or googleFile where nothing is fetched) */
|
|
229
|
+
export function titleRequest(l: Pick<Loaded, 'root' | 't'>, ti: Title, look: TrailerLook, motion: TitleMotion): TitleRequest {
|
|
230
|
+
return { root: l.root, text: ti.text, style: ti.style, size: l.t.size, place: placeOf(ti), look, motion, ...(ti.step ? { step: ti.step } : {}), ...(ti.fx ? { fx: ti.fx } : {}) }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** one line per font a title names: a file found, a Google Fonts family cached or to fetch */
|
|
234
|
+
export async function fontLines(l: Loaded): Promise<string[]> {
|
|
235
|
+
const seen = new Map<string, string>()
|
|
236
|
+
for (const s of l.t.shots)
|
|
237
|
+
for (const ti of s.titles) {
|
|
238
|
+
const f = fontOf(l, ti)
|
|
239
|
+
if (!f || f.file) continue
|
|
240
|
+
const { googleFile } = await import('./fonts.ts')
|
|
241
|
+
const k = `${f.family} ${f.weight}`
|
|
242
|
+
if (!seen.has(k)) seen.set(k, `font "${f.family}" ${f.weight}: ${existsSync(googleFile(f.family!, f.weight)) ? 'cached' : 'to fetch from Google Fonts'}`)
|
|
243
|
+
}
|
|
244
|
+
return [...seen.values()]
|
|
245
|
+
}
|
|
246
|
+
|
|
191
247
|
/** the clips, images and sound files the lines name, each found where it should be */
|
|
192
248
|
function checkFiles(l: Loaded): TrailerError[] {
|
|
193
249
|
const out: TrailerError[] = []
|
|
@@ -196,26 +252,104 @@ function checkFiles(l: Loaded): TrailerError[] {
|
|
|
196
252
|
}
|
|
197
253
|
for (const s of l.t.shots) {
|
|
198
254
|
if (s.film?.kind === 'clip' || s.film?.kind === 'image') need(s.film.line, `film ${s.film.kind}`, s.film.path)
|
|
199
|
-
for (const
|
|
255
|
+
for (const ti of s.titles) if (ti.fx?.font && /\.(ttf|otf|woff2?)$/i.test(ti.fx.font)) need(ti.line, 'title font', ti.fx.font)
|
|
256
|
+
for (const x of s.sfx) if (isFileSound(x.id, x)) need(x.line, 'sfx', x.id)
|
|
200
257
|
for (const v of s.voices) if (v.file) need(v.line, 'voice file', v.file)
|
|
258
|
+
for (const x of [...s.sfx, ...s.voices]) for (const f of x.run?.reads ?? []) need(x.line, `${'id' in x ? 'sfx' : 'voice'} run reads`, f)
|
|
259
|
+
}
|
|
260
|
+
for (const m of [l.t.music, ...l.t.shots.map((s) => s.music)]) {
|
|
261
|
+
if (m && m.song !== NO_MUSIC && isFileSound(m.song, m)) need(m.line, 'music', m.song)
|
|
262
|
+
for (const f of m?.run?.reads ?? []) need(m!.line, 'music run reads', f)
|
|
201
263
|
}
|
|
202
|
-
for (const m of [l.t.music, ...l.t.shots.map((s) => s.music)]) if (m && m.song !== NO_MUSIC && isFileWord(m.song)) need(m.line, 'music', m.song)
|
|
203
264
|
const v = l.t.vertical
|
|
204
265
|
if (v && typeof v.outro === 'object') need(v.line, 'vertical outro', v.outro.clip)
|
|
205
266
|
return out
|
|
206
267
|
}
|
|
207
268
|
|
|
208
|
-
/**
|
|
269
|
+
/** a sound word that names a file: never a quoted prompt nor a run's */
|
|
270
|
+
const isFileSound = (w: string, x: { prompt?: true; run?: unknown }): boolean => !x.prompt && !x.run && isFileWord(w)
|
|
271
|
+
|
|
272
|
+
/** the ids that are not files, prompts nor runs: what the repo's trailer unit resolves (else the house) */
|
|
209
273
|
export function soundIds(t: Trailer): { line: number; kind: 'music' | 'sfx' | 'voice'; id: string }[] {
|
|
274
|
+
const id = (x: { prompt?: true; run?: unknown }): boolean => !x.prompt && !x.run
|
|
210
275
|
return [
|
|
211
|
-
...[t.music, ...t.shots.map((s) => s.music)].flatMap((m) => (m && m.song !== NO_MUSIC && !isFileWord(m.song) ? [{ line: m.line, kind: 'music' as const, id: m.song }] : [])),
|
|
276
|
+
...[t.music, ...t.shots.map((s) => s.music)].flatMap((m) => (m && m.song !== NO_MUSIC && id(m) && !isFileWord(m.song) ? [{ line: m.line, kind: 'music' as const, id: m.song }] : [])),
|
|
212
277
|
...t.shots.flatMap((s) => [
|
|
213
|
-
...s.sfx.filter((x) => !isFileWord(x.id)).map((x) => ({ line: x.line, kind: 'sfx' as const, id: x.id })),
|
|
214
|
-
...s.voices.filter((v) => v.text !== undefined).map((v) => ({ line: v.line, kind: 'voice' as const, id: v.text! })),
|
|
278
|
+
...s.sfx.filter((x) => id(x) && !isFileWord(x.id)).map((x) => ({ line: x.line, kind: 'sfx' as const, id: x.id })),
|
|
279
|
+
...s.voices.filter((v) => id(v) && v.text !== undefined).map((v) => ({ line: v.line, kind: 'voice' as const, id: v.text! })),
|
|
215
280
|
]),
|
|
216
281
|
]
|
|
217
282
|
}
|
|
218
283
|
|
|
284
|
+
/** a sound that is no plain file: the repo's unit first (an id it knows), else the house's maker (a prompt, a run, a
|
|
285
|
+
* `.mid`, an id the unit answers null) */
|
|
286
|
+
export async function soundFile(l: Pick<Loaded, 'root' | 'file' | 'unit'>, req: SoundRequest, say: Say): Promise<string> {
|
|
287
|
+
const unit = l.unit
|
|
288
|
+
const own = houseOnly(req) ? null : req.kind === 'music' ? unit?.sound?.music && ((x: string) => unit.sound!.music!(x, { ...(req.take ? { take: req.take } : {}), seconds: req.seconds ?? 0 })) : req.kind === 'sfx' ? unit?.sound?.sfx : unit?.sound?.voice
|
|
289
|
+
const got = own ? await own(req.text) : null
|
|
290
|
+
if (got) return got
|
|
291
|
+
const house = await houseSound()
|
|
292
|
+
if (!house) throw new Refused(`${relative(l.root, l.file)}:${req.line}: ${req.kind} ${req.run ? 'run' : req.prompt ? `"${req.text}"` : req.text} is no file, and neither a trailer unit (a <name>.shipkit.ts default-exporting trailer({ sound })) nor the house's sound maker makes it`)
|
|
293
|
+
return house.resolveSound(l.root, req, say)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** a request the repo's unit never sees: a prompt, a run, a `.mid` */
|
|
297
|
+
const houseOnly = (r: SoundRequest): boolean => r.prompt === true || r.run !== undefined || r.text.endsWith('.mid')
|
|
298
|
+
|
|
299
|
+
/** a sound line as the house reads it */
|
|
300
|
+
export function soundRequest(kind: SoundRequest['kind'], text: string, x: { prompt?: true; gen?: SoundGen; run?: SoundRun; line: number }, dir: string, seconds?: number, take?: string): SoundRequest {
|
|
301
|
+
const g = x.gen ?? {}
|
|
302
|
+
return {
|
|
303
|
+
kind,
|
|
304
|
+
text,
|
|
305
|
+
...(g.seed !== undefined ? { seed: g.seed } : {}),
|
|
306
|
+
...(g.by ? { by: g.by } : {}),
|
|
307
|
+
...(g.for !== undefined ? { for: g.for } : {}),
|
|
308
|
+
...(g.hit ? { hit: true } : {}),
|
|
309
|
+
...(seconds !== undefined ? { seconds } : {}),
|
|
310
|
+
line: x.line,
|
|
311
|
+
...(x.run ? { run: x.run } : {}),
|
|
312
|
+
dir,
|
|
313
|
+
...(x.prompt ? { prompt: true } : {}),
|
|
314
|
+
...(take ? { take } : {}),
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** the sounds of the cut the house makes: every prompt, run and `.mid`, and every id whose kind the unit has no
|
|
319
|
+
* function for (an id the unit answers null is known only when it answers) */
|
|
320
|
+
export function houseRequests(l: Loaded): SoundRequest[] {
|
|
321
|
+
const s = l.unit?.sound
|
|
322
|
+
const out: SoundRequest[] = []
|
|
323
|
+
const add = (r: SoundRequest): void => void (houseOnly(r) || !(r.kind === 'music' ? s?.music : r.kind === 'sfx' ? s?.sfx : s?.voice) ? out.push(r) : undefined)
|
|
324
|
+
for (const { cue, at, end } of musicCues(l.t)) if (cue.song !== SCORE && (!isFileSound(cue.song, cue) || cue.song.endsWith('.mid'))) add(soundRequest('music', cue.song.endsWith('.mid') && !cue.prompt && !cue.run ? (fileOf(cue.song, l) ?? cue.song) : cue.song, cue, l.dir, cue.from + end - at, cue.take))
|
|
325
|
+
for (const sh of l.t.shots) {
|
|
326
|
+
for (const x of sh.sfx) if (!isFileSound(x.id, x)) add(soundRequest('sfx', x.id, x, l.dir))
|
|
327
|
+
for (const v of sh.voices) if (!v.file) add(soundRequest('voice', v.text ?? 'run', v, l.dir))
|
|
328
|
+
}
|
|
329
|
+
return out
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** one line per sound the house makes for this cut (its command, made or to make, a paid voice's characters), from
|
|
333
|
+
* the house's `soundStates`; none until it exists. A failure of the house is said, never thrown past the check */
|
|
334
|
+
export async function soundLines(l: Loaded): Promise<string[]> {
|
|
335
|
+
const p = join(import.meta.dir, 'sound', 'index.ts')
|
|
336
|
+
const reqs = houseRequests(l)
|
|
337
|
+
if (!existsSync(p) || reqs.length === 0) return []
|
|
338
|
+
try {
|
|
339
|
+
const m = (await import(p)) as { soundStates?: (root: string, rs: SoundRequest[]) => Promise<unknown[]>; stateLine?: (s: unknown) => string }
|
|
340
|
+
if (!m.soundStates) return []
|
|
341
|
+
return (await m.soundStates(l.root, reqs)).map((s) => (m.stateLine ? m.stateLine(s) : JSON.stringify(s)))
|
|
342
|
+
} catch (e) {
|
|
343
|
+
return [`WARN the house's sound states failed: ${(e as Error).message}`]
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** the house's sound maker (design call 9, src/trailer/sound/index.ts), null until it exists */
|
|
348
|
+
async function houseSound(): Promise<{ resolveSound(root: string, req: SoundRequest, say: Say): Promise<string> } | null> {
|
|
349
|
+
const p = join(import.meta.dir, 'sound', 'index.ts')
|
|
350
|
+
return existsSync(p) ? ((await import(p)) as { resolveSound(root: string, req: SoundRequest, say: Say): Promise<string> }) : null
|
|
351
|
+
}
|
|
352
|
+
|
|
219
353
|
/** the errors, which stop the build, then the warnings, which do not */
|
|
220
354
|
export function sayErrors(l: Loaded, say: Say, cwd: string): boolean {
|
|
221
355
|
for (const e of l.errors) say(`${relative(cwd, l.file)}:${e.line}: ${e.message}`)
|
|
@@ -234,6 +368,7 @@ const filmText = (f: Film | null): string => {
|
|
|
234
368
|
if (f.kind === 'run') return `run "${f.cmd}"${f.still ? ' still' : ''}`
|
|
235
369
|
if (f.kind === 'cli') return f.argv.join(' ')
|
|
236
370
|
if (f.kind === 'url') return `url ${f.route}${f.viewport ? ` viewport ${f.viewport.join('x')}` : ''}${f.still ? ' still' : ''}`
|
|
371
|
+
if (f.kind === 'page') return `page ${f.route}${f.query.length ? ` ${f.query.join(' ')}` : ''}, stepped by ${f.step}${f.still ? ', still' : ''}`
|
|
237
372
|
if (f.kind === 'grid') return `grid of ${f.cells.join(', ')}${f.pop ? `, a cell every ${secs(f.pop)}` : ''}${f.grow ? `, growing to ${f.grow.map(secs).join(' and ')}` : ''}`
|
|
238
373
|
if (f.kind === 'split') return `split of ${f.cells.join(', ')}, seams at ${f.angle}°${f.slide ? `, sliding in over ${secs(f.slide)}` : ''}`
|
|
239
374
|
return 'card'
|
|
@@ -369,7 +504,7 @@ export async function scoreFile(l: Loaded, seconds: number, say: Say): Promise<s
|
|
|
369
504
|
/** everything the stitch reads but its output: sources, titles, sounds, the look */
|
|
370
505
|
async function stitchInput(l: Loaded, takes: readonly Take[], shots: readonly Shot[], silent: boolean, say: Say = () => undefined) {
|
|
371
506
|
const { gridMask, splitMasks } = await import('./stitch.ts')
|
|
372
|
-
const [{ cardPng,
|
|
507
|
+
const [{ cardPng, titlePng }, { lookOf }, { durationOf }, { trailerCache }, { loadBrand, identityLogo }] = await Promise.all([
|
|
373
508
|
import('./titles.ts'),
|
|
374
509
|
import('./look.ts'),
|
|
375
510
|
import('./ffmpeg.ts'),
|
|
@@ -399,32 +534,33 @@ async function stitchInput(l: Loaded, takes: readonly Take[], shots: readonly Sh
|
|
|
399
534
|
if (f.kind === 'image') return { image: fileOf(f.path, l)! }
|
|
400
535
|
if (f.kind === 'clip') {
|
|
401
536
|
const file = fileOf(f.path, l)!
|
|
402
|
-
return { footage: file, seconds:
|
|
537
|
+
return { footage: file, seconds: await durationOf(file) }
|
|
403
538
|
}
|
|
404
539
|
const tk = takeOf(s)!
|
|
405
540
|
return tk.stamp!.seconds === 0 ? { image: tk.file } : { footage: tk.file, seconds: tk.stamp!.seconds }
|
|
406
541
|
}))
|
|
407
|
-
const
|
|
542
|
+
const styleErrors = resolveStyles(l.t, brand?.trailer?.styles ?? {})
|
|
543
|
+
if (styleErrors.length > 0) throw new Refused(styleErrors.map((e) => `${relative(l.root, l.file)}:${e.line}: ${e.message}`).join('\n'))
|
|
544
|
+
const motions = titleMotions({ ...l.t, shots: [...shots] })
|
|
545
|
+
const drawn = await Promise.all(shots.map((s, i) => Promise.all(s.titles.map(async (ti, j) => titlePng(titles, { ...titleRequest(l, ti, look, motions[i]![j]!), font: await titleFont(l, ti) })))))
|
|
408
546
|
if (silent) return { ...base, sources, titles: drawn, music: [], sfx: shots.map(() => []), voices: shots.map(() => []) }
|
|
409
|
-
const
|
|
410
|
-
const
|
|
411
|
-
const resolveId = async (kind: 'music' | 'sfx' | 'voice', id: string, line: number, seconds = 0, take?: string): Promise<string> => {
|
|
412
|
-
const f = kind === 'music' ? unit?.sound?.music && ((x: string) => unit.sound!.music!(x, { take, seconds })) : kind === 'sfx' ? unit?.sound?.sfx : unit?.sound?.voice
|
|
413
|
-
if (!f) throw new Refused(`${relative(l.root, l.file)}:${line}: ${kind} ${id} is no file, and no trailer unit resolves ${kind} ids (a <name>.shipkit.ts default-exporting trailer({ sound }))`)
|
|
414
|
-
return f(id)
|
|
415
|
-
}
|
|
547
|
+
const resolve = (req: SoundRequest): Promise<string> => soundFile(l, req, say)
|
|
548
|
+
const ask = (kind: SoundRequest['kind'], text: string, x: { prompt?: true; gen?: SoundGen; run?: SoundRun; line: number }, seconds?: number, take?: string): SoundRequest => soundRequest(kind, text, x, l.dir, seconds, take)
|
|
416
549
|
const music = await Promise.all(
|
|
417
|
-
musicCues(l.t).map(({ cue, at, end }) =>
|
|
418
|
-
cue.song === SCORE
|
|
419
|
-
|
|
550
|
+
musicCues(l.t).map(({ cue, at, end }) => {
|
|
551
|
+
if (cue.song === SCORE) return scoreFile(l, cue.from + end - at, say)
|
|
552
|
+
const seconds = cue.from + end - at
|
|
553
|
+
if (isFileSound(cue.song, cue)) return cue.song.endsWith('.mid') ? resolve(ask('music', fileOf(cue.song, l)!, cue, seconds)) : fileOf(cue.song, l)!
|
|
554
|
+
return resolve(ask('music', cue.song, cue, seconds, cue.take))
|
|
555
|
+
}),
|
|
420
556
|
)
|
|
421
557
|
const sfx = await Promise.all(shots.map((s) => Promise.all(s.sfx.map(async (x) => {
|
|
422
|
-
const file =
|
|
423
|
-
return { file, seconds:
|
|
558
|
+
const file = isFileSound(x.id, x) ? fileOf(x.id, l)! : await resolve(ask('sfx', x.id, x))
|
|
559
|
+
return { file, seconds: await durationOf(file) }
|
|
424
560
|
}))))
|
|
425
561
|
const voices = await Promise.all(shots.map((s) => Promise.all(s.voices.map(async (v) => {
|
|
426
|
-
const file = v.file ? fileOf(v.file, l)! : await
|
|
427
|
-
return { file, seconds:
|
|
562
|
+
const file = v.file ? fileOf(v.file, l)! : await resolve(ask('voice', v.text ?? 'run', v))
|
|
563
|
+
return { file, seconds: await durationOf(file) }
|
|
428
564
|
}))))
|
|
429
565
|
return { ...base, sources, titles: drawn, music, sfx, voices }
|
|
430
566
|
}
|
|
@@ -519,7 +655,15 @@ export async function build(l: Loaded, o: BuildOptions): Promise<string> {
|
|
|
519
655
|
const done = await stitch({ ...input, t: l.t, out: cut, cache: trailerCache(l.root, 'pieces'), encoder: o.final ? 'final' : 'draft' })
|
|
520
656
|
const finished = l.t.vertical ? await finishVertical(l, input, done, cut, out, o.final) : null
|
|
521
657
|
const stripFile = join(outDir(l), `${l.t.name}.strip.png`)
|
|
522
|
-
|
|
658
|
+
// the strip is drawn from the pieces' thumbs: the same pieces draw the same strip, so a build that rendered no
|
|
659
|
+
// section keeps it
|
|
660
|
+
const stripKey = join(trailerCache(l.root, 'pieces'), `${l.t.name}.strip.key`)
|
|
661
|
+
const key = done.pieces.map((p) => p.thumbs).join('\n')
|
|
662
|
+
const drawn = (): string => `${key}\n${statSync(stripFile).mtimeMs}`
|
|
663
|
+
if (!existsSync(stripFile) || !existsSync(stripKey) || readFileSync(stripKey, 'utf8') !== drawn()) {
|
|
664
|
+
await strip(done.pieces, stripFile)
|
|
665
|
+
writeFileSync(stripKey, drawn())
|
|
666
|
+
}
|
|
523
667
|
writeFileSync(join(outDir(l), `${l.t.name}.montage.md`), montage(l.t, basename(l.file)))
|
|
524
668
|
for (const line of timeline(l, takes, world, o.cwd)) o.say(line)
|
|
525
669
|
o.say(`ok: ${out} (${(finished?.seconds ?? total(l.t)).toFixed(2)} s) in ${((performance.now() - t0) / 1000).toFixed(1)} s: filming ${((tFilm - t0) / 1000).toFixed(1)} s, ${rendered(done)}${finished ? `, ${finished.note}` : ''}`)
|
|
@@ -579,8 +723,13 @@ export async function auditionScore(l: Loaded, o: BuildOptions): Promise<void> {
|
|
|
579
723
|
`[top][sp]vstack,${marks.join(',')}`,
|
|
580
724
|
].join(';')
|
|
581
725
|
await ffmpeg(['-i', wav, '-filter_complex', graph, '-frames:v', '1', png])
|
|
726
|
+
// the score as General MIDI, for a DAW or a soundfont (score/midi.ts), once it exists
|
|
727
|
+
const midi = join(import.meta.dir, 'score', 'midi.ts')
|
|
728
|
+
const mid = join(outDir(l), `${l.t.name}.score.mid`)
|
|
729
|
+
if (existsSync(midi)) writeFileSync(mid, ((await import(midi)) as { scoreMidi(s: CompiledScore): Uint8Array }).scoreMidi(sc))
|
|
582
730
|
o.say(`ok: ${wav} (${span.toFixed(1)} s) in ${((performance.now() - t0) / 1000).toFixed(1)} s`)
|
|
583
731
|
o.say(`ok: ${png} (the waveform over the spectrum; red: each section's start${sc.stop !== null ? ', yellow: the stop' : ''})`)
|
|
732
|
+
if (existsSync(mid)) o.say(`ok: ${mid} (the score as General MIDI)`)
|
|
584
733
|
}
|
|
585
734
|
|
|
586
735
|
/** one shot alone with its titles, no sound: out/shot-<name>.mp4 and its nine frames on out/shot-<name>.png */
|
package/src/trailer/ffmpeg.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// the one way the trailer spawns ffmpeg and ffprobe: a run that fails loud with ffmpeg's own words, a probe, the nine
|
|
2
2
|
// frame sheet. The binaries resolve once, with the homebrew path for processes started without it
|
|
3
3
|
import { spawn } from 'node:child_process'
|
|
4
|
-
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
5
5
|
import { basename } from 'node:path'
|
|
6
6
|
|
|
7
7
|
const FFMPEG = Bun.which('ffmpeg') ?? '/opt/homebrew/bin/ffmpeg'
|
|
@@ -38,6 +38,26 @@ export function duration(file: string): number {
|
|
|
38
38
|
return d
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
const probed = new Map<string, Promise<number>>()
|
|
42
|
+
|
|
43
|
+
/** a media file's length like `duration`, probed once per file and state in this process, the probes side by side: a
|
|
44
|
+
* cut that plays one sfx on many lines asks once */
|
|
45
|
+
export function durationOf(file: string): Promise<number> {
|
|
46
|
+
const st = statSync(file)
|
|
47
|
+
const k = `${file}\0${st.size}\0${st.mtimeMs}`
|
|
48
|
+
const had = probed.get(k)
|
|
49
|
+
if (had) return had
|
|
50
|
+
const p = (async () => {
|
|
51
|
+
const r = Bun.spawn([FFPROBE, '-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', file], { stdout: 'pipe', stderr: 'pipe' })
|
|
52
|
+
const [out, err, code] = await Promise.all([new Response(r.stdout).text(), new Response(r.stderr).text(), r.exited])
|
|
53
|
+
const d = Number(out.trim())
|
|
54
|
+
if (code !== 0 || !(d > 0)) throw new Error(`ffprobe ${file}: ${err.trim() || 'no length'}`)
|
|
55
|
+
return d
|
|
56
|
+
})()
|
|
57
|
+
probed.set(k, p)
|
|
58
|
+
return p
|
|
59
|
+
}
|
|
60
|
+
|
|
41
61
|
/** the media file carries a sound stream */
|
|
42
62
|
export function hasAudio(file: string): boolean {
|
|
43
63
|
const r = Bun.spawnSync([FFPROBE, '-v', 'error', '-select_streams', 'a', '-show_entries', 'stream=index', '-of', 'csv=p=0', file], { stdout: 'pipe', stderr: 'pipe' })
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// a title's face: a font file of the repo, or a Google Fonts family fetched once per weight into the machine's cache
|
|
2
|
+
// (every repo shares it). Each face is drawn under a name of its own, and one that does not register fails loud: a
|
|
3
|
+
// title never falls back to another face in silence
|
|
4
|
+
import { GlobalFonts } from '@napi-rs/canvas'
|
|
5
|
+
import { existsSync, mkdirSync, renameSync, writeFileSync } from 'node:fs'
|
|
6
|
+
import { homedir } from 'node:os'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
|
|
9
|
+
/** a `font` word that names a file; any other names a Google Fonts family */
|
|
10
|
+
export const isFontFile = (w: string): boolean => /\.(ttf|otf|woff2?)$/i.test(w)
|
|
11
|
+
|
|
12
|
+
export const fontCache = (): string => process.env.SHIPKIT_FONT_CACHE || join(homedir(), '.cache', 'shipkit', 'fonts')
|
|
13
|
+
|
|
14
|
+
const slug = (family: string): string => family.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
|
15
|
+
|
|
16
|
+
/** where a Google Fonts family at a weight is kept */
|
|
17
|
+
export const googleFile = (family: string, weight: number): string => join(fontCache(), `${slug(family)}-${weight}.ttf`)
|
|
18
|
+
|
|
19
|
+
/** a Google Fonts family at a weight, fetched when the cache lacks it: the css answer names a truetype file to a plain
|
|
20
|
+
* agent */
|
|
21
|
+
export async function googleFont(family: string, weight: number): Promise<string> {
|
|
22
|
+
const file = googleFile(family, weight)
|
|
23
|
+
if (existsSync(file)) return file
|
|
24
|
+
const url = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family).replaceAll('%20', '+')}:wght@${weight}`
|
|
25
|
+
const css = await fetch(url)
|
|
26
|
+
if (!css.ok) throw new Error(`font "${family}" weight ${weight}: Google Fonts has no such family at that weight (HTTP ${css.status}); check its name and weights on fonts.google.com, or name a font file of the repo (.ttf .otf .woff .woff2)`)
|
|
27
|
+
const src = /url\((https:[^)]+)\)/.exec(await css.text())?.[1]
|
|
28
|
+
if (!src) throw new Error(`font "${family}" weight ${weight}: Google Fonts answered with no font file (${url})`)
|
|
29
|
+
const got = await fetch(src)
|
|
30
|
+
if (!got.ok) throw new Error(`font "${family}" weight ${weight}: its file did not download (HTTP ${got.status}, ${src})`)
|
|
31
|
+
mkdirSync(fontCache(), { recursive: true })
|
|
32
|
+
const part = `${file}.${process.pid}.part`
|
|
33
|
+
writeFileSync(part, Buffer.from(await got.arrayBuffer()))
|
|
34
|
+
renameSync(part, file)
|
|
35
|
+
return file
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const registered = new Map<string, string>()
|
|
39
|
+
|
|
40
|
+
/** the name a face file is drawn under, its own: two files never share a family, and a file that does not load fails */
|
|
41
|
+
export function registerFont(abs: string): string {
|
|
42
|
+
const had = registered.get(abs)
|
|
43
|
+
if (had) return had
|
|
44
|
+
if (!existsSync(abs)) throw new Error(`the font file ${abs} is missing`)
|
|
45
|
+
const name = `F${new Bun.CryptoHasher('sha1').update(abs).digest('hex').slice(0, 12)}`
|
|
46
|
+
GlobalFonts.registerFromPath(abs, name)
|
|
47
|
+
if (!GlobalFonts.has(name)) throw new Error(`the font file ${abs} does not load: a .ttf, .otf, .woff or .woff2 face`)
|
|
48
|
+
registered.set(abs, name)
|
|
49
|
+
return name
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** a family the machine draws, never a stand in: a name it lacks fails loud */
|
|
53
|
+
export function hasFamily(family: string): boolean {
|
|
54
|
+
return family.split(',').some((f) => GlobalFonts.has(f.trim().replace(/^["']|["']$/g, '')))
|
|
55
|
+
}
|
package/src/trailer/footage.ts
CHANGED
|
@@ -115,7 +115,7 @@ export async function filmTake(tk: Take, c: FilmContext): Promise<Take> {
|
|
|
115
115
|
const still = takeIsStill(f)
|
|
116
116
|
let said = ''
|
|
117
117
|
for (let attempt = 1; ; attempt++) {
|
|
118
|
-
said = f.kind === 'url' ? await urlTake(tk, f, part, seconds, c) : await commandTake(tk, f, part, seconds, c)
|
|
118
|
+
said = f.kind === 'url' || f.kind === 'page' ? await urlTake(tk, f, part, seconds, c) : await commandTake(tk, f, part, seconds, c)
|
|
119
119
|
if (still || !c.holes) break
|
|
120
120
|
const holes = await (await import('./holes.ts')).takeHoles(part)
|
|
121
121
|
if (holes.length === 0) break
|
|
@@ -143,7 +143,7 @@ export async function filmTake(tk: Take, c: FilmContext): Promise<Take> {
|
|
|
143
143
|
}
|
|
144
144
|
|
|
145
145
|
/** a take a command writes at `part` (a `film run`, a project CLI row); what it said, or a throw with its last lines */
|
|
146
|
-
async function commandTake(tk: Take, f: Exclude<TakeFilm, { kind: 'url' }>, part: string, seconds: number | null, c: FilmContext): Promise<string> {
|
|
146
|
+
async function commandTake(tk: Take, f: Exclude<TakeFilm, { kind: 'url' | 'page' }>, part: string, seconds: number | null, c: FilmContext): Promise<string> {
|
|
147
147
|
if (f.kind === 'cli' && c.cli === null) throw new Error(`shot ${tk.shot.name} (line ${f.line}): film ${f.argv.join(' ')} needs the project CLI (the cli script of package.json)`)
|
|
148
148
|
const argv = f.kind === 'run' ? ['sh', '-c', runCommand(f, { out: part, seconds, size: c.t.size, fps: c.t.fps })] : cliArgv(c.cli!, f, part, seconds, tk.argv)
|
|
149
149
|
// the cut's frame and rate: a row that films at the cut's size reads them
|
|
@@ -160,7 +160,7 @@ async function commandTake(tk: Take, f: Exclude<TakeFilm, { kind: 'url' }>, part
|
|
|
160
160
|
}
|
|
161
161
|
|
|
162
162
|
/** a `film url` take through the house shot service; a throw names the shot, its line and the service's reason */
|
|
163
|
-
async function urlTake(tk: Take, f: Extract<TakeFilm, { kind: 'url' }>, part: string, seconds: number | null, c: FilmContext): Promise<string> {
|
|
163
|
+
async function urlTake(tk: Take, f: Extract<TakeFilm, { kind: 'url' | 'page' }>, part: string, seconds: number | null, c: FilmContext): Promise<string> {
|
|
164
164
|
const { filmUrl } = await import('./url.ts')
|
|
165
165
|
try {
|
|
166
166
|
const note = await filmUrl(f, { root: c.root, base: c.t.base, size: c.t.size, fps: c.t.fps, seconds, out: part, say: c.say })
|
|
@@ -168,6 +168,6 @@ async function urlTake(tk: Take, f: Extract<TakeFilm, { kind: 'url' }>, part: st
|
|
|
168
168
|
return note
|
|
169
169
|
} catch (e) {
|
|
170
170
|
rmSync(part, { force: true })
|
|
171
|
-
throw new Error(`shot ${tk.shot.name} (line ${f.line}): film
|
|
171
|
+
throw new Error(`shot ${tk.shot.name} (line ${f.line}): film ${f.kind} ${f.route}: ${(e as Error).message}`)
|
|
172
172
|
}
|
|
173
173
|
}
|