rvlib-shipkit 2.3.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -2
- package/package.json +1 -1
- package/src/assets/kits/identity-kit.ts +5 -2
- package/src/cli/cmd_Commands.ts +4 -0
- package/src/cli/cmd_Review.ts +36 -10
- package/src/cli/cmd_Sound.ts +209 -0
- package/src/cli/cmd_Trailer.ts +7 -2
- package/src/cli/kit/verb.ts +11 -3
- package/src/cli.ts +2 -0
- package/src/core/app-icon.ts +10 -6
- package/src/repo-config.ts +3 -0
- package/src/review/format.ts +11 -3
- package/src/review/model.ts +47 -5
- package/src/review/project.ts +20 -4
- package/src/review/routes.ts +12 -1
- package/src/review/store.ts +15 -6
- package/src/review/unit.ts +19 -1
- package/src/serve/hub-app/admin-svc.ts +1 -1
- package/src/serve/hub-app/admin-view.tsx +1 -1
- package/src/serve/hub-app/hash-route.ts +12 -4
- package/src/serve/hub-app/home-st.ts +7 -1
- package/src/serve/hub-app/hub.css +38 -1
- package/src/serve/hub-app/icons.tsx +6 -0
- package/src/serve/hub-app/layout-st.ts +2 -2
- package/src/serve/hub-app/md.ts +32 -0
- package/src/serve/hub-app/project-list.css +9 -7
- package/src/serve/hub-app/project-list.tsx +5 -2
- package/src/serve/hub-app/projects-svc.ts +3 -2
- package/src/serve/hub-app/repo-st.ts +22 -7
- package/src/serve/hub-app/review/clip-st.ts +57 -7
- package/src/serve/hub-app/review/kind-view.tsx +78 -9
- package/src/serve/hub-app/review/review-answer.tsx +83 -53
- package/src/serve/hub-app/review/review-page.tsx +9 -14
- package/src/serve/hub-app/review/review-st.ts +141 -18
- package/src/serve/hub-app/review/review-views.tsx +154 -9
- package/src/serve/hub-app/review.css +70 -10
- package/src/serve/hub-app/wiki-st.ts +190 -0
- package/src/serve/hub-app/wikis-view.tsx +134 -5
- package/src/serve/hub-runtime.ts +4 -4
- package/src/serve/project-gutter-route.ts +25 -1
- package/src/serve/project-gutter.ts +1 -1
- package/src/serve/serve-hub.ts +7 -0
- package/src/shot/client.ts +17 -5
- package/src/shot/shooter.ts +19 -3
- package/src/shot/shot-ask.ts +9 -1
- package/src/shot/stepped-clip.ts +53 -0
- package/src/trailer/adapter.ts +33 -4
- package/src/trailer/build.ts +170 -29
- package/src/trailer/fonts.ts +55 -0
- package/src/trailer/footage.ts +4 -4
- package/src/trailer/grammar.ts +429 -67
- package/src/trailer/graph.ts +130 -56
- package/src/trailer/look.ts +3 -0
- package/src/trailer/motion.ts +207 -0
- package/src/trailer/plan.ts +2 -2
- package/src/trailer/prune.ts +13 -2
- package/src/trailer/score/compose.ts +18 -2
- package/src/trailer/score/gm-render.ts +43 -0
- package/src/trailer/score/gm.ts +90 -0
- package/src/trailer/score/midi.ts +143 -0
- package/src/trailer/score/pattern.ts +13 -6
- package/src/trailer/score/score.ts +28 -14
- package/src/trailer/score/sound.ts +12 -1
- package/src/trailer/score/synth.ts +24 -2
- package/src/trailer/sound/audio.ts +148 -0
- package/src/trailer/sound/brand.ts +42 -0
- package/src/trailer/sound/comfy.ts +38 -0
- package/src/trailer/sound/eleven.ts +72 -0
- package/src/trailer/sound/fluid.ts +71 -0
- package/src/trailer/sound/gen.ts +139 -0
- package/src/trailer/sound/index.ts +60 -0
- package/src/trailer/sound/presets.ts +69 -0
- package/src/trailer/sound/run.ts +189 -0
- package/src/trailer/titles.ts +451 -235
- package/src/trailer/url.ts +18 -3
- package/src/trailer/vocab.ts +12 -1
- package/src/trailer/words.ts +29 -8
- package/src/wiki/graph-mount.ts +3 -0
- package/src/wiki/graph-page.ts +45 -9
- package/src/wiki/graph-pages.ts +128 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// a clip filmed frame by frame (wiki build/trailer.md, film page): before each frame the page is asked to draw the
|
|
2
|
+
// frame's time, `window[step](t)`, then the shot service waits for its ready mark and two painted frames, takes one
|
|
3
|
+
// screenshot, and ffmpeg encodes the frames in order. The page's own speed never shows: a frame that takes a second to
|
|
4
|
+
// draw sits beside one that took a millisecond. The page owns its clock while it is stepped
|
|
5
|
+
import { mkdirSync } from 'fs'
|
|
6
|
+
import { dirname } from 'path'
|
|
7
|
+
import { domEncodeArgs } from './dom-clip.ts'
|
|
8
|
+
import { type ClipAsk, readMark, waitReady } from './shot-ask.ts'
|
|
9
|
+
|
|
10
|
+
type Cdp = { send(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>> }
|
|
11
|
+
type Page = { evaluate<R, A>(fn: (a: A) => R | Promise<R>, arg: A): Promise<R>; waitForTimeout(ms: number): Promise<void> }
|
|
12
|
+
|
|
13
|
+
/** the frames a stepped clip holds, and each one's time */
|
|
14
|
+
export const stepTimes = (seconds: number, fps: number): number[] => Array.from({ length: Math.max(1, Math.round(seconds * fps)) }, (_, i) => +(i / fps).toFixed(6))
|
|
15
|
+
|
|
16
|
+
/** IN THE PAGE: the hook asked to draw time t; false when the page has no such hook */
|
|
17
|
+
export const stepTo = async ([hook, t]: [string, number]): Promise<boolean> => {
|
|
18
|
+
const f = (window as unknown as Record<string, unknown>)[hook]
|
|
19
|
+
if (typeof f !== 'function') return false
|
|
20
|
+
await (f as (t: number) => unknown)(t)
|
|
21
|
+
await new Promise<void>((ok) => requestAnimationFrame(() => requestAnimationFrame(() => ok())))
|
|
22
|
+
return true
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function stepPage(cdp: Cdp, page: Page, a: ClipAsk & { step: string }): Promise<string> {
|
|
26
|
+
const ffmpeg = Bun.which('ffmpeg') ?? (process.platform === 'darwin' ? '/opt/homebrew/bin/ffmpeg' : null)
|
|
27
|
+
if (ffmpeg == null) throw new Error('ffmpeg is not installed: brew install ffmpeg')
|
|
28
|
+
mkdirSync(dirname(a.out), { recursive: true })
|
|
29
|
+
const times = stepTimes(a.seconds, a.fps)
|
|
30
|
+
const p = Bun.spawn([ffmpeg, ...domEncodeArgs(a.fps, times.length, a.out)], { stdin: 'pipe', stdout: 'ignore', stderr: 'pipe' })
|
|
31
|
+
const stderr = new Response(p.stderr).text()
|
|
32
|
+
const t0 = performance.now()
|
|
33
|
+
let px = ''
|
|
34
|
+
try {
|
|
35
|
+
for (const t of times) {
|
|
36
|
+
if (!(await page.evaluate(stepTo, [a.step, t] as [string, number]))) throw new Error(`the page has no window.${a.step}(t): a stepped page draws the time it is given there`)
|
|
37
|
+
await waitReady(() => page.evaluate(readMark, undefined), 0, { now: () => performance.now(), sleep: (n) => page.waitForTimeout(n) })
|
|
38
|
+
const shot = await cdp.send('Page.captureScreenshot', { format: 'jpeg', quality: 92 })
|
|
39
|
+
const jpeg = Buffer.from(String(shot['data']), 'base64')
|
|
40
|
+
// the frame's pixels, from the jpeg's start of frame
|
|
41
|
+
const sof = jpeg.indexOf(Buffer.from([0xff, 0xc0]))
|
|
42
|
+
if (px === '' && sof >= 0) px = `${jpeg.readUInt16BE(sof + 7)}x${jpeg.readUInt16BE(sof + 5)}`
|
|
43
|
+
p.stdin.write(jpeg)
|
|
44
|
+
await p.stdin.flush()
|
|
45
|
+
}
|
|
46
|
+
} finally {
|
|
47
|
+
await Promise.resolve(p.stdin.end()).catch(() => undefined)
|
|
48
|
+
}
|
|
49
|
+
const err = await stderr
|
|
50
|
+
if ((await p.exited) !== 0) throw new Error(`ffmpeg encode → ${a.out}: ${err.trim().split('\n').slice(-6).join('\n') || `exit ${p.exitCode}`}`)
|
|
51
|
+
const s = (performance.now() - t0) / 1000
|
|
52
|
+
return `${times.length} frames ${px} at ${a.fps} fps, each stepped through window.${a.step}(t) and its ready mark (${(s / times.length * 1000).toFixed(0)} ms a frame), H.264 by ffmpeg`
|
|
53
|
+
}
|
package/src/trailer/adapter.ts
CHANGED
|
@@ -3,15 +3,38 @@
|
|
|
3
3
|
// resolves the sounds that are not files, names the paths a take's world is, checks the content the language cannot
|
|
4
4
|
// know, refines one film line's take, and turns on the guards that read the repo's own words. Discovered like every
|
|
5
5
|
// unit file (load-unit.ts)
|
|
6
|
-
import type { TakeLength } from './grammar.ts'
|
|
6
|
+
import type { SoundRun, TakeLength } from './grammar.ts'
|
|
7
|
+
import { type Words, wordsProblem } from './words.ts'
|
|
7
8
|
|
|
9
|
+
/** the repo's sounds by id; null: not the repo's, the house makes it (src/trailer/sound/, design call 9) */
|
|
8
10
|
export interface TrailerSounds {
|
|
9
11
|
/** a music id to an audio file of at least `seconds`, cached by the repo */
|
|
10
|
-
music?(id: string, o: { take?: string; seconds: number }): Promise<string>
|
|
12
|
+
music?(id: string, o: { take?: string; seconds: number }): Promise<string | null>
|
|
11
13
|
/** an sfx id to an audio file */
|
|
12
|
-
sfx?(id: string): Promise<string>
|
|
14
|
+
sfx?(id: string): Promise<string | null>
|
|
13
15
|
/** a voice line, found by its text, to an audio file */
|
|
14
|
-
voice?(text: string): Promise<string>
|
|
16
|
+
voice?(text: string): Promise<string | null>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** a sound line the house makes (`resolveSound` and `soundStates` of src/trailer/sound/index.ts, the shape it reads):
|
|
20
|
+
* the line read, the prompt, or a `.mid` file's path (`text`), how it is made (`seed`, `by`, `for`, `hit`), what a music
|
|
21
|
+
* cue covers (`seconds`), or the line's own `run` command */
|
|
22
|
+
export interface SoundRequest {
|
|
23
|
+
kind: 'music' | 'sfx' | 'voice'
|
|
24
|
+
text: string
|
|
25
|
+
seed?: number
|
|
26
|
+
by?: string
|
|
27
|
+
for?: number
|
|
28
|
+
hit?: boolean
|
|
29
|
+
seconds?: number
|
|
30
|
+
line: number
|
|
31
|
+
run?: SoundRun
|
|
32
|
+
/** the cut's folder: a `reads` file is found beside it first, then from the repo root */
|
|
33
|
+
dir: string
|
|
34
|
+
/** the text is a quoted prompt, never an id */
|
|
35
|
+
prompt?: boolean
|
|
36
|
+
/** music: the take of a song id */
|
|
37
|
+
take?: string
|
|
15
38
|
}
|
|
16
39
|
|
|
17
40
|
/** a film line of the project CLI as the unit sees it: its words as written, the text of each `@<file>` word */
|
|
@@ -66,6 +89,8 @@ export interface TrailerDef {
|
|
|
66
89
|
states?: { ignore?: string[]; underWay?: string[] }
|
|
67
90
|
/** a clip take that drops its picture for a few frames is filmed again once, then refused (holes.ts) */
|
|
68
91
|
holes?: boolean
|
|
92
|
+
/** the words a `film page` of this repo takes (words.ts): each checked at its line, its query made plain */
|
|
93
|
+
page?: { words: Words }
|
|
69
94
|
}
|
|
70
95
|
|
|
71
96
|
export type TrailerUnit = TrailerDef & { kind: 'trailer' }
|
|
@@ -73,6 +98,10 @@ export type TrailerUnit = TrailerDef & { kind: 'trailer' }
|
|
|
73
98
|
export function trailer(d: TrailerDef): TrailerUnit {
|
|
74
99
|
for (const [k, f] of Object.entries(d.sound ?? {})) if (typeof f !== 'function') throw new Error(`trailer: sound.${k} is a function returning an audio file`)
|
|
75
100
|
for (const k of ['check', 'take'] as const) if (d[k] !== undefined && typeof d[k] !== 'function') throw new Error(`trailer: ${k} is a function`)
|
|
101
|
+
if (d.page !== undefined) {
|
|
102
|
+
const why = wordsProblem(d.page.words) ?? Object.entries(d.page.words).flatMap(([k, w]) => (w.arg !== undefined ? [`page word ${k} declares arg: a page word has a key (it is the url's query)`] : []))[0] ?? null
|
|
103
|
+
if (why !== null) throw new Error(`trailer: ${why}`)
|
|
104
|
+
}
|
|
76
105
|
if (d.stamp !== undefined && (!Array.isArray(d.stamp) || d.stamp.some((p) => typeof p !== 'string' || p === '' || p.startsWith('/')))) throw new Error('trailer: stamp is a list of repo relative paths')
|
|
77
106
|
return { ...d, kind: 'trailer' }
|
|
78
107
|
}
|
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 }, { duration }, { trailerCache }, { loadBrand, identityLogo }] = await Promise.all([
|
|
373
508
|
import('./titles.ts'),
|
|
374
509
|
import('./look.ts'),
|
|
375
510
|
import('./ffmpeg.ts'),
|
|
@@ -404,26 +539,27 @@ async function stitchInput(l: Loaded, takes: readonly Take[], shots: readonly Sh
|
|
|
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 =
|
|
558
|
+
const file = isFileSound(x.id, x) ? fileOf(x.id, l)! : await resolve(ask('sfx', x.id, x))
|
|
423
559
|
return { file, seconds: duration(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
|
|
562
|
+
const file = v.file ? fileOf(v.file, l)! : await resolve(ask('voice', v.text ?? 'run', v))
|
|
427
563
|
return { file, seconds: duration(file) }
|
|
428
564
|
}))))
|
|
429
565
|
return { ...base, sources, titles: drawn, music, sfx, voices }
|
|
@@ -579,8 +715,13 @@ export async function auditionScore(l: Loaded, o: BuildOptions): Promise<void> {
|
|
|
579
715
|
`[top][sp]vstack,${marks.join(',')}`,
|
|
580
716
|
].join(';')
|
|
581
717
|
await ffmpeg(['-i', wav, '-filter_complex', graph, '-frames:v', '1', png])
|
|
718
|
+
// the score as General MIDI, for a DAW or a soundfont (score/midi.ts), once it exists
|
|
719
|
+
const midi = join(import.meta.dir, 'score', 'midi.ts')
|
|
720
|
+
const mid = join(outDir(l), `${l.t.name}.score.mid`)
|
|
721
|
+
if (existsSync(midi)) writeFileSync(mid, ((await import(midi)) as { scoreMidi(s: CompiledScore): Uint8Array }).scoreMidi(sc))
|
|
582
722
|
o.say(`ok: ${wav} (${span.toFixed(1)} s) in ${((performance.now() - t0) / 1000).toFixed(1)} s`)
|
|
583
723
|
o.say(`ok: ${png} (the waveform over the spectrum; red: each section's start${sc.stop !== null ? ', yellow: the stop' : ''})`)
|
|
724
|
+
if (existsSync(mid)) o.say(`ok: ${mid} (the score as General MIDI)`)
|
|
584
725
|
}
|
|
585
726
|
|
|
586
727
|
/** one shot alone with its titles, no sound: out/shot-<name>.mp4 and its nine frames on out/shot-<name>.png */
|
|
@@ -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
|
}
|