rvlib-shipkit 2.1.0 → 2.2.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 +18 -0
- package/package.json +2 -1
- package/src/assets/kits/identity-kit.ts +7 -3
- package/src/assets/run-assets.ts +18 -4
- package/src/checks/SK004-gitignore-policy.check.ts +11 -6
- package/src/checks/SK013-one-way-in-policy.check.ts +13 -8
- package/src/cli/cmd_CacheAudit.ts +3 -2
- package/src/cli/cmd_Calls.ts +53 -0
- package/src/cli/cmd_Ci.ts +4 -3
- package/src/cli/cmd_Commands.ts +158 -43
- package/src/cli/cmd_Fleet.ts +3 -1
- package/src/cli/cmd_GlobalProcesses.ts +7 -2
- package/src/cli/cmd_Shot.ts +63 -0
- package/src/cli/cmd_Start.ts +8 -4
- package/src/cli/cmd_Trailer.ts +108 -0
- package/src/cli/kit/builtins.ts +40 -0
- package/src/cli/kit/calls.ts +177 -0
- package/src/cli/kit/families.ts +67 -0
- package/src/cli/kit/index.ts +8 -0
- package/src/cli/kit/pages-check.ts +36 -0
- package/src/cli/kit/project-cli.ts +73 -0
- package/src/cli/kit/verb.ts +157 -0
- package/src/cli/list-flag.ts +3 -0
- package/src/cli.ts +13 -2
- package/src/core/layout.ts +10 -2
- package/src/core/migrate-layout.ts +31 -8
- package/src/core/one-way-in-policy.ts +12 -4
- package/src/mv/mv-md.ts +2 -1
- package/src/reflect/prompt-corpus.ts +5 -0
- package/src/scaffold/cli-skeleton.ts +149 -0
- package/src/scaffold/files/agent-index.md +2 -2
- package/src/scaffold/files/tsconfig.json +3 -0
- package/src/scaffold/files/verify-skill.md +1 -1
- package/src/scaffold/project-scaffold.ts +90 -12
- package/src/shipkit-files.ts +8 -4
- package/src/shot/client.ts +67 -0
- package/src/shot/clip-encoder.ts +146 -0
- package/src/shot/dom-clip.ts +124 -0
- package/src/shot/serve-shot.ts +54 -0
- package/src/shot/shooter.ts +343 -0
- package/src/shot/shot-ask.ts +189 -0
- package/src/shot/shot.shipkit.ts +14 -0
- package/src/smoke/run-smoke.ts +2 -2
- package/src/start-repo.ts +29 -14
- package/src/suite/judge-suites.ts +56 -10
- package/src/suite/select.ts +2 -5
- package/src/suite/suite-cli.ts +3 -2
- package/src/trailer/adapter.ts +23 -0
- package/src/trailer/build.ts +344 -0
- package/src/trailer/ffmpeg.ts +50 -0
- package/src/trailer/footage.ts +104 -0
- package/src/trailer/grammar.ts +915 -0
- package/src/trailer/graph.ts +323 -0
- package/src/trailer/load-unit.ts +14 -0
- package/src/trailer/look.ts +110 -0
- package/src/trailer/plan.ts +133 -0
- package/src/trailer/stitch.ts +150 -0
- package/src/trailer/titles.ts +274 -0
- package/src/trailer/url.ts +108 -0
- package/src/trailer/vertical-draw.ts +286 -0
- package/src/trailer/vertical.ts +148 -0
- package/src/trailer/vocab.ts +40 -0
- package/src/ts/rename.ts +9 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// the stitch: the cut rendered piece by piece (a piece = a section), each cached on its ffmpeg arguments and the size
|
|
2
|
+
// and time of every file it reads, so an edit renders only the pieces it touches. The pieces join with no second
|
|
3
|
+
// encode, the sound is mixed once (cached the same way) and muxed beside them, and the strip lays out each piece's row
|
|
4
|
+
import { existsSync, mkdirSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs'
|
|
5
|
+
import { availableParallelism } from 'node:os'
|
|
6
|
+
import { dirname, join } from 'node:path'
|
|
7
|
+
import sharp from 'sharp'
|
|
8
|
+
import { ffmpeg } from './ffmpeg.ts'
|
|
9
|
+
import { type Encoder, mixArgs, pieceArgs, type StitchInput, THUMB_W } from './graph.ts'
|
|
10
|
+
import { type Piece, pieces } from './plan.ts'
|
|
11
|
+
|
|
12
|
+
/** bump when the stitch changes what the same arguments make */
|
|
13
|
+
const STITCH_VERSION = 1
|
|
14
|
+
|
|
15
|
+
/** the files an ffmpeg command reads */
|
|
16
|
+
const inputsOf = (args: string[]): string[] => args.flatMap((x, i) => (args[i - 1] === '-i' && existsSync(x) ? [x] : []))
|
|
17
|
+
|
|
18
|
+
/** a render's key: its arguments (the outputs named alike) and every file it reads as it stands: a refilm rewrites a
|
|
19
|
+
* take in place, so its size and time count */
|
|
20
|
+
function keyOf(args: string[]): string {
|
|
21
|
+
const reads = inputsOf(args).map((f) => {
|
|
22
|
+
const s = statSync(f)
|
|
23
|
+
return [f, s.size, s.mtimeMs]
|
|
24
|
+
})
|
|
25
|
+
return new Bun.CryptoHasher('sha1').update(JSON.stringify([STITCH_VERSION, args, reads])).digest('hex').slice(0, 16)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** a render kept under its key, one file per extension (the first always made, the others when the render has them):
|
|
29
|
+
* made only when missing, written aside then moved in place, the first last */
|
|
30
|
+
async function cached(dir: string, stem: string, exts: string[], make: (outs: string[]) => string[]): Promise<{ files: string[]; made: boolean }> {
|
|
31
|
+
return cachedAs(dir, stem, keyOf(make(exts.map((e) => `out.${e}`))), exts, make)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** a render kept under a key its caller computed: for one whose inputs are rewritten by every build */
|
|
35
|
+
export async function cachedAs(dir: string, stem: string, key: string, exts: string[], make: (outs: string[]) => string[]): Promise<{ files: string[]; made: boolean }> {
|
|
36
|
+
const files = exts.map((e) => join(dir, `${stem}-${key}.${e}`))
|
|
37
|
+
if (existsSync(files[0]!)) {
|
|
38
|
+
const now = new Date()
|
|
39
|
+
for (const f of files) if (existsSync(f)) utimesSync(f, now, now)
|
|
40
|
+
return { files, made: false }
|
|
41
|
+
}
|
|
42
|
+
mkdirSync(dir, { recursive: true })
|
|
43
|
+
const parts = exts.map((e) => join(dir, `${stem}-${key}.part.${e}`))
|
|
44
|
+
try {
|
|
45
|
+
await ffmpeg(make(parts), { nice: 10 })
|
|
46
|
+
for (let i = parts.length - 1; i >= 0; i--) if (existsSync(parts[i]!)) renameSync(parts[i]!, files[i]!)
|
|
47
|
+
} finally {
|
|
48
|
+
for (const f of parts) rmSync(f, { force: true })
|
|
49
|
+
}
|
|
50
|
+
return { files, made: true }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** runs the jobs, at most `n` at once */
|
|
54
|
+
export async function pool<T>(jobs: (() => Promise<T>)[], n: number): Promise<T[]> {
|
|
55
|
+
const out: T[] = []
|
|
56
|
+
let next = 0
|
|
57
|
+
const lane = async (): Promise<void> => {
|
|
58
|
+
while (next < jobs.length) {
|
|
59
|
+
const i = next++
|
|
60
|
+
out[i] = await jobs[i]!()
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
await Promise.all(Array.from({ length: Math.min(n, jobs.length) }, lane))
|
|
64
|
+
return out
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** how many pieces render at once: a graph runs mostly on one core */
|
|
68
|
+
const LANES = Math.max(1, Math.min(4, Math.floor(availableParallelism() / 2)))
|
|
69
|
+
|
|
70
|
+
export interface Stitched {
|
|
71
|
+
/** the pieces, each with whether this run rendered it, its video and its row of the contact strip */
|
|
72
|
+
pieces: (Piece & { made: boolean; file: string; thumbs: string })[]
|
|
73
|
+
/** the sound was mixed again */
|
|
74
|
+
mixed: boolean
|
|
75
|
+
/** the mix file, null for a silent stitch */
|
|
76
|
+
mix: string | null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface StitchRun extends StitchInput {
|
|
80
|
+
out: string
|
|
81
|
+
/** where the rendered pieces and the mix are kept */
|
|
82
|
+
cache: string
|
|
83
|
+
encoder?: Encoder
|
|
84
|
+
/** no sound at all: a shot's preview */
|
|
85
|
+
silent?: boolean
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function renderPieces(a: StitchRun, ps: Piece[]): Promise<Stitched['pieces']> {
|
|
89
|
+
return pool(
|
|
90
|
+
ps.map((p) => async () => {
|
|
91
|
+
const { files, made } = await cached(a.cache, `${a.t.name}-${p.name}`, ['mp4', 'png'], ([video, row]) => pieceArgs(a, p, video!, a.encoder, row))
|
|
92
|
+
return { ...p, made, file: files[0]!, thumbs: files[1]! }
|
|
93
|
+
}),
|
|
94
|
+
LANES,
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function renderMix(a: StitchRun): Promise<{ file: string; made: boolean }> {
|
|
99
|
+
const { files, made } = await cached(join(a.cache, 'mix'), `mix-${a.t.name}`, ['m4a'], ([out]) => mixArgs(a, out!))
|
|
100
|
+
return { file: files[0]!, made }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** the pieces joined as they are, no encode, with the sound beside them */
|
|
104
|
+
async function joinPieces(files: string[], sound: string | null, out: string): Promise<void> {
|
|
105
|
+
mkdirSync(dirname(out), { recursive: true })
|
|
106
|
+
const list = `${out}.list.txt`
|
|
107
|
+
writeFileSync(list, files.map((f) => `file '${f.replaceAll("'", "'\\''")}'`).join('\n'))
|
|
108
|
+
const part = out.replace(/(\.\w+)$/, '.part$1')
|
|
109
|
+
try {
|
|
110
|
+
await ffmpeg(['-f', 'concat', '-safe', '0', '-i', list, ...(sound ? ['-i', sound] : []), '-map', '0:v', ...(sound ? ['-map', '1:a', '-c:a', 'copy'] : []), '-c:v', 'copy', '-movflags', '+faststart', part], { nice: 10 })
|
|
111
|
+
renameSync(part, out)
|
|
112
|
+
} finally {
|
|
113
|
+
rmSync(list, { force: true })
|
|
114
|
+
rmSync(part, { force: true })
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** the whole cut: every piece (rendered where the cache misses it), joined, with its sound */
|
|
119
|
+
export async function stitch(a: StitchRun): Promise<Stitched> {
|
|
120
|
+
const [ps, mix] = await Promise.all([renderPieces(a, pieces(a.t)), a.silent ? null : renderMix(a)])
|
|
121
|
+
await joinPieces(ps.map((p) => p.file), mix?.file ?? null, a.out)
|
|
122
|
+
return { pieces: ps, mixed: mix?.made ?? false, mix: mix?.file ?? null }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** one frame from the middle of each shot, side by side, rows of six: the cut at a glance. Each piece drew its own
|
|
126
|
+
* row as it rendered, so the strip only lays the rows out */
|
|
127
|
+
export async function strip(ps: Pick<Stitched['pieces'][number], 'thumbs'>[], out: string): Promise<void> {
|
|
128
|
+
const rows = await Promise.all(ps.filter((p) => existsSync(p.thumbs)).map((p) => sharp(p.thumbs).removeAlpha().raw().toBuffer({ resolveWithObject: true })))
|
|
129
|
+
const h = rows[0]?.info.height ?? 0
|
|
130
|
+
const cells = rows.flatMap(({ data, info }) => Array.from({ length: Math.round(info.width / THUMB_W) }, (_, k) => ({ data, left: k * THUMB_W, width: info.width })))
|
|
131
|
+
if (cells.length === 0) return
|
|
132
|
+
const PAD = 6
|
|
133
|
+
const cols = Math.min(6, cells.length)
|
|
134
|
+
const W = cols * THUMB_W + (cols - 1) * PAD
|
|
135
|
+
const H = Math.ceil(cells.length / cols) * (h + PAD) - PAD
|
|
136
|
+
const grid = Buffer.alloc(W * H * 3)
|
|
137
|
+
cells.forEach((c, i) => {
|
|
138
|
+
const [x, y] = [(i % cols) * (THUMB_W + PAD), Math.floor(i / cols) * (h + PAD)]
|
|
139
|
+
for (let r = 0; r < h; r++) c.data.copy(grid, ((y + r) * W + x) * 3, (r * c.width + c.left) * 3, (r * c.width + c.left + THUMB_W) * 3)
|
|
140
|
+
})
|
|
141
|
+
await sharp(grid, { raw: { width: W, height: H, channels: 3 } }).png({ compressionLevel: 3 }).toFile(out)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** which pieces a stitch rendered, in one phrase; the others came from the cache */
|
|
145
|
+
export function rendered(d: Stitched): string {
|
|
146
|
+
const made = d.pieces.filter((p) => p.made).map((p) => p.name)
|
|
147
|
+
const all = d.pieces.length
|
|
148
|
+
const sound = d.mixed ? ', the sound mixed' : ''
|
|
149
|
+
return made.length === 0 ? `every section cached${sound}` : made.length === all ? `${all} section(s) rendered${sound}` : `rendered ${made.join(', ')}, ${all - made.length} of ${all} sections cached${sound}`
|
|
150
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// the trailer's drawn frames: one transparent PNG per title, cut to what it draws, and the card's ground, in the repo's
|
|
2
|
+
// look (look.ts). Five styles: logo, big, caption, corner, line. Cached on what they draw, so a title edit draws one PNG
|
|
3
|
+
import { createCanvas, GlobalFonts, loadImage, type Image, type SKRSContext2D } from '@napi-rs/canvas'
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import sharp from 'sharp'
|
|
7
|
+
import type { Title, TitleStyle } from './grammar.ts'
|
|
8
|
+
import { alpha, darker, type TrailerLook } from './look.ts'
|
|
9
|
+
|
|
10
|
+
/** bump when a style's drawing changes: every cached title draws again */
|
|
11
|
+
const DRAWING = 1
|
|
12
|
+
|
|
13
|
+
export type TitlePlace = 'top' | 'bottom'
|
|
14
|
+
export const placeOf = (ti: Title): TitlePlace => ti.place ?? 'bottom'
|
|
15
|
+
|
|
16
|
+
/** a title's PNG cut to what it draws, and where it sits on the frame: the stitch blends that box, never the whole frame */
|
|
17
|
+
export interface TitleImage {
|
|
18
|
+
file: string
|
|
19
|
+
x: number
|
|
20
|
+
y: number
|
|
21
|
+
w: number
|
|
22
|
+
h: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const registered = new Set<string>()
|
|
26
|
+
/** the look's font file registered once under its family */
|
|
27
|
+
export function registerFace(look: TrailerLook, root: string): void {
|
|
28
|
+
if (!look.font.file || registered.has(look.font.file)) return
|
|
29
|
+
const abs = join(root, look.font.file)
|
|
30
|
+
if (!existsSync(abs)) throw new Error(`the brand's trailer font ${look.font.file} is not in the repo`)
|
|
31
|
+
GlobalFonts.registerFromPath(abs, look.font.family)
|
|
32
|
+
registered.add(look.font.file)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const face = (g: SKRSContext2D, look: TrailerLook, px: number, weight = look.font.weight): void => {
|
|
36
|
+
const fam = look.font.family.includes(',') ? look.font.family : `"${look.font.family}"`
|
|
37
|
+
g.font = `${weight} ${Math.round(px)}px ${fam}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** the text cut into lines no wider than `max`; `\n` breaks a line */
|
|
41
|
+
function wrap(g: SKRSContext2D, text: string, max: number): string[] {
|
|
42
|
+
const out: string[] = []
|
|
43
|
+
for (const para of text.split('\\n')) {
|
|
44
|
+
let line = ''
|
|
45
|
+
for (const w of para.split(/\s+/).filter(Boolean)) {
|
|
46
|
+
const next = line ? `${line} ${w}` : w
|
|
47
|
+
if (line && g.measureText(next).width > max) {
|
|
48
|
+
out.push(line)
|
|
49
|
+
line = w
|
|
50
|
+
} else line = next
|
|
51
|
+
}
|
|
52
|
+
out.push(line)
|
|
53
|
+
}
|
|
54
|
+
return out
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** a rounded plate with a soft drop under it */
|
|
58
|
+
function plate(g: SKRSContext2D, x: number, y: number, w: number, h: number, r: number, k: number, fill: string, line: string, edge: string): void {
|
|
59
|
+
g.save()
|
|
60
|
+
g.shadowColor = alpha(edge, 0.4)
|
|
61
|
+
g.shadowBlur = 18 * k
|
|
62
|
+
g.shadowOffsetY = 6 * k
|
|
63
|
+
g.fillStyle = fill
|
|
64
|
+
g.beginPath()
|
|
65
|
+
g.roundRect(x, y, w, h, r)
|
|
66
|
+
g.fill()
|
|
67
|
+
g.restore()
|
|
68
|
+
g.lineWidth = 3 * k
|
|
69
|
+
g.strokeStyle = line
|
|
70
|
+
g.beginPath()
|
|
71
|
+
g.roundRect(x, y, w, h, r)
|
|
72
|
+
g.stroke()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** one title over a transparent frame. A title's `\n` breaks its line; a logo's second line is its subtitle */
|
|
76
|
+
export function drawTitle(text: string, style: TitleStyle, [W, H]: readonly [number, number], place: TitlePlace, look: TrailerLook, logo: Image | null): Buffer {
|
|
77
|
+
const top = place === 'top'
|
|
78
|
+
const c = createCanvas(W, H)
|
|
79
|
+
const g = c.getContext('2d')
|
|
80
|
+
const k = Math.min(H, W) / 1080
|
|
81
|
+
g.textAlign = 'center'
|
|
82
|
+
g.textBaseline = 'middle'
|
|
83
|
+
const [first = '', ...more] = text.split('\\n')
|
|
84
|
+
if (style === 'logo') {
|
|
85
|
+
// the logo image over the word, the word in the accent over the edge and its soft drop, the subtitle spaced under it
|
|
86
|
+
const glow = g.createRadialGradient(W / 2, H * 0.48, 0, W / 2, H * 0.48, Math.max(W, H) * 0.45)
|
|
87
|
+
glow.addColorStop(0, alpha(look.edge, 0.45))
|
|
88
|
+
glow.addColorStop(1, alpha(look.edge, 0))
|
|
89
|
+
g.fillStyle = glow
|
|
90
|
+
g.fillRect(0, 0, W, H)
|
|
91
|
+
const markH = logo ? Math.min(H * 0.3, W * 0.3) : 0
|
|
92
|
+
face(g, look, 170 * k, 900)
|
|
93
|
+
const kl = Math.min(1, (W * 0.78) / (g.measureText(first).width + 16 * k))
|
|
94
|
+
const px = 170 * k * kl
|
|
95
|
+
const subPx = 34 * k * Math.max(kl, 0.7)
|
|
96
|
+
const block = markH + (markH ? px * 0.25 : 0) + (first ? px : 0) + (more.length ? subPx * 2 : 0)
|
|
97
|
+
let y = H * 0.48 - block / 2
|
|
98
|
+
if (logo) {
|
|
99
|
+
const w = (logo.width / logo.height) * markH
|
|
100
|
+
g.save()
|
|
101
|
+
g.shadowColor = alpha(look.edge, 0.6)
|
|
102
|
+
g.shadowBlur = 30 * k
|
|
103
|
+
g.drawImage(logo, W / 2 - w / 2, y, w, markH)
|
|
104
|
+
g.restore()
|
|
105
|
+
y += markH + px * 0.25
|
|
106
|
+
}
|
|
107
|
+
if (first) {
|
|
108
|
+
const cy = y + px / 2
|
|
109
|
+
face(g, look, px, 900)
|
|
110
|
+
g.save()
|
|
111
|
+
g.shadowColor = alpha(look.edge, 0.6)
|
|
112
|
+
g.shadowBlur = 30 * k
|
|
113
|
+
g.shadowOffsetY = 12 * k
|
|
114
|
+
g.lineJoin = 'round'
|
|
115
|
+
g.lineWidth = 16 * k * kl
|
|
116
|
+
g.strokeStyle = look.edge
|
|
117
|
+
g.strokeText(first, W / 2, cy + 3 * k)
|
|
118
|
+
g.restore()
|
|
119
|
+
const grad = g.createLinearGradient(0, cy - px / 2, 0, cy + px / 2)
|
|
120
|
+
grad.addColorStop(0.1, look.light)
|
|
121
|
+
grad.addColorStop(0.55, look.accent)
|
|
122
|
+
grad.addColorStop(1, darker(look.accent, 0.2))
|
|
123
|
+
g.fillStyle = grad
|
|
124
|
+
g.fillText(first, W / 2, cy)
|
|
125
|
+
y += px
|
|
126
|
+
}
|
|
127
|
+
if (more.length) {
|
|
128
|
+
face(g, look, subPx, 800)
|
|
129
|
+
;(g as unknown as { letterSpacing: string }).letterSpacing = `${Math.round(subPx * 0.42)}px`
|
|
130
|
+
g.shadowColor = alpha(look.edge, 0.9)
|
|
131
|
+
g.shadowBlur = subPx * 0.42
|
|
132
|
+
g.fillStyle = look.light
|
|
133
|
+
g.fillText(more.join(' ').toUpperCase(), W / 2, y + subPx)
|
|
134
|
+
}
|
|
135
|
+
} else if (style === 'big') {
|
|
136
|
+
// a strong display line in the upper third: light words in a thick edge outline, an accent under-glow
|
|
137
|
+
const px = 112 * k
|
|
138
|
+
face(g, look, px, 900)
|
|
139
|
+
const lines = wrap(g, [first, ...more].join(' '), W * 0.82)
|
|
140
|
+
lines.forEach((l, i) => {
|
|
141
|
+
const y = H * 0.26 + (i - (lines.length - 1) / 2) * px * 1.12
|
|
142
|
+
g.save()
|
|
143
|
+
g.shadowColor = alpha(look.edge, 0.55)
|
|
144
|
+
g.shadowBlur = 26 * k
|
|
145
|
+
g.shadowOffsetY = 10 * k
|
|
146
|
+
g.lineJoin = 'round'
|
|
147
|
+
g.lineWidth = 22 * k
|
|
148
|
+
g.strokeStyle = look.edge
|
|
149
|
+
g.strokeText(l, W / 2, y)
|
|
150
|
+
g.restore()
|
|
151
|
+
g.lineWidth = 8 * k
|
|
152
|
+
g.strokeStyle = look.accent
|
|
153
|
+
g.strokeText(l, W / 2, y)
|
|
154
|
+
g.fillStyle = look.light
|
|
155
|
+
g.fillText(l, W / 2, y)
|
|
156
|
+
})
|
|
157
|
+
} else if (style === 'line') {
|
|
158
|
+
// a tagline: light words centred low on a soft dark band, no plate, the frame stays the picture
|
|
159
|
+
const px = 64 * k
|
|
160
|
+
face(g, look, px)
|
|
161
|
+
const lines = more.length > 0 ? [first, ...more] : wrap(g, first, W * 0.7)
|
|
162
|
+
const lh = px * 1.3
|
|
163
|
+
const cy = top ? H * 0.2 : H * 0.8
|
|
164
|
+
const span = lh * lines.length + 80 * k
|
|
165
|
+
const band = g.createLinearGradient(0, cy - span, 0, cy + span)
|
|
166
|
+
band.addColorStop(0, alpha(look.edge, 0))
|
|
167
|
+
band.addColorStop(0.5, alpha(look.edge, 0.42))
|
|
168
|
+
band.addColorStop(1, alpha(look.edge, 0))
|
|
169
|
+
g.fillStyle = band
|
|
170
|
+
g.fillRect(0, cy - span, W, 2 * span)
|
|
171
|
+
lines.forEach((l, i) => {
|
|
172
|
+
const y = cy + (i - (lines.length - 1) / 2) * lh
|
|
173
|
+
// `*word*` is a key word in the accent, the rest light
|
|
174
|
+
const parts = l.split('*').map((t, n) => ({ text: t, key: n % 2 === 1 }))
|
|
175
|
+
const plain = parts.map((p) => p.text).join('')
|
|
176
|
+
let x = W / 2 - g.measureText(plain).width / 2
|
|
177
|
+
g.textAlign = 'left'
|
|
178
|
+
g.save()
|
|
179
|
+
g.shadowColor = alpha(look.edge, 0.85)
|
|
180
|
+
g.shadowBlur = 18 * k
|
|
181
|
+
g.shadowOffsetY = 4 * k
|
|
182
|
+
g.lineJoin = 'round'
|
|
183
|
+
g.lineWidth = 9 * k
|
|
184
|
+
g.strokeStyle = alpha(look.edge, 0.82)
|
|
185
|
+
g.strokeText(plain, x, y)
|
|
186
|
+
g.restore()
|
|
187
|
+
for (const p of parts) {
|
|
188
|
+
g.fillStyle = p.key ? look.accent : look.light
|
|
189
|
+
g.fillText(p.text, x, y)
|
|
190
|
+
x += g.measureText(p.text).width
|
|
191
|
+
}
|
|
192
|
+
g.textAlign = 'center'
|
|
193
|
+
})
|
|
194
|
+
} else if (style === 'caption') {
|
|
195
|
+
// the plate: its fill, its outline, its ink, an accent dot leading it
|
|
196
|
+
const px = 46 * k
|
|
197
|
+
face(g, look, px, 900)
|
|
198
|
+
const lines = wrap(g, [first, ...more].join(' '), W * 0.66)
|
|
199
|
+
const lh = px * 1.28
|
|
200
|
+
const dot = 14 * k
|
|
201
|
+
const bw = Math.max(...lines.map((l) => g.measureText(l).width)) + 96 * k + dot * 2
|
|
202
|
+
const bh = lines.length * lh + 34 * k
|
|
203
|
+
const cy = top ? H * 0.12 : H * 0.83
|
|
204
|
+
plate(g, W / 2 - bw / 2, cy - bh / 2, bw, bh, Math.min(bh / 2, 30 * k), k, look.plate.fill, look.plate.line, look.edge)
|
|
205
|
+
g.fillStyle = look.accent
|
|
206
|
+
g.beginPath()
|
|
207
|
+
g.arc(W / 2 - bw / 2 + 34 * k, cy, dot / 2 + 2 * k, 0, Math.PI * 2)
|
|
208
|
+
g.fill()
|
|
209
|
+
g.fillStyle = look.plate.ink
|
|
210
|
+
lines.forEach((l, i) => g.fillText(l, W / 2 + dot, cy + (i - (lines.length - 1) / 2) * lh + 2 * k))
|
|
211
|
+
} else {
|
|
212
|
+
// corner: a word chip, bottom left (top left when placed at the top)
|
|
213
|
+
const px = 54 * k
|
|
214
|
+
face(g, look, px, 900)
|
|
215
|
+
const t = [first, ...more].join(' ')
|
|
216
|
+
const w = g.measureText(t).width + 72 * k
|
|
217
|
+
const h = px * 1.5
|
|
218
|
+
const x = 72 * k
|
|
219
|
+
const y = top ? 80 * k : H - 80 * k - h
|
|
220
|
+
plate(g, x, y, w, h, h / 2, k, look.chip.fill, darker(look.chip.fill, 0.15), look.edge)
|
|
221
|
+
g.fillStyle = look.chip.ink
|
|
222
|
+
g.textAlign = 'left'
|
|
223
|
+
g.fillText(t, x + 36 * k, y + h / 2 + 2 * k)
|
|
224
|
+
}
|
|
225
|
+
return Buffer.from(c.toBuffer('image/png'))
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** the card's ground: the look's ground, darker at the edges */
|
|
229
|
+
export function drawCard([W, H]: readonly [number, number], look: TrailerLook): Buffer {
|
|
230
|
+
const c = createCanvas(W, H)
|
|
231
|
+
const g = c.getContext('2d')
|
|
232
|
+
const grad = g.createRadialGradient(W / 2, H * 0.45, 0, W / 2, H * 0.45, Math.hypot(W, H) * 0.6)
|
|
233
|
+
grad.addColorStop(0, look.ground[0])
|
|
234
|
+
grad.addColorStop(1, look.ground[1])
|
|
235
|
+
g.fillStyle = grad
|
|
236
|
+
g.fillRect(0, 0, W, H)
|
|
237
|
+
return Buffer.from(c.toBuffer('image/png'))
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const key = (parts: unknown): string => new Bun.CryptoHasher('sha1').update(JSON.stringify([DRAWING, parts])).digest('hex').slice(0, 16)
|
|
241
|
+
|
|
242
|
+
/** what the look reads from disk, as it stands: a new font or logo file draws every title again */
|
|
243
|
+
export const lookFiles = (look: TrailerLook, root: string): unknown[] =>
|
|
244
|
+
[look.font.file, look.logo].map((f) => {
|
|
245
|
+
if (!f) return null
|
|
246
|
+
const abs = join(root, f)
|
|
247
|
+
return existsSync(abs) ? [f, statSync(abs).size, statSync(abs).mtimeMs] : [f, 'missing']
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
/** the PNG of a title, drawn once per text, style, frame, place and look; its box on the frame in `<file>.json` beside it */
|
|
251
|
+
export async function titlePng(dir: string, root: string, text: string, style: TitleStyle, size: readonly [number, number], place: TitlePlace, look: TrailerLook): Promise<TitleImage> {
|
|
252
|
+
const file = join(dir, `title-${key([text, style, size, place, look, lookFiles(look, root)])}.png`)
|
|
253
|
+
const where = `${file}.json`
|
|
254
|
+
if (!existsSync(file) || !existsSync(where)) {
|
|
255
|
+
mkdirSync(dir, { recursive: true })
|
|
256
|
+
registerFace(look, root)
|
|
257
|
+
const logo = style === 'logo' && look.logo && existsSync(join(root, look.logo)) ? await loadImage(join(root, look.logo)) : null
|
|
258
|
+
const { data, info } = await sharp(drawTitle(text, style, size, place, look, logo)).trim({ threshold: 0 }).png().toBuffer({ resolveWithObject: true })
|
|
259
|
+
writeFileSync(file, data)
|
|
260
|
+
writeFileSync(where, JSON.stringify({ x: -(info.trimOffsetLeft ?? 0), y: -(info.trimOffsetTop ?? 0), w: info.width, h: info.height }))
|
|
261
|
+
}
|
|
262
|
+
const { x, y, w, h } = JSON.parse(readFileSync(where, 'utf8')) as TitleImage
|
|
263
|
+
return { file, x, y, w, h }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** the PNG of the card's ground for a frame and a look */
|
|
267
|
+
export function cardPng(dir: string, size: readonly [number, number], look: TrailerLook): string {
|
|
268
|
+
const file = join(dir, `card-${key(['card', size, look.ground])}.png`)
|
|
269
|
+
if (!existsSync(file)) {
|
|
270
|
+
mkdirSync(dir, { recursive: true })
|
|
271
|
+
writeFileSync(file, drawCard(size, look))
|
|
272
|
+
}
|
|
273
|
+
return file
|
|
274
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// a `film url` take: a route resolved against the repo's dev url (the url of its one global process, or of the one
|
|
2
|
+
// `base <id>` names), the page filmed by the house shot service (shot/client.ts), its process started through pm when
|
|
3
|
+
// it does not answer. Pure first, the io after
|
|
4
|
+
import type { Film } from './grammar.ts'
|
|
5
|
+
|
|
6
|
+
export type UrlFilm = Extract<Film, { kind: 'url' }>
|
|
7
|
+
|
|
8
|
+
/** a global process of the repo that serves a url */
|
|
9
|
+
export interface DevProcess {
|
|
10
|
+
id: string
|
|
11
|
+
url: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** the page a route films: an http url as written, else the route under the base's own path */
|
|
15
|
+
export function urlOf(route: string, base: string): string {
|
|
16
|
+
return /^https?:\/\//.test(route) ? route : `${base.replace(/\/+$/, '')}${route}`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** the process a cut's routes resolve against: the one `base <id>` names, else the repo's only one; or why none */
|
|
20
|
+
export function pickBase(ps: readonly DevProcess[], base: string | null): DevProcess | { error: string } {
|
|
21
|
+
const listed = ps.map((p) => `${p.id} (${p.url})`).join(', ')
|
|
22
|
+
if (base !== null) return ps.find((p) => p.id === base) ?? { error: `base ${base}: no global process of this repo has that id and a url${ps.length ? `; it has ${listed}` : ''}` }
|
|
23
|
+
if (ps.length === 1) return ps[0]!
|
|
24
|
+
if (ps.length === 0) return { error: 'film url: no global process of this repo declares a url (a <name>.shipkit.ts with globalProcess({ id, cmd, url })); or film a full http url' }
|
|
25
|
+
return { error: `film url: this repo serves several urls, name the one its routes start from with a header line base <id>: ${listed}` }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** a portrait cut films its page at a phone's css width */
|
|
29
|
+
const PHONE_CSS_W = 430
|
|
30
|
+
/** a landscape cut films its page no wider than this, in css pixels */
|
|
31
|
+
const DESKTOP_CSS_W = 1920
|
|
32
|
+
|
|
33
|
+
/** the css viewport and the pixel ratio that fill the cut's frame: a viewport given keeps its css size, the ratio
|
|
34
|
+
* making up the frame's width (1 to 4); none: the frame itself, a portrait frame at a phone's width */
|
|
35
|
+
export function viewportOf(size: readonly [number, number], viewport?: readonly [number, number]): { viewport: { width: number; height: number }; dpr: number } {
|
|
36
|
+
const [W, H] = size
|
|
37
|
+
if (viewport) return { viewport: { width: viewport[0], height: viewport[1] }, dpr: +Math.min(4, Math.max(1, W / viewport[0])).toFixed(4) }
|
|
38
|
+
const dpr = +Math.min(4, Math.max(1, W / (H > W ? PHONE_CSS_W : DESKTOP_CSS_W))).toFixed(4)
|
|
39
|
+
return { viewport: { width: Math.round(W / dpr), height: Math.round(H / dpr) }, dpr }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** every global process of the repo that declares a url, by the walk every unit file takes */
|
|
43
|
+
export async function devProcesses(root: string): Promise<DevProcess[]> {
|
|
44
|
+
const { importFresh, repoUnitFiles } = await import('../core/discover-checks.ts')
|
|
45
|
+
const out: DevProcess[] = []
|
|
46
|
+
for (const f of repoUnitFiles(root)) {
|
|
47
|
+
const d = (await importFresh(f)) as { kind?: unknown; id?: unknown; url?: unknown } | undefined
|
|
48
|
+
if (d?.kind === 'globalProcess' && typeof d.id === 'string' && typeof d.url === 'string') out.push({ id: d.id, url: d.url })
|
|
49
|
+
}
|
|
50
|
+
return out
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** any http answer, a 404 included, means the server is up */
|
|
54
|
+
const answers = (url: string): Promise<boolean> =>
|
|
55
|
+
fetch(url, { signal: AbortSignal.timeout(2_000) }).then(
|
|
56
|
+
() => true,
|
|
57
|
+
() => false,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
async function resolveBase(root: string, base: string | null, say: (l: string) => void): Promise<string> {
|
|
61
|
+
const pick = pickBase(await devProcesses(root), base)
|
|
62
|
+
if ('error' in pick) throw new Error(pick.error)
|
|
63
|
+
if (await answers(pick.url)) return pick.url
|
|
64
|
+
const { pmUp, pmUrl } = await import('../home/home-paths.ts')
|
|
65
|
+
if (process.env['CI'] == null && process.env['SHIPKIT_NO_HOME'] == null && (await pmUp())) {
|
|
66
|
+
say(` starting ${pick.id}: ${pick.url} does not answer (sk gp start ${pick.id})`)
|
|
67
|
+
const { pmSwitchDeps, switchProcess } = await import('../cli/cmd_GlobalProcesses.ts')
|
|
68
|
+
if ((await switchProcess(pick.id, 'start', pmSwitchDeps(say, pmUrl()), 120_000)) !== 0) throw new Error(`${pick.id} did not start through pm: sk gp shows where it stands`)
|
|
69
|
+
return pick.url
|
|
70
|
+
}
|
|
71
|
+
throw new Error(`${pick.url} does not answer and no pm runs here to start it: sk gp start ${pick.id}`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** the base of each repo's routes, resolved once per process: two takes filming at once start the process once */
|
|
75
|
+
const bases = new Map<string, Promise<string>>()
|
|
76
|
+
export function baseUrl(root: string, base: string | null, say: (l: string) => void): Promise<string> {
|
|
77
|
+
const k = `${root}\0${base ?? ''}`
|
|
78
|
+
let p = bases.get(k)
|
|
79
|
+
if (!p) {
|
|
80
|
+
p = resolveBase(root, base, say)
|
|
81
|
+
p.catch(() => bases.delete(k))
|
|
82
|
+
bases.set(k, p)
|
|
83
|
+
}
|
|
84
|
+
return p
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface UrlTake {
|
|
88
|
+
root: string
|
|
89
|
+
base: string | null
|
|
90
|
+
size: readonly [number, number]
|
|
91
|
+
fps: number
|
|
92
|
+
/** null: a still */
|
|
93
|
+
seconds: number | null
|
|
94
|
+
/** absolute: a .png for a still, a .mp4 for a clip */
|
|
95
|
+
out: string
|
|
96
|
+
say: (l: string) => void
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** film the page of a `film url` line at `out`; the note says what was filmed and how */
|
|
100
|
+
export async function filmUrl(f: UrlFilm, o: UrlTake): Promise<string> {
|
|
101
|
+
const url = /^https?:\/\//.test(f.route) ? f.route : urlOf(f.route, await baseUrl(o.root, o.base, o.say))
|
|
102
|
+
const { clipUrl, shotUrl } = await import('../shot/client.ts')
|
|
103
|
+
const { viewport, dpr } = viewportOf(o.size, f.viewport)
|
|
104
|
+
const wait = f.wait === undefined ? {} : { waitMs: Math.round(f.wait * 1000) }
|
|
105
|
+
const log = (l: string): void => o.say(` ${l}`)
|
|
106
|
+
const r = o.seconds === null ? await shotUrl({ url, out: o.out, viewport, dpr, ...wait }, log) : await clipUrl({ url, out: o.out, viewport, dpr, seconds: o.seconds, fps: o.fps, ...wait }, log)
|
|
107
|
+
return `${url} at ${viewport.width}x${viewport.height}@${dpr}, ${r.via}, ${r.ms} ms${r.note ? `: ${r.note}` : ''}`
|
|
108
|
+
}
|