@homie-rocks/studio 0.51.3 → 0.51.5
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 +45 -0
- package/README.md +10 -29
- package/apps/APPS.md +1 -1
- package/bin/homie-studio.mjs +28 -30
- package/docs/mcp-events.md +68 -0
- package/guides/app/SKILL.md +25 -1
- package/guides/art/SKILL.md +68 -140
- package/guides/art/scripts/art.mjs +6 -79
- package/guides/models/SKILL.md +73 -177
- package/guides/models/references/RIGHTS.md +1 -1
- package/guides/models/scripts/models.mjs +7 -288
- package/guides/music/SKILL.md +74 -183
- package/guides/music/references/COMPOSITION.md +21 -26
- package/guides/music/references/GAME-AUDIO.md +3 -3
- package/guides/music/references/RIGHTS.md +12 -15
- package/guides/music/scripts/music.mjs +11 -202
- package/guides/office/SKILL.md +24 -0
- package/guides/parts/SKILL.md +1 -1
- package/guides/servers/SKILL.md +24 -0
- package/guides/shop/SKILL.md +68 -77
- package/guides/sound/SKILL.md +70 -1
- package/guides/sound/scripts/player/sound.js +233 -0
- package/guides/studio-setup/SKILL.md +11 -27
- package/guides/studio-setup/references/OFFICIAL-MEDIA.md +66 -0
- package/guides/style/SKILL.md +66 -3
- package/guides/video/SKILL.md +83 -0
- package/guides/video/references/DELIVERY.md +73 -0
- package/guides/video/references/HONESTY.md +40 -0
- package/guides/video/references/METHOD.md +144 -0
- package/guides/video/references/RECORD.md +137 -0
- package/guides/video/references/TRAILER.md +154 -0
- package/guides/video/references/examples/site-walkthrough.json +14 -0
- package/guides/video/references/examples/studio-play-phone.json +15 -0
- package/guides/video/references/examples/studio-play.json +17 -0
- package/guides/video/scripts/capture-game.mjs +175 -0
- package/guides/video/scripts/clock.js +136 -0
- package/guides/video/scripts/drawover/film.js +58 -0
- package/guides/video/scripts/drawover/index.html +9 -0
- package/guides/video/scripts/drawover/look.js +115 -0
- package/guides/video/scripts/drawover/shots.json +12 -0
- package/guides/video/scripts/lib/browser.mjs +87 -0
- package/guides/video/scripts/lib/frames.mjs +170 -0
- package/guides/video/scripts/lib/remix.mjs +159 -0
- package/guides/video/scripts/qa.mjs +127 -0
- package/guides/video/scripts/record-fixed.mjs +403 -0
- package/guides/video/scripts/record-page.mjs +477 -0
- package/guides/video/scripts/tap.js +108 -0
- package/guides/video/scripts/video.mjs +888 -0
- package/lib/build.mjs +12 -9
- package/lib/cloudflare.mjs +6 -11
- package/lib/dev.mjs +1 -2
- package/lib/doctor.mjs +10 -54
- package/lib/functions-build.mjs +8 -1
- package/lib/mcp-tools.mjs +42 -21
- package/lib/mcp.mjs +1 -1
- package/lib/media-import.mjs +41 -0
- package/lib/media-workflows.mjs +13 -0
- package/lib/official-media.mjs +15 -0
- package/lib/rules-build.mjs +13 -5
- package/lib/scaffold.mjs +2 -2
- package/lib/shop-links.mjs +54 -18
- package/lib/shop-listen.mjs +43 -0
- package/lib/shop-local.mjs +1 -1
- package/lib/shop-mcp.mjs +58 -0
- package/lib/stripe-connect.mjs +36 -15
- package/lib/template-history.json +87 -0
- package/lib/upgrade-media.mjs +13 -0
- package/lib/upgrade.mjs +10 -0
- package/package.json +1 -1
- package/parts/PARTS.md +1 -1
- package/rules/view.ts +4 -0
- package/shop/SHOP.md +66 -34
- package/worker/build-id.mjs +1 -1
- package/worker/event-resources.mjs +12 -3
- package/worker/event-webhooks.mjs +14 -9
- package/worker/functions.mjs +11 -6
- package/worker/index.mjs +5 -1
- package/worker/mcp-event-streams.mjs +58 -0
- package/worker/mcp-events-wire.mjs +41 -0
- package/worker/mcp-events.mjs +132 -0
- package/worker/mcp.mjs +4 -1
- package/worker/purchase-reconciliation.mjs +16 -1
- package/worker/shop-subscriptions.mjs +2 -0
- package/worker/shop.mjs +7 -5
- package/worker/stripe-ownership.mjs +7 -0
- package/worker/version.mjs +1 -1
- package/guides/models/references/models.json +0 -175
- package/guides/music/scripts/lib/eleven.mjs +0 -260
- package/lib/projects-env.mjs +0 -68
- package/lib/projects.mjs +0 -311
- package/lib/provider-key.mjs +0 -21
|
@@ -0,0 +1,888 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** video.mjs: local asset preparation only. Generation, schemas and pricing belong to the official provider MCP.
|
|
3
|
+
* Read ../SKILL.md for the current workflow. */
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { copyFileSync, createReadStream, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { createServer } from 'node:http';
|
|
7
|
+
import { dirname, extname, join, resolve } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { beatsOf, decodeMono, energyOnsets, lagBetween, loudness, need, probe, run } from '../../music/scripts/lib/audio.mjs';
|
|
10
|
+
import { experienceDir, experienceJson, isServerRules, SLUG, checkBudget, getEntry, readJson, receipt, rel, requireStudio, setBudget, upsertEntry, writeJson } from '../../music/scripts/lib/studio.mjs';
|
|
11
|
+
import { publishEntry } from '../../music/scripts/music.mjs';
|
|
12
|
+
import { chromePath, htmlToPng, launch, loadPuppeteer } from './lib/browser.mjs';
|
|
13
|
+
|
|
14
|
+
import { BT709_FLAGS, BT709_TAIL, frameSegments } from './lib/frames.mjs';
|
|
15
|
+
import { eventScores } from './lib/remix.mjs';
|
|
16
|
+
|
|
17
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const argv = process.argv.slice(2);
|
|
19
|
+
const flags = new Map();
|
|
20
|
+
const pos = [];
|
|
21
|
+
const BOOL = new Set(['json', 'yes', 'dry-run', 'publish', 'no-deploy', 'no-game-audio', 'no-cursor', 'square', 'no-pace', 'keep-capture']);
|
|
22
|
+
for (let i = 0; i < argv.length; i++) {
|
|
23
|
+
const a = argv[i];
|
|
24
|
+
if (a.startsWith('--')) {
|
|
25
|
+
const [k, v] = a.slice(2).split(/=(.*)/s, 2);
|
|
26
|
+
if (v !== undefined) flags.set(k, v);
|
|
27
|
+
else if (!BOOL.has(k) && argv[i + 1] !== undefined && !argv[i + 1].startsWith('--')) flags.set(k, argv[++i]);
|
|
28
|
+
else flags.set(k, true);
|
|
29
|
+
} else pos.push(a);
|
|
30
|
+
}
|
|
31
|
+
const asJson = flags.has('json');
|
|
32
|
+
const say = (line) => { if (!asJson) process.stderr.write(`${line}\n`); };
|
|
33
|
+
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
34
|
+
|
|
35
|
+
function jobDir(root, slug) {
|
|
36
|
+
if (!SLUG.test(String(slug ?? ''))) throw new Error('name the video with a slug: lowercase letters, digits and hyphens (it becomes /videos/<slug>/)');
|
|
37
|
+
const dir = join(root, 'videos', slug);
|
|
38
|
+
mkdirSync(join(dir, 'work'), { recursive: true });
|
|
39
|
+
return dir;
|
|
40
|
+
}
|
|
41
|
+
const inJob = (dir, p) => resolve(dir, String(p));
|
|
42
|
+
/** Does the studio have storage (an R2 bucket it made with `homie-studio storage add`)? */
|
|
43
|
+
function hasStorage(root) {
|
|
44
|
+
const cf = readJson(join(root, 'studio.json'), {}).cloudflare ?? {};
|
|
45
|
+
return Boolean(cf.r2 && (cf.created ?? []).includes(`r2:${cf.r2}`));
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The video bitrate cap for a delivery. With no storage the site serves each file itself, and a file may be at
|
|
49
|
+
* most 25 MiB, so a cut is held to about 23 MiB whatever its length (with 192 kbit/s of sound); with storage, 12 Mbit/s.
|
|
50
|
+
*/
|
|
51
|
+
function deliveryKbps(seconds, storage) {
|
|
52
|
+
if (storage) return 12000;
|
|
53
|
+
return Math.max(800, Math.min(12000, Math.floor(((23 * 1048576 * 8) / Math.max(1, seconds)) / 1000) - 200));
|
|
54
|
+
}
|
|
55
|
+
function ff(args, what) {
|
|
56
|
+
const r = run('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-y', ...args]);
|
|
57
|
+
if (r.code !== 0) throw new Error(`${what} failed: ${r.stderr.trim().split('\n').slice(-2).join(' ')}`);
|
|
58
|
+
return r;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* ---------------------------------------------------------------- check / budget / price / gen */
|
|
62
|
+
|
|
63
|
+
async function check() {
|
|
64
|
+
const out = { ok: true, command: 'check', ffmpeg: need('ffmpeg') && need('ffprobe'), chrome: chromePath() };
|
|
65
|
+
out.fal = { connection: 'Use the official fal MCP in your AI client' };
|
|
66
|
+
let root = null;
|
|
67
|
+
try { root = requireStudio(); out.studio = { ok: true, root, puppeteer: Boolean(loadPuppeteer(root)) }; } catch (error) { out.studio = { ok: false, why: error.message }; }
|
|
68
|
+
if (!out.ffmpeg) { out.ok = false; out.why = 'ffmpeg is not installed (macOS: `brew install ffmpeg`; the person approves the install)'; }
|
|
69
|
+
if (!out.chrome) { out.ok = false; out.why = [out.why, 'no Chrome found (set CHROME_PATH)'].filter(Boolean).join('; '); }
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function budget(root) {
|
|
74
|
+
const cap = Number(flags.get('cap'));
|
|
75
|
+
if (!(cap > 0)) throw new Error('--cap <US dollars>: the most the person agreed to spend on this video');
|
|
76
|
+
return { ok: true, command: 'budget', slug: pos[1], budget: setBudget(jobDir(root, pos[1]), { provider: 'fal', unit: 'usd', cap, approvedBy: flags.get('by') ?? 'the person, in chat', note: flags.get('note') ?? null }) };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* ---------------------------------------------------------------- the song: grid, slices, lag */
|
|
80
|
+
|
|
81
|
+
function songFile(root, song) {
|
|
82
|
+
if (!song) return null;
|
|
83
|
+
if (existsSync(resolve(String(song)))) return { file: resolve(String(song)), entry: null };
|
|
84
|
+
const e = getEntry(root, 'music', String(song));
|
|
85
|
+
if (!e) throw new Error(`no song "${song}" in music/manifest.json (or give a file path)`);
|
|
86
|
+
const audio = (e.files ?? []).find((f) => f.role === 'audio');
|
|
87
|
+
if (!audio?.path || !existsSync(join(root, audio.path))) throw new Error(`the song "${song}" has no audio file on this computer`);
|
|
88
|
+
return { file: join(root, audio.path), entry: e, dir: join(root, 'music', String(song)) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Sung words with their times, from the music skill's render (the model's own word stamps), shifted to the mastered file. */
|
|
92
|
+
function wordsOf(root, song) {
|
|
93
|
+
if (!song?.dir) return [];
|
|
94
|
+
const r = readJson(join(song.dir, 'render.json'), null);
|
|
95
|
+
const w = r ? readJson(join(song.dir, 'work', `render-${r.n}.json`), null) : null;
|
|
96
|
+
// Directions in {…} and section names in […] are in the stamps too, a word at a time: skip whole spans.
|
|
97
|
+
const out = []; let inside = null;
|
|
98
|
+
for (const x of w?.words ?? []) {
|
|
99
|
+
const t = String(x.word ?? '').trim();
|
|
100
|
+
if (!t) continue;
|
|
101
|
+
if (!inside && (t.startsWith('{') || t.startsWith('['))) inside = t[0] === '{' ? '}' : ']';
|
|
102
|
+
if (inside) { if (t.endsWith(inside)) inside = null; continue; }
|
|
103
|
+
out.push({ w: t, s: x.start_ms / 1000, e: x.end_ms / 1000 });
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function grid(root) {
|
|
109
|
+
const slug = pos[1];
|
|
110
|
+
const dir = jobDir(root, slug);
|
|
111
|
+
const song = songFile(root, flags.get('song'));
|
|
112
|
+
if (!song) throw new Error('--song <music slug or audio file>');
|
|
113
|
+
const plan = song.dir ? readJson(join(song.dir, 'plan.json'), null) : null;
|
|
114
|
+
const bpm = flags.has('bpm') ? Number(flags.get('bpm')) : plan?.bpm ?? null;
|
|
115
|
+
const g = beatsOf(song.file, { bpm });
|
|
116
|
+
const beat = 60 / g.bpm;
|
|
117
|
+
const bpb = plan?.beatsPerBar ?? 4;
|
|
118
|
+
const offset = ((g.phase % beat) + beat) % beat;
|
|
119
|
+
const bars = [];
|
|
120
|
+
for (let t = offset; t < g.duration - 0.01; t += beat * bpb) bars.push(+t.toFixed(4));
|
|
121
|
+
// The model's word stamps can sit a few frames off what is heard (measured: 50 to 80 ms late on plosives): move
|
|
122
|
+
// each word to the strongest vocal-band onset from 100 ms before to 40 ms after its stamp, so the type pops on
|
|
123
|
+
// the consonant. A word with no onset there keeps its stamp.
|
|
124
|
+
const words = wordsOf(root, song);
|
|
125
|
+
let snapped = 0;
|
|
126
|
+
if (words.length) {
|
|
127
|
+
const sr = 22050;
|
|
128
|
+
const x = decodeMono(song.file, { rate: sr, filter: 'highpass=f=250,lowpass=f=3500' });
|
|
129
|
+
const on = energyOnsets(x, sr);
|
|
130
|
+
for (const w of words) {
|
|
131
|
+
let bi = -1; let bv = 0;
|
|
132
|
+
for (let i = Math.max(0, Math.round((w.s - 0.1 - on.offset) * on.rate)); i <= Math.min(on.env.length - 1, Math.round((w.s + 0.04 - on.offset) * on.rate)); i++) if (on.env[i] > bv) { bv = on.env[i]; bi = i; }
|
|
133
|
+
if (bi >= 0 && bv > 0.12) { const t = +(bi / on.rate + on.offset).toFixed(3); w.stamp = w.s; w.s = t; snapped++; }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const out = { song: rel(root, song.file), songSlug: song.entry?.slug ?? null, bpm: g.bpm, beat, beatsPerBar: bpb, offset: +offset.toFixed(4), confidence: g.confidence, duration: g.duration, beats: g.beats, bars, words, wordsSnapped: snapped, sections: plan ? plan.lyrics.map((s) => ({ name: s.section, bar: s.startBar, at: +(offset + s.startBar * beat * bpb).toFixed(3), lines: s.lines })) : [] };
|
|
137
|
+
writeJson(join(dir, 'grid.json'), out);
|
|
138
|
+
return { ok: true, command: 'grid', slug, bpm: out.bpm, bars: bars.length, barSeconds: +(beat * bpb).toFixed(4), offset: out.offset, confidence: out.confidence, words: out.words.length, wordsSnapped: snapped, file: rel(root, join(dir, 'grid.json')) };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function slice(root) {
|
|
142
|
+
const slug = pos[1];
|
|
143
|
+
const dir = jobDir(root, slug);
|
|
144
|
+
const g = readJson(join(dir, 'grid.json'), null);
|
|
145
|
+
const song = flags.get('song') ? songFile(root, flags.get('song')).file : g ? join(root, g.song) : null;
|
|
146
|
+
if (!song) throw new Error('grid the song first, or --song');
|
|
147
|
+
const from = Number(flags.get('from')); const to = Number(flags.get('to'));
|
|
148
|
+
if (!(to > from)) throw new Error('--from <s> --to <s>');
|
|
149
|
+
const out = inJob(dir, flags.get('out') ?? `work/audio/${from.toFixed(2)}-${to.toFixed(2)}.wav`);
|
|
150
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
151
|
+
ff(['-ss', from.toFixed(4), '-t', (to - from).toFixed(4), '-i', song, '-ac', '2', '-ar', '48000', '-c:a', 'pcm_s16le', out], 'the slice');
|
|
152
|
+
return { ok: true, command: 'slice', file: rel(root, out), seconds: +(to - from).toFixed(3) };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function lag(root) {
|
|
156
|
+
const base = resolve(String(flags.get('base') ?? '')); const ref = resolve(String(flags.get('ref') ?? ''));
|
|
157
|
+
if (!existsSync(base) || !existsSync(ref)) throw new Error('--base <generated clip.mp4> --ref <the audio reference it was given>');
|
|
158
|
+
const sr = 16000;
|
|
159
|
+
const a = decodeMono(ref, { rate: sr }); const b = decodeMono(base, { rate: sr });
|
|
160
|
+
const l = lagBetween(a, b, sr);
|
|
161
|
+
const frames = l.lagS * 24;
|
|
162
|
+
// lagS < 0: the clip's sound (and so its lips) runs AHEAD of the reference, e.g. a model that dropped a quiet lead-in.
|
|
163
|
+
const baseStart = +(l.lagS).toFixed(3);
|
|
164
|
+
return { ok: Math.abs(l.lagS) <= 0.042 && l.peak >= 8, command: 'lag', base: rel(root, base), lagMs: Math.round(l.lagS * 1000), lagFramesAt24: +frames.toFixed(2), peak: l.peak, baseStart,
|
|
165
|
+
verdict: l.peak < 8 ? 'no clear match: the clip\'s sound is not the reference (regenerate with the audio reference, or check the file)' : Math.abs(l.lagS) <= 0.042 ? 'in sync (within one frame)' : `the clip's sound is ${Math.abs(Math.round(l.lagS * 1000))} ms ${l.lagS < 0 ? 'ahead of' : 'behind'} its reference: set this shot's "baseStart" to ${baseStart} (add it to any baseStart it already has) so its lips land on the song` };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/* ---------------------------------------------------------------- capture / edl / cards / cut */
|
|
169
|
+
|
|
170
|
+
function capture(root) {
|
|
171
|
+
const slug = pos[1];
|
|
172
|
+
const dir = jobDir(root, slug);
|
|
173
|
+
const url = flags.get('url') ?? readJson(join(root, 'studio.json'), {}).cloudflare?.url;
|
|
174
|
+
if (!url) throw new Error('--url <site>: http://127.0.0.1:8787 while `npm run dev` runs, or the live site');
|
|
175
|
+
const out = join(dir, 'work', 'capture');
|
|
176
|
+
const args = [join(HERE, 'capture-game.mjs'), '--url', String(url), '--game', String(flags.get('game') ?? ''), '--seconds', String(flags.get('seconds') ?? 60), '--out', out, '--view', String(flags.get('view') ?? 'tv')];
|
|
177
|
+
// A heavy game paints more frames on a smaller page (--scale), scaled up to the film's size when it is encoded.
|
|
178
|
+
for (const k of ['scale', 'fps']) if (flags.has(k)) args.push(`--${k}`, String(flags.get(k)));
|
|
179
|
+
const r = spawnSync(process.execPath, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'], timeout: 20 * 60_000 });
|
|
180
|
+
let j = null; try { j = JSON.parse(r.stdout.trim().split('\n').pop()); } catch { /* */ }
|
|
181
|
+
if (r.status !== 0 || !j?.ok) throw new Error('the capture failed (its log is above)');
|
|
182
|
+
return { ok: true, command: 'capture', slug, file: rel(root, j.file), seconds: j.seconds, sourceFps: j.sourceFps, heldFrames: j.heldFrames, audio: j.audio, ...(j.advice ? { advice: j.advice } : {}) };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* A page recording (record-page.mjs): a steps file drives the page (Play, keys, taps, typing, scrolls, waits) while it
|
|
187
|
+
* is recorded in real time. The take lands in work/record (or work/record-<name>): recording.mp4, recording.json (each
|
|
188
|
+
* step's second, frame rates, held frames, the input each frame received) and captions.vtt. Paths only, never media.
|
|
189
|
+
*/
|
|
190
|
+
function record(root) {
|
|
191
|
+
const slug = pos[1];
|
|
192
|
+
const dir = jobDir(root, slug);
|
|
193
|
+
const steps = flags.get('steps');
|
|
194
|
+
if (!steps || !existsSync(resolve(String(steps)))) throw new Error('--steps <steps.json>: what to press and wait for (references/RECORD.md; references/examples/studio-play.json records a studio\'s own game)');
|
|
195
|
+
const take = flags.get('name') ? String(flags.get('name')) : null;
|
|
196
|
+
if (take && !/^[a-z0-9-]{1,40}$/.test(take)) throw new Error('--name <take>: lowercase letters, digits and hyphens');
|
|
197
|
+
const out = join(dir, 'work', take ? `record-${take}` : 'record');
|
|
198
|
+
const args = [join(HERE, 'record-page.mjs'), '--steps', resolve(String(steps)), '--out', out];
|
|
199
|
+
const url = flags.get('url') ?? readJson(join(root, 'studio.json'), {}).cloudflare?.url ?? null;
|
|
200
|
+
if (url) args.push('--url', String(url));
|
|
201
|
+
for (const k of ['device', 'seconds', 'fps', 'scale', 'width', 'height', 'min-fps']) if (flags.has(k)) args.push(`--${k}`, String(flags.get(k)));
|
|
202
|
+
if (flags.has('no-cursor')) args.push('--no-cursor');
|
|
203
|
+
const r = spawnSync(process.execPath, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'], timeout: 20 * 60_000 });
|
|
204
|
+
let j = null; try { j = JSON.parse(r.stdout.trim().split('\n').pop()); } catch { /* */ }
|
|
205
|
+
if (!j) throw new Error('the recording failed (its log is above)');
|
|
206
|
+
return {
|
|
207
|
+
ok: j.ok, command: 'record', slug, file: rel(root, j.file), json: rel(root, j.json), ...(j.captions ? { captions: rel(root, j.captions) } : {}),
|
|
208
|
+
seconds: j.seconds, pageFps: j.pageFps, inputs: j.inputs, paintedFps: j.paintedFps, heldFrames: j.heldFrames, steps: j.steps,
|
|
209
|
+
...(j.failed ? { failed: j.failed } : {}), ...(j.warnings ? { warnings: j.warnings } : {}), ...(j.why ? { why: j.why } : {}),
|
|
210
|
+
next: `look at it (sheet ${slug} --in ${rel(root, j.file)}), then: add ${slug} --file ${rel(root, j.file)} --kind clip --title "<title>"${j.captions ? ` (copy ${rel(root, j.captions)} to videos/${slug}/captions.vtt first for the page's captions)` : ''}`,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Motion per tenth of a second (the mean change of a small grey thumbnail between frames), the
|
|
216
|
+
* thumbnails themselves, and where in the frame things change (for a push-in toward the action).
|
|
217
|
+
*/
|
|
218
|
+
const TW = 96; const TH = 54;
|
|
219
|
+
function motionOf(file) {
|
|
220
|
+
const r = run('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-i', file, '-vf', `fps=10,scale=${TW}:${TH},format=gray`, '-f', 'rawvideo', '-']);
|
|
221
|
+
const px = TW * TH; const n = Math.floor(r.stdout.length / px);
|
|
222
|
+
const m = new Float32Array(n);
|
|
223
|
+
for (let i = 1; i < n; i++) { let s = 0; for (let k = 0; k < px; k++) s += Math.abs(r.stdout[i * px + k] - r.stdout[(i - 1) * px + k]); m[i] = s / px; }
|
|
224
|
+
const thumb = (i) => r.stdout.subarray(Math.min(n - 1, Math.max(0, i)) * px, (Math.min(n - 1, Math.max(0, i)) + 1) * px);
|
|
225
|
+
/** The centre of change between frames a..b (0..1 in each axis), or the middle when nothing moves. */
|
|
226
|
+
const focus = (a, b) => {
|
|
227
|
+
let sx = 0; let sy = 0; let sw = 0;
|
|
228
|
+
for (let i = Math.max(1, a); i < Math.min(n, b); i++) {
|
|
229
|
+
for (let y = 0; y < TH; y++) for (let x = 0; x < TW; x++) {
|
|
230
|
+
const d = Math.abs(r.stdout[i * px + y * TW + x] - r.stdout[(i - 1) * px + y * TW + x]);
|
|
231
|
+
if (d > 6) { sx += x * d; sy += y * d; sw += d; }
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return sw ? [+(sx / sw / TW).toFixed(3), +(sy / sw / TH).toFixed(3)] : [0.5, 0.5];
|
|
235
|
+
};
|
|
236
|
+
const differ = (i, j) => { const a = thumb(i); const b = thumb(j); let d = 0; for (let k = 0; k < px; k++) d += Math.abs(a[k] - b[k]); return d / px; };
|
|
237
|
+
return { m, n, focus, differ };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function edl(root) {
|
|
241
|
+
const slug = pos[1];
|
|
242
|
+
const dir = jobDir(root, slug);
|
|
243
|
+
const length = Number(flags.get('length') ?? 30);
|
|
244
|
+
if (!(length >= 5 && length <= 180)) throw new Error('--length 5..180 seconds');
|
|
245
|
+
const cap = inJob(dir, flags.get('capture') ?? 'work/capture/capture.mp4');
|
|
246
|
+
if (!existsSync(cap)) throw new Error(`no capture at ${rel(root, cap)}: run capture first`);
|
|
247
|
+
const capInfo = readJson(join(dirname(cap), 'capture.json'), {});
|
|
248
|
+
const song = flags.get('song') ? songFile(root, flags.get('song')) : null;
|
|
249
|
+
let bar = 2; let bedFrom = 0; let bpm = null;
|
|
250
|
+
if (song) {
|
|
251
|
+
const plan = song.dir ? readJson(join(song.dir, 'plan.json'), null) : null;
|
|
252
|
+
const g = beatsOf(song.file, { bpm: plan?.bpm ?? null });
|
|
253
|
+
bpm = g.bpm; const beat = 60 / g.bpm; bar = beat * (plan?.beatsPerBar ?? 4);
|
|
254
|
+
const offset = ((g.phase % beat) + beat) % beat;
|
|
255
|
+
bedFrom = offset + Number(flags.get('bed-from-bar') ?? 0) * bar;
|
|
256
|
+
const dur = probe(song.file).duration;
|
|
257
|
+
if (bedFrom + length > dur + 0.05) throw new Error(`the song is ${dur.toFixed(1)} s; from bar ${flags.get('bed-from-bar') ?? 0} it cannot carry ${length} s (pick an earlier bar or a shorter cut)`);
|
|
258
|
+
}
|
|
259
|
+
// A shot is one bar (two beats when a bar is long); the title holds one bar, the end card what is left (at least 2.5 s).
|
|
260
|
+
const shot = bar > 2.6 ? bar / 2 : bar;
|
|
261
|
+
const titleDur = flags.get('title') ? Math.max(bar, 1.5) : 0;
|
|
262
|
+
const endMin = Math.max(2.5, bar);
|
|
263
|
+
const nClips = Math.max(1, Math.floor((length - titleDur - endMin) / shot + 1e-6));
|
|
264
|
+
const endDur = +(length - titleDur - nClips * shot).toFixed(4);
|
|
265
|
+
const mo = motionOf(cap);
|
|
266
|
+
const motion = mo.m;
|
|
267
|
+
const capDur = probe(cap).duration;
|
|
268
|
+
const settle = Math.min(2, capDur / 10);
|
|
269
|
+
const win = Math.round(shot * 10);
|
|
270
|
+
const scores = [];
|
|
271
|
+
for (let s = Math.round(settle * 10); s + win <= motion.length; s++) { let v = 0; for (let k = s; k < s + win; k++) v += motion[k]; scores.push({ at: s / 10, v: v / win }); }
|
|
272
|
+
if (!scores.length) throw new Error('the capture is shorter than one shot');
|
|
273
|
+
// The busiest windows that do not overlap and do not look like a shot already picked (a cut between two
|
|
274
|
+
// near-identical pictures reads as a jump, not a cut), then shown in the order they happened.
|
|
275
|
+
const picked = [];
|
|
276
|
+
const mid = (at) => Math.round(at * 10 + win / 2);
|
|
277
|
+
for (const c of [...scores].sort((x, y) => y.v - x.v)) {
|
|
278
|
+
if (picked.length >= nClips) break;
|
|
279
|
+
if (picked.some((p) => Math.abs(p.at - c.at) < shot + 0.5)) continue;
|
|
280
|
+
if (picked.some((p) => mo.differ(mid(p.at), mid(c.at)) < 4)) continue;
|
|
281
|
+
picked.push(c);
|
|
282
|
+
}
|
|
283
|
+
for (const c of [...scores].sort((x, y) => y.v - x.v)) { if (picked.length >= nClips) break; if (!picked.some((p) => Math.abs(p.at - c.at) < shot + 0.5)) picked.push(c); }
|
|
284
|
+
while (picked.length < nClips) picked.push({ at: settle + ((picked.length * shot) % Math.max(shot, capDur - shot - settle)), v: 0 });
|
|
285
|
+
picked.sort((x, y) => x.at - y.at);
|
|
286
|
+
// A push-in toward where the action is: a camera move on the real footage (a crop that grows), nothing added.
|
|
287
|
+
const push = flags.has('push') ? Number(flags.get('push')) : 1.25;
|
|
288
|
+
const segments = [];
|
|
289
|
+
if (titleDur) segments.push({ type: 'card', src: 'work/card-title', dur: +titleDur.toFixed(4) });
|
|
290
|
+
for (const p of picked.slice(0, nClips)) segments.push({ type: 'clip', src: rel(dir, cap), in: +p.at.toFixed(3), dur: +shot.toFixed(4), motion: +p.v.toFixed(2), ...(push > 1 ? { push: +push.toFixed(3), focus: mo.focus(Math.round(p.at * 10), Math.round(p.at * 10) + win) } : {}) });
|
|
291
|
+
segments.push({ type: 'card', src: 'work/card-end', dur: endDur });
|
|
292
|
+
const out = {
|
|
293
|
+
fps: 30, length, bpm, bar: +bar.toFixed(4),
|
|
294
|
+
bed: song ? { file: rel(dir, song.file), from: +bedFrom.toFixed(4), fadeOut: Math.min(1.5, endDur), gainDb: 0 } : null,
|
|
295
|
+
gameAudio: capInfo.audio?.peakDb != null && !flags.has('no-game-audio') ? { gainDb: song ? -8 : 0 } : null,
|
|
296
|
+
segments,
|
|
297
|
+
honesty: capInfo.honesty ?? null,
|
|
298
|
+
};
|
|
299
|
+
writeJson(join(dir, 'work', 'edl.json'), out);
|
|
300
|
+
const cuts = []; let t = 0; for (const s of segments.slice(0, -1)) { t += s.dur; cuts.push(+t.toFixed(3)); }
|
|
301
|
+
return { ok: true, command: 'edl', slug, file: rel(root, join(dir, 'work', 'edl.json')), shots: nClips, shotSeconds: +shot.toFixed(3), title: titleDur, end: endDur, cuts, bed: out.bed, gameAudio: out.gameAudio };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function cardHtml({ text, sub, small, w, h, bg = '#07080d', ink = '#f2f4fa', accent = '#ffcf5a', font = null }) {
|
|
305
|
+
const v = h >= w; // a square card is set like the tall one: narrower lines
|
|
306
|
+
const face = font && /^[A-Za-z0-9 ]{1,40}$/.test(font) ? font : null;
|
|
307
|
+
return `<!doctype html><html><head><meta charset="utf-8">${face ? `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=${encodeURIComponent(face).replace(/%20/g, '+')}:wght@400;700;900&display=block">` : ''}<style>
|
|
308
|
+
html,body{margin:0;width:${w}px;height:${h}px;background:${bg};color:${ink};font-family:${face ? `"${face}",` : ''}ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;overflow:hidden}
|
|
309
|
+
.c{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:${v ? 90 : 80}px}
|
|
310
|
+
h1{margin:0;font-size:${h === w ? 104 : v ? 118 : 132}px;line-height:.95;letter-spacing:-.03em;font-weight:900;max-width:${v ? 900 : 1600}px}
|
|
311
|
+
p{margin:${v ? 44 : 36}px 0 0;font-size:${v ? 46 : 44}px;color:${accent};font-weight:700;letter-spacing:.01em}
|
|
312
|
+
small{position:absolute;left:0;right:0;bottom:${h === w ? 56 : v ? 120 : 56}px;font-size:${v ? 26 : 22}px;opacity:.55}
|
|
313
|
+
</style></head><body><div class="c"><h1>${esc(text)}</h1>${sub ? `<p>${esc(sub)}</p>` : ''}</div>${small ? `<small>${esc(small)}</small>` : ''}</body></html>`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** A game's look for a card (games/<id>/style.json: its palette and display font), or nothing. */
|
|
317
|
+
function lookOf(root, game) {
|
|
318
|
+
const look = {};
|
|
319
|
+
if (!game) return look;
|
|
320
|
+
const t = readJson(join(experienceDir(root, String(game)), 'style.json'), null);
|
|
321
|
+
const hex = (c) => (/^#[0-9a-f]{6}$/i.test(String(c ?? '')) ? c : undefined);
|
|
322
|
+
if (t?.palette) Object.assign(look, { bg: hex(t.palette.bg), ink: hex(t.palette.ink), accent: hex(t.palette.accent) });
|
|
323
|
+
if (t?.fonts?.display) look.font = t.fonts.display;
|
|
324
|
+
for (const k of Object.keys(look)) if (look[k] === undefined) delete look[k];
|
|
325
|
+
return look;
|
|
326
|
+
}
|
|
327
|
+
const CARD_SIZES = [[1920, 1080, '16x9'], [1080, 1920, '9x16']];
|
|
328
|
+
const SQUARE = [1080, 1080, '1x1'];
|
|
329
|
+
async function drawCard(root, dir, name, { text, sub = '', small = '', look = {}, square = false }) {
|
|
330
|
+
const files = [];
|
|
331
|
+
for (const [w, h, tag] of [...CARD_SIZES, ...(square ? [SQUARE] : [])]) {
|
|
332
|
+
const out = join(dir, 'work', `card-${name}-${tag}.png`);
|
|
333
|
+
await htmlToPng(root, cardHtml({ text, sub, small, w, h, ...look }), out, { width: w, height: h });
|
|
334
|
+
files.push(rel(root, out));
|
|
335
|
+
}
|
|
336
|
+
return files;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function card(root) {
|
|
340
|
+
const slug = pos[1];
|
|
341
|
+
const dir = jobDir(root, slug);
|
|
342
|
+
const name = String(flags.get('name') ?? 'title');
|
|
343
|
+
if (!/^[a-z0-9-]+$/.test(name)) throw new Error('--name title|end|<word>');
|
|
344
|
+
const text = String(flags.get('text') ?? '');
|
|
345
|
+
if (!text) throw new Error('--text "<the words on the card>"');
|
|
346
|
+
// --game <id>: the card wears the game's look. --square: a 1:1 card too, for a square delivery.
|
|
347
|
+
const files = await drawCard(root, dir, name, { text, sub: flags.get('sub') ?? '', small: flags.get('small') ?? '', look: lookOf(root, flags.get('game')), square: flags.has('square') });
|
|
348
|
+
return { ok: true, command: 'card', slug, files };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** What the picture of a delivery is, read back from the file: frames, pixel format, range, colour tags. */
|
|
352
|
+
function pictureOf(file) {
|
|
353
|
+
const r = run('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-count_packets', '-show_entries', 'stream=nb_read_packets,pix_fmt,color_range,color_primaries,color_transfer,color_space,r_frame_rate', '-of', 'json', file]);
|
|
354
|
+
const v = r.code === 0 ? JSON.parse(r.stdout.toString('utf8')).streams?.[0] ?? {} : {};
|
|
355
|
+
return { frames: Number(v.nb_read_packets ?? NaN), pixFmt: v.pix_fmt ?? null, range: v.color_range ?? null, primaries: v.color_primaries ?? null, transfer: v.color_transfer ?? null, matrix: v.color_space ?? null, rate: v.r_frame_rate ?? null };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function cut(root) {
|
|
359
|
+
const slug = pos[1];
|
|
360
|
+
const dir = jobDir(root, slug);
|
|
361
|
+
const e = readJson(inJob(dir, flags.get('edl') ?? 'work/edl.json'), null);
|
|
362
|
+
if (!e) throw new Error('no edl: run edl first (or --edl <file>)');
|
|
363
|
+
const fps = e.fps ?? 30;
|
|
364
|
+
if (!(Number.isInteger(fps) && fps >= 1 && fps <= 120)) throw new Error('the edl\'s fps must be a whole number (30 by default)');
|
|
365
|
+
// Everything below counts in frames (and the sound in samples of 48 kHz): see frameSegments.
|
|
366
|
+
const segs = frameSegments(e.segments, fps);
|
|
367
|
+
const totalFrames = segs.reduce((a, s) => a + s.frames, 0);
|
|
368
|
+
const total = totalFrames / fps;
|
|
369
|
+
const SR = 48000;
|
|
370
|
+
const sampleAt = (frame) => Math.round((frame * SR) / fps);
|
|
371
|
+
const stamp = 'setpts=N/FRAME_RATE/TB'; // frame k at exactly k/fps: no timestamp inherited from the source survives
|
|
372
|
+
const outs = {};
|
|
373
|
+
// 16:9 and 9:16 always; 1:1 when the edit or --square asks. The tall and the square ones show the whole frame
|
|
374
|
+
// across the middle over a blurred, darkened fill of itself.
|
|
375
|
+
const SIZES = { '16x9': [1920, 1080, `${slug}.mp4`], '9x16': [1080, 1920, `${slug}-vertical.mp4`], '1x1': [1080, 1080, `${slug}-square.mp4`] };
|
|
376
|
+
const wanted = [...new Set(['16x9', '9x16', ...(e.deliveries ?? []), ...(flags.has('square') ? ['1x1'] : [])])].filter((t) => SIZES[t]);
|
|
377
|
+
// The game's own sound, cut with the picture: each clip's own sound track, or one file on the capture's clock
|
|
378
|
+
// (gameAudio.file: the effects bus of a rebuilt mix, so the music can run on under the cuts as the bed).
|
|
379
|
+
const gameFile = e.gameAudio?.file ? inJob(dir, e.gameAudio.file) : null;
|
|
380
|
+
if (gameFile && !existsSync(gameFile)) throw new Error(`the edit's gameAudio.file is missing: ${e.gameAudio.file}`);
|
|
381
|
+
for (const tag of wanted) {
|
|
382
|
+
const [W, H, fileName] = SIZES[tag];
|
|
383
|
+
const inputs = []; const chains = []; const labels = [];
|
|
384
|
+
const clipInput = new Map();
|
|
385
|
+
e.segments.forEach((s, i) => {
|
|
386
|
+
const n = segs[i].frames;
|
|
387
|
+
if (s.type === 'card') {
|
|
388
|
+
const png = inJob(dir, `${s.src}-${tag}.png`);
|
|
389
|
+
if (!existsSync(png)) throw new Error(`card ${s.src}-${tag}.png is missing: run card first`);
|
|
390
|
+
// Two frames more than needed are read, and exactly n are kept.
|
|
391
|
+
inputs.push('-loop', '1', '-framerate', String(fps), '-t', ((n + 2) / fps).toFixed(4), '-i', png);
|
|
392
|
+
const k = inputs.filter((x) => x === '-i').length - 1;
|
|
393
|
+
chains.push(`[${k}:v]scale=${W}:${H},setsar=1,fps=${fps},trim=end_frame=${n},${stamp},${BT709_TAIL}[v${i}]`);
|
|
394
|
+
} else {
|
|
395
|
+
const src = inJob(dir, s.src);
|
|
396
|
+
if (!clipInput.has(src)) { inputs.push('-i', src); clipInput.set(src, inputs.filter((x) => x === '-i').length - 1); }
|
|
397
|
+
const k = clipInput.get(src);
|
|
398
|
+
// The source at the edit's frame rate first, then a count of frames out of it; the range is made limited
|
|
399
|
+
// BT.709 before anything is blurred, dimmed or laid over anything else.
|
|
400
|
+
const first = Math.max(0, Math.round(s.in * fps));
|
|
401
|
+
let trim = `fps=${fps},trim=start_frame=${first}:end_frame=${first + n},${stamp},${BT709_TAIL}`;
|
|
402
|
+
if (s.push > 1) {
|
|
403
|
+
// zoom from 1 to s.push over the shot, centred on s.focus (kept inside the frame); upscaled first so the crop moves smoothly.
|
|
404
|
+
const [fx, fy] = s.focus ?? [0.5, 0.5];
|
|
405
|
+
const z = `(1+${(s.push - 1).toFixed(4)}*on/${n})`;
|
|
406
|
+
trim += `,scale=3840:2160:force_original_aspect_ratio=decrease,pad=3840:2160:(ow-iw)/2:(oh-ih)/2,zoompan=z='${z}':x='max(0,min(iw-iw/zoom,${fx}*iw-iw/zoom/2))':y='max(0,min(ih-ih/zoom,${fy}*ih-ih/zoom/2))':d=1:s=1920x1080:fps=${fps},${stamp}`;
|
|
407
|
+
}
|
|
408
|
+
if (tag === '16x9') chains.push(`[${k}:v]${trim},scale=${W}:${H}:force_original_aspect_ratio=decrease,pad=${W}:${H}:(ow-iw)/2:(oh-ih)/2,setsar=1,${BT709_TAIL}[v${i}]`);
|
|
409
|
+
else chains.push(`[${k}:v]${trim},split[a${i}][b${i}];[a${i}]scale=${W}:${H}:force_original_aspect_ratio=increase,crop=${W}:${H},boxblur=28:2,eq=brightness=-0.18[bg${i}];[b${i}]scale=${W}:-2[fg${i}];[bg${i}][fg${i}]overlay=(W-w)/2:(H-h)/2,setsar=1,${BT709_TAIL}[v${i}]`);
|
|
410
|
+
}
|
|
411
|
+
labels.push(`[v${i}]`);
|
|
412
|
+
});
|
|
413
|
+
const vgraph = `${chains.join(';')};${labels.join('')}concat=n=${labels.length}:v=1:a=0[vout]`;
|
|
414
|
+
// Sound: the bed, and the game's own sound under it for the clips (silence under the cards). The game's sound
|
|
415
|
+
// is cut by counts of samples at the same frame boundaries as the picture.
|
|
416
|
+
const aParts = []; let aMix = null;
|
|
417
|
+
let nIn = inputs.filter((x) => x === '-i').length;
|
|
418
|
+
if (e.bed) {
|
|
419
|
+
inputs.push('-i', inJob(dir, e.bed.file));
|
|
420
|
+
aParts.push(`[${nIn}:a]atrim=start=${e.bed.from.toFixed(4)}:duration=${total.toFixed(6)},asetpts=PTS-STARTPTS,aresample=${SR},volume=${e.bed.gainDb ?? 0}dB,afade=t=out:st=${Math.max(0, total - (e.bed.fadeOut ?? 1)).toFixed(3)}:d=${(e.bed.fadeOut ?? 1).toFixed(3)}[bed]`);
|
|
421
|
+
nIn++;
|
|
422
|
+
}
|
|
423
|
+
if (e.gameAudio) {
|
|
424
|
+
const pieces = [];
|
|
425
|
+
let gameIn = null;
|
|
426
|
+
if (gameFile) { inputs.push('-i', gameFile); gameIn = nIn; nIn++; }
|
|
427
|
+
e.segments.forEach((s, i) => {
|
|
428
|
+
const len = sampleAt(segs[i].startFrame + segs[i].frames) - sampleAt(segs[i].startFrame);
|
|
429
|
+
if (s.type === 'clip' && gameIn !== null) {
|
|
430
|
+
const from = sampleAt(Math.max(0, Math.round(s.in * fps)));
|
|
431
|
+
pieces.push(`[${gameIn}:a]aresample=${SR},aformat=channel_layouts=stereo,apad,atrim=start_sample=${from}:end_sample=${from + len},asetpts=N/SR/TB[g${i}]`);
|
|
432
|
+
} else if (s.type === 'clip' && clipInput.has(inJob(dir, s.src))) {
|
|
433
|
+
const from = sampleAt(Math.max(0, Math.round(s.in * fps)));
|
|
434
|
+
// apad: a capture whose sound ends before its picture still fills the segment, so the pieces after it do not slide.
|
|
435
|
+
pieces.push(`[${clipInput.get(inJob(dir, s.src))}:a]aresample=${SR},aformat=channel_layouts=stereo,apad,atrim=start_sample=${from}:end_sample=${from + len},asetpts=N/SR/TB[g${i}]`);
|
|
436
|
+
} else pieces.push(`anullsrc=r=${SR}:cl=stereo,atrim=end_sample=${len}[g${i}]`);
|
|
437
|
+
});
|
|
438
|
+
aParts.push(...pieces, `${e.segments.map((_, i) => `[g${i}]`).join('')}concat=n=${e.segments.length}:v=0:a=1,volume=${e.gameAudio.gainDb ?? 0}dB[game]`);
|
|
439
|
+
}
|
|
440
|
+
if (e.bed && e.gameAudio) aMix = '[bed][game]amix=inputs=2:normalize=0:duration=first[aout]';
|
|
441
|
+
else if (e.bed) aMix = '[bed]anull[aout]';
|
|
442
|
+
else if (e.gameAudio) aMix = '[game]anull[aout]';
|
|
443
|
+
const graph = [vgraph, ...aParts, ...(aMix ? [aMix] : [])].join(';');
|
|
444
|
+
const tmp = join(dir, 'work', `cut-${tag}-premix.mp4`);
|
|
445
|
+
ff([...inputs, '-filter_complex', graph, '-map', '[vout]', ...(aMix ? ['-map', '[aout]', '-c:a', 'pcm_s16le'] : []), '-c:v', 'libx264', '-preset', 'medium', '-crf', '14', '-r', String(fps), ...BT709_FLAGS, '-frames:v', String(totalFrames), '-t', total.toFixed(6), '-f', 'mov', tmp], `the ${tag} edit`);
|
|
446
|
+
const final = join(dir, fileName);
|
|
447
|
+
const kbps = deliveryKbps(total, hasStorage(root));
|
|
448
|
+
const vflags = ['-c:v', 'libx264', '-preset', 'slow', '-crf', '18', '-maxrate', `${kbps}k`, '-bufsize', `${kbps * 2}k`, '-r', String(fps), ...BT709_FLAGS, '-movflags', '+faststart'];
|
|
449
|
+
let meas = null;
|
|
450
|
+
if (aMix) {
|
|
451
|
+
// Two-pass loudness to -14 LUFS integrated, -1 dBTP: what the video page and the social sites play at.
|
|
452
|
+
const m = run('ffmpeg', ['-hide_banner', '-i', tmp, '-vn', '-af', 'loudnorm=I=-14:TP=-1.5:LRA=11:print_format=json', '-f', 'null', '-']).stderr;
|
|
453
|
+
try { meas = JSON.parse(m.slice(m.lastIndexOf('{'), m.lastIndexOf('}') + 1)); } catch { meas = null; }
|
|
454
|
+
const ln = meas ? `loudnorm=I=-14:TP=-1.5:LRA=11:measured_I=${meas.input_i}:measured_TP=${meas.input_tp}:measured_LRA=${meas.input_lra}:measured_thresh=${meas.input_thresh}:offset=${meas.target_offset}:linear=true` : 'loudnorm=I=-14:TP=-1.5:LRA=11';
|
|
455
|
+
ff(['-i', tmp, '-map', '0:v', '-map', '0:a', ...vflags, '-af', `${ln},aresample=48000`, '-c:a', 'aac', '-b:a', '192k', '-ar', '48000', final], `the ${tag} delivery`);
|
|
456
|
+
} else ff(['-i', tmp, '-map', '0:v', ...vflags, final], `the ${tag} delivery`);
|
|
457
|
+
rmSync(tmp, { force: true });
|
|
458
|
+
const f = probe(final);
|
|
459
|
+
outs[tag] = { file: rel(root, final), seconds: f.duration, width: f.video?.width, height: f.video?.height, picture: pictureOf(final), loudness: aMix ? loudness(final) : null, bytes: statSync(final).size, maxKbps: kbps };
|
|
460
|
+
}
|
|
461
|
+
// A poster: the middle of the busiest clip.
|
|
462
|
+
const clips = e.segments.map((s, i) => ({ ...s, i })).filter((s) => s.type === 'clip');
|
|
463
|
+
const best = clips.sort((a, b) => (b.motion ?? 0) - (a.motion ?? 0))[0];
|
|
464
|
+
const posterAt = best ? (segs[best.i].startFrame + segs[best.i].frames / 2) / fps : 0.5;
|
|
465
|
+
const poster = join(dir, 'poster.jpg');
|
|
466
|
+
ff(['-ss', posterAt.toFixed(3), '-i', join(root, outs['16x9'].file), '-frames:v', '1', '-vf', 'scale=1280:-2', '-q:v', '3', poster], 'the poster');
|
|
467
|
+
// Where each cut is, in frames, and how far that is from where the edit asked for it (never over half a frame).
|
|
468
|
+
const cuts = segs.slice(1).map((s) => ({ frame: s.startFrame, at: +(s.startFrame / fps).toFixed(6), asked: +s.at.toFixed(4), offFrames: +(s.startFrame - s.at * fps).toFixed(3) }));
|
|
469
|
+
const problems = [];
|
|
470
|
+
for (const [tag, o] of Object.entries(outs)) {
|
|
471
|
+
if (o.picture.frames !== totalFrames) problems.push(`${tag}: ${o.picture.frames} frames, the edit is ${totalFrames} (a clip's source ended before its out point?)`);
|
|
472
|
+
if (o.picture.pixFmt !== 'yuv420p' || o.picture.range !== 'tv') problems.push(`${tag}: ${o.picture.pixFmt}, range ${o.picture.range ?? 'untagged'} (wanted yuv420p, limited)`);
|
|
473
|
+
if (o.picture.primaries !== 'bt709' || o.picture.transfer !== 'bt709' || o.picture.matrix !== 'bt709') problems.push(`${tag}: colour tags ${o.picture.primaries ?? 'unset'}/${o.picture.transfer ?? 'unset'}/${o.picture.matrix ?? 'unset'} (wanted bt709 for all three)`);
|
|
474
|
+
}
|
|
475
|
+
writeJson(join(dir, 'cut.json'), { at: new Date().toISOString(), edl: e, fps, frames: totalFrames, cuts, outputs: outs, poster: rel(root, poster), problems });
|
|
476
|
+
return { ok: problems.length === 0 && Object.values(outs).every((o) => Math.abs(o.seconds - total) < 0.1), command: 'cut', slug, seconds: +total.toFixed(3), fps, frames: totalFrames, cuts, outputs: outs, poster: rel(root, poster), ...(problems.length ? { problems, why: problems.join('; ') } : {}) };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/* ---------------------------------------------------------------- the trailer, in one command */
|
|
480
|
+
|
|
481
|
+
/** The shell's own furniture (status chip, join card, results card) is not the game: hidden for the film, as capture does. */
|
|
482
|
+
const FURNITURE = '[data-chip],[data-join],[data-screen],[data-results],[data-room-ui],[data-toast]{display:none!important} *{cursor:none!important}';
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* An edit from what the game PLAYED. The highlights are the shots with the most going on in the sound log (a rare
|
|
486
|
+
* sound counts for more than a common one: lib/remix.mjs eventScores), with the picture's motion as the smaller
|
|
487
|
+
* part, so a still moment with a fanfare beats a busy one with nothing happening in the game.
|
|
488
|
+
* When the game played music, a shot is a bar of it and the music runs on under the cuts as the bed, from one of
|
|
489
|
+
* its own bar lines; the game's effects are cut with the picture. Cards: an optional title, always an end card.
|
|
490
|
+
*/
|
|
491
|
+
function trailerEdl(root, dir, { cap, capInfo, log, length, title, shotFlag, push }) {
|
|
492
|
+
const capDur = probe(cap).duration;
|
|
493
|
+
if (!(length >= 5 && length <= 180)) throw new Error('--length 5..180 seconds');
|
|
494
|
+
const starts = log.events.filter((e) => e.type === 'start');
|
|
495
|
+
const tune = starts.find((e) => e.bus === 'music' && Number(e.bar) > 0) ?? null;
|
|
496
|
+
const bar = tune ? Number(tune.bar) : null;
|
|
497
|
+
const shot = shotFlag > 0 ? shotFlag : bar ? (bar > 2.6 ? bar / 2 : bar < 1.2 ? bar * 2 : bar) : 2;
|
|
498
|
+
const titleDur = title ? (bar ? Math.ceil(1.5 / bar - 1e-9) * bar : 1.5) : 0;
|
|
499
|
+
const endMin = Math.max(2.5, shot);
|
|
500
|
+
const nClips = Math.floor((length - titleDur - endMin) / shot + 1e-6);
|
|
501
|
+
if (nClips < 1) throw new Error(`--length ${length} is too short for one ${shot.toFixed(2)} s shot and an end card`);
|
|
502
|
+
if (capDur < shot + 0.2) throw new Error(`the capture is ${capDur.toFixed(1)} s, shorter than one shot`);
|
|
503
|
+
const endDur = +(length - titleDur - nClips * shot).toFixed(4);
|
|
504
|
+
const mo = motionOf(cap);
|
|
505
|
+
const ev = eventScores(log.events, capDur);
|
|
506
|
+
const win = Math.round(shot * 10);
|
|
507
|
+
const sum = (arr, s) => { let v = 0; for (let k = s; k < s + win && k < arr.length; k++) v += arr[k]; return v; };
|
|
508
|
+
const raw = [];
|
|
509
|
+
for (let s = 0; s + win <= Math.min(mo.m.length, Math.floor(capDur * 10)); s++) raw.push({ at: s / 10, e: sum(ev.scores, s), m: sum(mo.m, s) / win });
|
|
510
|
+
if (!raw.length) throw new Error('the capture is shorter than one shot');
|
|
511
|
+
const maxE = Math.max(...raw.map((r) => r.e)); const maxM = Math.max(...raw.map((r) => r.m), 1e-6);
|
|
512
|
+
const heard = maxE > 0;
|
|
513
|
+
const scored = raw.map((r) => ({ at: r.at, v: (heard ? r.e / maxE : 0) + (heard ? 0.35 : 1) * (r.m / maxM) }));
|
|
514
|
+
const picked = [];
|
|
515
|
+
const mid = (at) => Math.round(at * 10 + win / 2);
|
|
516
|
+
const ranked = [...scored].sort((x, y) => y.v - x.v);
|
|
517
|
+
for (const c of ranked) {
|
|
518
|
+
if (picked.length >= nClips) break;
|
|
519
|
+
if (picked.some((p) => Math.abs(p.at - c.at) < shot)) continue;
|
|
520
|
+
if (picked.some((p) => mo.differ(mid(p.at), mid(c.at)) < 4)) continue;
|
|
521
|
+
picked.push(c);
|
|
522
|
+
}
|
|
523
|
+
// A capture with too few different moments: shots that do not overlap, then (a short capture) shots that do.
|
|
524
|
+
for (const c of ranked) { if (picked.length >= nClips) break; if (!picked.some((p) => Math.abs(p.at - c.at) < shot)) picked.push(c); }
|
|
525
|
+
for (const c of ranked) { if (picked.length >= nClips) break; if (!picked.includes(c)) picked.push(c); }
|
|
526
|
+
picked.sort((x, y) => x.at - y.at);
|
|
527
|
+
const namesIn = (at) => { const out = new Map(); for (let k = Math.round(at * 10); k < Math.round(at * 10) + win && k < ev.names.length; k++) for (const n of ev.names[k]) out.set(n, (out.get(n) ?? 0) + 1); return Object.fromEntries(out); };
|
|
528
|
+
const segments = [];
|
|
529
|
+
if (titleDur) segments.push({ type: 'card', src: 'work/card-title', dur: +titleDur.toFixed(4) });
|
|
530
|
+
for (const p of picked) segments.push({ type: 'clip', src: rel(dir, cap), in: +p.at.toFixed(3), dur: +shot.toFixed(4), score: +p.v.toFixed(3), played: namesIn(p.at), ...(push > 1 ? { push: +push.toFixed(3), focus: mo.focus(Math.round(p.at * 10), Math.round(p.at * 10) + win) } : {}) });
|
|
531
|
+
segments.push({ type: 'card', src: 'work/card-end', dur: endDur });
|
|
532
|
+
// The bed: the game's own music bus, from one of its bar lines, as long as the edit.
|
|
533
|
+
const stem = (name) => { const f = join(dirname(cap), name); return existsSync(f) ? f : null; };
|
|
534
|
+
const musicStem = tune ? stem('music.wav') : null;
|
|
535
|
+
let bed = null;
|
|
536
|
+
if (musicStem) {
|
|
537
|
+
const t0 = tune.t + (tune.delay ?? 0);
|
|
538
|
+
let from = t0 >= 0 ? t0 : t0 + Math.ceil(-t0 / bar - 1e-9) * bar;
|
|
539
|
+
while (from + length > capDur && from - bar >= 0) from -= bar;
|
|
540
|
+
if (from + length <= capDur + 0.05) bed = { file: rel(dir, musicStem), from: +from.toFixed(4), fadeOut: Math.min(1.5, endDur), gainDb: 0 };
|
|
541
|
+
}
|
|
542
|
+
const sfxStem = stem('sfx.wav');
|
|
543
|
+
const edl = {
|
|
544
|
+
fps: capInfo.fps ?? 30, length, bpm: bar ? +((60 * 4) / bar).toFixed(2) : null, bar: bar ? +bar.toFixed(4) : null, deliveries: ['16x9', '1x1', '9x16'],
|
|
545
|
+
bed,
|
|
546
|
+
// With a bed made of the game's music, the effects alone are cut with the picture; without one, the whole mix is.
|
|
547
|
+
gameAudio: bed && sfxStem ? { file: rel(dir, sfxStem), gainDb: 0 } : capInfo.audio?.peakDb != null ? { gainDb: 0 } : null,
|
|
548
|
+
segments,
|
|
549
|
+
picked: heard ? 'by what the game played (its sound log), then by motion' : 'by motion only: the game logged no sound effects',
|
|
550
|
+
honesty: capInfo.honesty ?? null,
|
|
551
|
+
};
|
|
552
|
+
return { edl, shots: picked.length, shot, titleDur, endDur, heard, musicCut: Boolean(tune) && !bed };
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
async function trailer(root) {
|
|
556
|
+
const slug = pos[1];
|
|
557
|
+
const dir = jobDir(root, slug);
|
|
558
|
+
const game = String(flags.get('game') ?? '');
|
|
559
|
+
if (!/^[a-z0-9][a-z0-9-]{0,39}$/.test(game)) throw new Error('--game <id>: the game to film');
|
|
560
|
+
const sourceDir = experienceDir(root, game);
|
|
561
|
+
const serverRules = isServerRules(sourceDir);
|
|
562
|
+
const out = join(dir, 'work', 'capture');
|
|
563
|
+
const length = Number(flags.get('length') ?? 20);
|
|
564
|
+
const seconds = Number(flags.get('seconds') ?? Math.max(30, length * 2));
|
|
565
|
+
if (!(seconds >= length)) throw new Error(`--seconds ${seconds} of game cannot fill a ${length} s trailer: film at least as long as the cut, better two or three times`);
|
|
566
|
+
// 1. The game, frame by frame, and its sound rebuilt (record-fixed.mjs). --keep-capture edits the one already there.
|
|
567
|
+
if (!(flags.has('keep-capture') && existsSync(join(out, 'capture.mp4')) && existsSync(join(out, 'events.json')))) {
|
|
568
|
+
const site = String(flags.get('url') ?? readJson(join(root, 'studio.json'), {}).cloudflare?.url ?? '').replace(/\/+$/, '');
|
|
569
|
+
if (!/^https?:\/\//.test(site)) throw new Error('--url <site>: http://127.0.0.1:8787 while `npm run dev` runs, or the live site');
|
|
570
|
+
if (serverRules && (flags.has('steps') || flags.has('page') || flags.has('no-pace'))) throw new Error('Server rules keep real time. For scripted footage use video record with your steps, then edl and cut; omit --steps/--page/--no-pace for a live-room trailer.');
|
|
571
|
+
const own = flags.get('page');
|
|
572
|
+
const page = own ? String(own) : `/${game}/tv`;
|
|
573
|
+
const args = serverRules
|
|
574
|
+
? [join(HERE, 'capture-game.mjs'), '--url', site, '--game', game, '--view', String(flags.get('view') ?? 'play'), '--out', out, '--seconds', String(seconds)]
|
|
575
|
+
: [join(HERE, 'record-fixed.mjs'), '--url', `${site}${page.startsWith('/') ? '' : '/'}${page}`, '--out', out, '--seconds', String(seconds)];
|
|
576
|
+
if (!serverRules && !own) args.push('--frame', 'game', '--css', FURNITURE, '--settle', String(flags.get('settle') ?? 4));
|
|
577
|
+
for (const k of ['fps', 'scale', 'width', 'height', 'min-free-gb', ...(serverRules ? ['settle'] : ['steps', 'wait-for', ...(own ? ['settle', 'frame'] : [])])]) if (flags.has(k) && flags.get(k) !== true) args.push(`--${k}`, String(flags.get(k)));
|
|
578
|
+
if (!serverRules && flags.has('no-pace')) args.push('--no-pace');
|
|
579
|
+
const r = spawnSync(process.execPath, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'], timeout: 60 * 60_000 });
|
|
580
|
+
let j = null; try { j = JSON.parse(r.stdout.trim().split('\n').pop()); } catch { /* */ }
|
|
581
|
+
if (!j?.ok) throw new Error(j?.failed ? `the recording stopped at step ${j.failed.step} (${j.failed.do}): ${j.failed.why}` : 'the gameplay recording failed (its log is above)');
|
|
582
|
+
}
|
|
583
|
+
if (serverRules && !existsSync(join(out, 'events.json'))) writeJson(join(out, 'events.json'), { events: [] });
|
|
584
|
+
const cap = join(out, 'capture.mp4');
|
|
585
|
+
const capInfo = readJson(join(out, 'capture.json'), {});
|
|
586
|
+
const captureMode = capInfo.mode ?? (capInfo.realSeconds === undefined ? 'live' : 'virtual');
|
|
587
|
+
const log = readJson(join(out, 'events.json'), { events: [] });
|
|
588
|
+
// 2. The edit, from the sound log.
|
|
589
|
+
const title = flags.get('title') && flags.get('title') !== true ? String(flags.get('title')) : null;
|
|
590
|
+
const made = trailerEdl(root, dir, { cap, capInfo, log, length, title, shotFlag: Number(flags.get('shot') ?? 0), push: flags.has('push') ? Number(flags.get('push')) : 1.25 });
|
|
591
|
+
writeJson(join(dir, 'work', 'edl.json'), made.edl);
|
|
592
|
+
// 3. The cards, in the game's look, at all three shapes.
|
|
593
|
+
const look = lookOf(root, game);
|
|
594
|
+
const gameName = readJson(experienceJson(experienceDir(root, game)), {}).name ?? game;
|
|
595
|
+
const end = flags.get('end') && flags.get('end') !== true ? String(flags.get('end')) : `Play ${gameName}`;
|
|
596
|
+
const sub = flags.get('sub') && flags.get('sub') !== true ? String(flags.get('sub')) : (readJson(join(root, 'studio.json'), {}).cloudflare?.url ? `${readJson(join(root, 'studio.json'), {}).cloudflare.url.replace(/^https?:\/\//, '').replace(/\/+$/, '')}/${game}` : '');
|
|
597
|
+
if (title) await drawCard(root, dir, 'title', { text: title, look, square: true });
|
|
598
|
+
await drawCard(root, dir, 'end', { text: end, sub, small: flags.get('small') && flags.get('small') !== true ? String(flags.get('small')) : (captureMode === 'live' ? 'Real gameplay, recorded live.' : 'Real gameplay, rendered frame by frame.'), look, square: true });
|
|
599
|
+
// 4. The three deliveries.
|
|
600
|
+
flags.set('square', true); flags.delete('edl');
|
|
601
|
+
const c = await cut(root);
|
|
602
|
+
const warnings = [...(capInfo.warnings ?? []), ...(capInfo.advice ? [capInfo.advice] : [])];
|
|
603
|
+
if (!made.heard) warnings.push('the game logged no sound effects, so the shots were picked by motion alone');
|
|
604
|
+
if (made.musicCut) warnings.push(`the game's music could not run on under the cuts: from its first bar line the capture does not hold ${length} s of it, so the music is cut with the picture and jumps at every cut. Film longer (--seconds), two or three times the trailer's length.`);
|
|
605
|
+
return {
|
|
606
|
+
ok: c.ok, command: 'trailer', slug, game, seconds: c.seconds, frames: c.frames, fps: c.fps,
|
|
607
|
+
capture: { mode: captureMode, file: rel(root, cap), seconds: capInfo.seconds, frames: capInfo.outputFrames, heldFrames: capInfo.heldFrames ?? 0, realSeconds: capInfo.realSeconds, speed: capInfo.speed, soundEvents: capInfo.soundEvents ?? log.events.length, audio: capInfo.audio ? { rebuilt: capInfo.audio.rebuilt ?? false, placed: capInfo.audio.placed, missing: capInfo.audio.missing?.length ?? 0, peakDb: capInfo.audio.peakDb } : null },
|
|
608
|
+
edit: { file: rel(root, join(dir, 'work', 'edl.json')), shots: made.shots, shotSeconds: +made.shot.toFixed(3), title: made.titleDur, end: made.endDur, picked: made.edl.picked, bed: made.edl.bed ? 'the game\'s own music, running on under the cuts' : null, shotsPlayed: made.edl.segments.filter((x) => x.type === 'clip').map((x) => ({ in: x.in, played: x.played })) },
|
|
609
|
+
cuts: c.cuts, outputs: c.outputs, poster: c.poster, ...(warnings.length ? { warnings } : {}), ...(c.problems ? { problems: c.problems, why: c.why } : {}),
|
|
610
|
+
honesty: capInfo.honesty ?? null,
|
|
611
|
+
next: `look at it (sheet ${slug} --in ${c.outputs['16x9'].file} --every 0.5), check it (qa.mjs), then: add ${slug} --title "<title>" --kind trailer --for-game ${game} --publish`,
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/* ---------------------------------------------------------------- the draw-over film */
|
|
616
|
+
|
|
617
|
+
function serve(dir) {
|
|
618
|
+
const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.json': 'application/json', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.otf': 'font/otf', '.svg': 'image/svg+xml' };
|
|
619
|
+
const server = createServer((req, res) => {
|
|
620
|
+
const p = decodeURIComponent(new URL(req.url, 'http://x').pathname);
|
|
621
|
+
const f = join(dir, p);
|
|
622
|
+
if (!f.startsWith(dir) || !existsSync(f) || statSync(f).isDirectory()) { res.writeHead(404); res.end(); return; }
|
|
623
|
+
res.writeHead(200, { 'content-type': MIME[extname(f).toLowerCase()] ?? 'application/octet-stream', 'cache-control': 'no-store' });
|
|
624
|
+
createReadStream(f).pipe(res);
|
|
625
|
+
});
|
|
626
|
+
return new Promise((ok) => server.listen(0, '127.0.0.1', () => ok({ server, port: server.address().port })));
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
async function film(root) {
|
|
630
|
+
const sub = pos[1]; const slug = pos[2];
|
|
631
|
+
const dir = jobDir(root, slug);
|
|
632
|
+
const filmDir = join(dir, 'film');
|
|
633
|
+
if (sub === 'init') {
|
|
634
|
+
mkdirSync(filmDir, { recursive: true });
|
|
635
|
+
const tpl = join(HERE, 'drawover');
|
|
636
|
+
const wrote = [];
|
|
637
|
+
for (const f of readdirSync(tpl)) { if (!existsSync(join(filmDir, f))) { copyFileSync(join(tpl, f), join(filmDir, f)); wrote.push(`film/${f}`); } }
|
|
638
|
+
return { ok: true, command: 'film init', slug, wrote, next: 'Edit film/shots.json (one entry per shot on the bar grid) and film/look.js (the look), then `film render`. Bases are extracted with `frames`.' };
|
|
639
|
+
}
|
|
640
|
+
if (sub === 'frames') {
|
|
641
|
+
const base = resolve(String(flags.get('base') ?? ''));
|
|
642
|
+
if (!existsSync(base)) throw new Error('--base <generated clip.mp4>');
|
|
643
|
+
const id = String(flags.get('id') ?? base.split('/').pop().replace(/\.[a-z0-9]+$/i, ''));
|
|
644
|
+
const out = join(dir, 'work', 'frames', id);
|
|
645
|
+
rmSync(out, { recursive: true, force: true }); mkdirSync(out, { recursive: true });
|
|
646
|
+
ff(['-i', base, '-vf', 'fps=24', '-q:v', '3', join(out, 'f%05d.jpg')], 'frame extraction');
|
|
647
|
+
return { ok: true, command: 'film frames', id, frames: readdirSync(out).length, dir: rel(root, out) };
|
|
648
|
+
}
|
|
649
|
+
if (sub !== 'render') throw new Error('film init <slug> | film frames <slug> --base <clip> | film render <slug>');
|
|
650
|
+
const mode = flags.get('mode') === 'v' ? 'v' : 'h';
|
|
651
|
+
const [W, H] = mode === 'v' ? [1080, 1920] : [1920, 1080];
|
|
652
|
+
const shots = readJson(join(filmDir, 'shots.json'), null);
|
|
653
|
+
if (!shots) throw new Error('no film/shots.json: run film init first');
|
|
654
|
+
const fps = shots.fps ?? 24;
|
|
655
|
+
const from = Number(flags.get('from') ?? 0); const to = Number(flags.get('to') ?? shots.length);
|
|
656
|
+
const frames = join(dir, 'work', `film-${mode}`);
|
|
657
|
+
rmSync(frames, { recursive: true, force: true }); mkdirSync(frames, { recursive: true });
|
|
658
|
+
const { server, port } = await serve(dir);
|
|
659
|
+
let browser, close;
|
|
660
|
+
try { ({ browser, close } = await launch(root, { width: W, height: H })); }
|
|
661
|
+
catch (error) { server.close(); throw error; }
|
|
662
|
+
try {
|
|
663
|
+
const page = await browser.newPage();
|
|
664
|
+
await page.setViewport({ width: W, height: H, deviceScaleFactor: 1 });
|
|
665
|
+
const errs = []; page.on('pageerror', (e) => errs.push(e.message));
|
|
666
|
+
await page.goto(`http://127.0.0.1:${port}/film/index.html?mode=${mode}`, { waitUntil: 'networkidle0' });
|
|
667
|
+
await page.waitForFunction(() => window.READY === true || window.FAIL, { timeout: 120_000 });
|
|
668
|
+
const failed = await page.evaluate(() => window.FAIL);
|
|
669
|
+
if (failed) throw new Error(`the film page failed: ${failed}`);
|
|
670
|
+
const n0 = Math.round(from * fps); const n1 = Math.round(to * fps);
|
|
671
|
+
for (let n = n0; n < n1; n++) {
|
|
672
|
+
const url = await page.evaluate((t) => window.renderAt(t), n / fps);
|
|
673
|
+
writeFileSync(join(frames, `f${String(n - n0).padStart(5, '0')}.jpg`), Buffer.from(url.slice(url.indexOf(',') + 1), 'base64'));
|
|
674
|
+
if ((n - n0) % (fps * 5) === 0) say(`film ${mode}: ${((n - n0) / fps).toFixed(0)} s of ${(to - from).toFixed(0)}`);
|
|
675
|
+
}
|
|
676
|
+
if (errs.length) say(`page errors: ${errs.slice(0, 3).join(' | ')}`);
|
|
677
|
+
} finally { await close(); server.close(); }
|
|
678
|
+
const audio = shots.audio ? inJob(dir, shots.audio) : null;
|
|
679
|
+
const out = join(dir, 'work', `film-${mode}.mp4`);
|
|
680
|
+
// A drawn, boiling print costs a lot of bits; the cap keeps the delivery servable (25 MiB a file with no storage).
|
|
681
|
+
const kbps = deliveryKbps(to - from, hasStorage(root));
|
|
682
|
+
ff(['-framerate', String(fps), '-i', join(frames, 'f%05d.jpg'), ...(audio ? ['-ss', from.toFixed(4), '-i', audio, '-map', '0:v', '-map', '1:a', '-c:a', 'aac', '-b:a', '192k', '-ar', '48000', '-shortest'] : []),
|
|
683
|
+
'-vf', `format=rgb24,${BT709_TAIL}`, '-c:v', 'libx264', '-preset', 'medium', '-crf', '18', '-maxrate', `${kbps}k`, '-bufsize', `${kbps * 2}k`, ...BT709_FLAGS, '-movflags', '+faststart', out], 'the film encode');
|
|
684
|
+
rmSync(frames, { recursive: true, force: true });
|
|
685
|
+
return { ok: true, command: 'film render', slug, mode, file: rel(root, out), seconds: probe(out).duration, bytes: statSync(out).size, maxKbps: kbps };
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/* ---------------------------------------------------------------- review: sheets, words, sync */
|
|
689
|
+
|
|
690
|
+
function stillsAt(file, times, width) {
|
|
691
|
+
const tmp = join(dirname(file), `.stills-${process.pid}`);
|
|
692
|
+
rmSync(tmp, { recursive: true, force: true }); mkdirSync(tmp, { recursive: true });
|
|
693
|
+
const out = [];
|
|
694
|
+
times.forEach((t, i) => {
|
|
695
|
+
const f = join(tmp, `${String(i).padStart(4, '0')}.jpg`);
|
|
696
|
+
const r = run('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-y', '-ss', Math.max(0, t).toFixed(3), '-i', file, '-frames:v', '1', '-vf', `scale=${width}:-2`, '-q:v', '4', f]);
|
|
697
|
+
out.push(r.code === 0 && existsSync(f) ? `data:image/jpeg;base64,${readFileSync(f).toString('base64')}` : null);
|
|
698
|
+
});
|
|
699
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
700
|
+
return out;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
async function sheetOf(root, file, cells, out, { cols = 6, cellW = 300, title = '' } = {}) {
|
|
704
|
+
const imgs = stillsAt(file, cells.map((c) => c.t), cellW);
|
|
705
|
+
const f = probe(file);
|
|
706
|
+
const aspect = f.video ? f.video.height / f.video.width : 9 / 16;
|
|
707
|
+
const cellH = Math.round(cellW * aspect);
|
|
708
|
+
const rows = Math.ceil(cells.length / cols);
|
|
709
|
+
const W = cols * (cellW + 8) + 8; const Hh = rows * (cellH + 30) + 44;
|
|
710
|
+
const html = `<!doctype html><html><head><meta charset="utf-8"><style>body{margin:0;background:#15161b;color:#ddd;font:14px ui-monospace,Menlo,monospace}
|
|
711
|
+
h1{font-size:15px;margin:10px 10px 8px;color:#ffd166}.g{display:grid;grid-template-columns:repeat(${cols},${cellW}px);gap:8px;padding:0 8px}
|
|
712
|
+
figure{margin:0}img{width:${cellW}px;height:${cellH}px;object-fit:cover;display:block;background:#000}figcaption{height:22px;line-height:22px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}</style></head>
|
|
713
|
+
<body><h1>${esc(title)}</h1><div class="g">${cells.map((c, i) => `<figure>${imgs[i] ? `<img src="${imgs[i]}">` : '<div style="height:' + cellH + 'px"></div>'}<figcaption>${esc(c.label)}</figcaption></figure>`).join('')}</div></body></html>`;
|
|
714
|
+
await htmlToPng(root, html, out, { width: W, height: Hh });
|
|
715
|
+
return out;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
async function sheet(root) {
|
|
719
|
+
const slug = pos[1];
|
|
720
|
+
const dir = jobDir(root, slug);
|
|
721
|
+
const file = resolve(String(flags.get('in') ?? join(dir, `${slug}.mp4`)));
|
|
722
|
+
if (!existsSync(file)) throw new Error('--in <video file>');
|
|
723
|
+
const every = Number(flags.get('every') ?? 1);
|
|
724
|
+
const d = probe(file).duration;
|
|
725
|
+
const cells = []; for (let t = 0.02; t < d; t += every) cells.push({ t, label: `${t.toFixed(2)} s` });
|
|
726
|
+
const out = join(dir, 'work', `sheet-${file.split('/').pop().replace(/\.[a-z0-9]+$/i, '')}.png`);
|
|
727
|
+
await sheetOf(root, file, cells, out, { cols: probe(file).video?.height > probe(file).video?.width ? 10 : 6, cellW: probe(file).video?.height > probe(file).video?.width ? 160 : 300, title: `${rel(root, file)} · every ${every} s` });
|
|
728
|
+
return { ok: true, command: 'sheet', file: rel(root, out), frames: cells.length, look: 'Open the PNG and look at every cell before anyone else sees the video.' };
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
async function words(root) {
|
|
732
|
+
const slug = pos[1];
|
|
733
|
+
const dir = jobDir(root, slug);
|
|
734
|
+
const file = resolve(String(flags.get('in') ?? ''));
|
|
735
|
+
const g = readJson(join(dir, 'grid.json'), null);
|
|
736
|
+
if (!existsSync(file) || !g) throw new Error('--in <video>, after grid (the sung words come from the song the music skill made)');
|
|
737
|
+
const shift = Number(flags.get('offset') ?? 0); // where the song starts in this video
|
|
738
|
+
const ws = g.words.filter((w) => w.s + shift < probe(file).duration);
|
|
739
|
+
if (!ws.length) throw new Error('no sung words with times for this song (an instrumental, or a song not made with the music skill)');
|
|
740
|
+
const cells = ws.map((w) => ({ t: w.s + shift + 0.04, label: `${(w.s + shift).toFixed(2)} ${w.w}` }));
|
|
741
|
+
const out = join(dir, 'work', 'words.png');
|
|
742
|
+
await sheetOf(root, file, cells, out, { cols: 8, cellW: 220, title: 'Every sung word, at its onset: the mouth and the lyric type must match the word' });
|
|
743
|
+
return { ok: true, command: 'words', file: rel(root, out), words: cells.length };
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** Sound onset vs picture change near each expected hit: both should land on the same frame. */
|
|
747
|
+
function sync(root) {
|
|
748
|
+
const slug = pos[1];
|
|
749
|
+
const dir = jobDir(root, slug);
|
|
750
|
+
const file = resolve(String(flags.get('in') ?? join(dir, `${slug}.mp4`)));
|
|
751
|
+
if (!existsSync(file)) throw new Error('--in <video file>');
|
|
752
|
+
let at = flags.get('at') ? String(flags.get('at')).split(',').map(Number) : null;
|
|
753
|
+
if (!at) {
|
|
754
|
+
const e = readJson(join(dir, 'work', 'edl.json'), null);
|
|
755
|
+
if (!e) throw new Error('--at t1,t2,… (or cut from an edl)');
|
|
756
|
+
// The cuts are where `cut` put them: on whole frames (frameSegments), not at the running sum of durations.
|
|
757
|
+
at = frameSegments(e.segments, e.fps ?? 30).slice(1).map((s) => s.startFrame / (e.fps ?? 30));
|
|
758
|
+
}
|
|
759
|
+
const f = probe(file);
|
|
760
|
+
const fps = f.video?.fps ?? 30;
|
|
761
|
+
const sr = 22050;
|
|
762
|
+
const x = decodeMono(file, { rate: sr });
|
|
763
|
+
const on = energyOnsets(x, sr);
|
|
764
|
+
const r = run('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-i', file, '-vf', `scale=96:54,format=gray`, '-f', 'rawvideo', '-']);
|
|
765
|
+
const px = 96 * 54; const nF = Math.floor(r.stdout.length / px);
|
|
766
|
+
const diff = new Float32Array(nF);
|
|
767
|
+
for (let i = 1; i < nF; i++) { let s = 0; for (let k = 0; k < px; k++) s += Math.abs(r.stdout[i * px + k] - r.stdout[(i - 1) * px + k]); diff[i] = s / px; }
|
|
768
|
+
// Only an onset within two frames of the hit is the hit's sound: a pickup note just before a downbeat is music, not lag.
|
|
769
|
+
const near = 2 / fps + 0.01;
|
|
770
|
+
const rows = at.map((t) => {
|
|
771
|
+
let bestI = -1; let bestV = -1;
|
|
772
|
+
for (let i = Math.max(0, Math.round((t - near - on.offset) * on.rate)); i <= Math.min(on.env.length - 1, Math.round((t + near - on.offset) * on.rate)); i++) if (on.env[i] > bestV) { bestV = on.env[i]; bestI = i; }
|
|
773
|
+
const sound = bestI >= 0 && bestV > 0.2 ? bestI / on.rate + on.offset : null;
|
|
774
|
+
const f0 = Math.round(t * fps);
|
|
775
|
+
let pk = -1; let pv = -1;
|
|
776
|
+
for (let i = Math.max(1, f0 - 5); i <= Math.min(nF - 1, f0 + 5); i++) if (diff[i] > pv) { pv = diff[i]; pk = i; }
|
|
777
|
+
const soundFrame = sound === null ? null : Math.round(sound * fps);
|
|
778
|
+
const clearCut = pv > 3;
|
|
779
|
+
return { at: +t.toFixed(3), expectedFrame: f0, soundAt: sound === null ? null : +sound.toFixed(3), soundFrame, pictureFrame: clearCut ? pk : null, offsetFrames: soundFrame === null || !clearCut ? null : pk - soundFrame, cutOnFrame: clearCut ? Math.abs(pk - f0) <= 1 : null, soundStrength: +bestV.toFixed(2), pictureChange: +pv.toFixed(1) };
|
|
780
|
+
});
|
|
781
|
+
const judged = rows.filter((x) => x.offsetFrames !== null);
|
|
782
|
+
const bad = judged.filter((x) => Math.abs(x.offsetFrames) > 1);
|
|
783
|
+
const offCut = rows.filter((x) => x.cutOnFrame === false);
|
|
784
|
+
return { ok: bad.length === 0 && offCut.length === 0, command: 'sync', file: rel(root, file), fps, judged: judged.length, of: rows.length, outOfSync: bad.length, cutsOffTheirFrame: offCut.length, rows, rule: 'a cut or hit is in sync when the picture changes within one frame of a sound onset within two frames of it; a hit with no onset that close (a rest, or a pickup note before the bar) or no clear picture change is listed, not judged. Never move a cut off its bar to satisfy this check: fix the edit or the music instead' };
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/* ---------------------------------------------------------------- add / publish */
|
|
788
|
+
|
|
789
|
+
function add(root) {
|
|
790
|
+
const slug = pos[1];
|
|
791
|
+
const dir = jobDir(root, slug);
|
|
792
|
+
const title = flags.get('title');
|
|
793
|
+
if (!title) throw new Error('--title "<title>"');
|
|
794
|
+
const main = join(dir, `${slug}.mp4`);
|
|
795
|
+
const src = flags.get('file') ? resolve(String(flags.get('file'))) : null;
|
|
796
|
+
if (src && src !== main) copyFileSync(src, main);
|
|
797
|
+
if (!existsSync(main)) throw new Error(`videos/${slug}/${slug}.mp4 is missing: cut it first (or --file <the finished 16:9 file>)`);
|
|
798
|
+
const vertical = flags.get('vertical') ? resolve(String(flags.get('vertical'))) : join(dir, `${slug}-vertical.mp4`);
|
|
799
|
+
if (flags.get('vertical') && vertical !== join(dir, `${slug}-vertical.mp4`)) copyFileSync(vertical, join(dir, `${slug}-vertical.mp4`));
|
|
800
|
+
const files = [{ role: 'video', path: rel(root, main), type: 'video/mp4', bytes: statSync(main).size }];
|
|
801
|
+
if (existsSync(join(dir, `${slug}-vertical.mp4`))) files.push({ role: 'vertical', path: rel(root, join(dir, `${slug}-vertical.mp4`)), type: 'video/mp4', bytes: statSync(join(dir, `${slug}-vertical.mp4`)).size });
|
|
802
|
+
if (!existsSync(join(dir, 'poster.jpg'))) ff(['-ss', '1', '-i', main, '-frames:v', '1', '-vf', 'scale=1280:-2', '-q:v', '3', join(dir, 'poster.jpg')], 'the poster');
|
|
803
|
+
files.push({ role: 'poster', path: rel(root, join(dir, 'poster.jpg')), type: 'image/jpeg' });
|
|
804
|
+
if (existsSync(join(dir, 'captions.vtt'))) files.push({ role: 'captions', path: rel(root, join(dir, 'captions.vtt')), type: 'text/vtt' });
|
|
805
|
+
const budget = readJson(join(dir, 'budget.json'), null);
|
|
806
|
+
const cutInfo = readJson(join(dir, 'cut.json'), null);
|
|
807
|
+
// A page recording delivered as it is: its own honesty line (recorded in real time, frames held, the cursor drawn).
|
|
808
|
+
const recInfo = src ? readJson(join(dirname(src), 'recording.json'), null) : null;
|
|
809
|
+
const kind = String(flags.get('kind') ?? (flags.has('for-song') ? 'music-video' : 'trailer'));
|
|
810
|
+
const models = [...new Set((budget?.calls ?? []).map((c) => c.model))];
|
|
811
|
+
const song = flags.has('for-song') ? getEntry(root, 'music', String(flags.get('for-song'))) : null;
|
|
812
|
+
const entry = {
|
|
813
|
+
slug, kind, title: String(title), blurb: flags.get('blurb') ?? '', published: flags.has('publish'), duration: probe(main).duration, files,
|
|
814
|
+
credits: flags.get('credits') ?? ([models.length ? `Generated footage: ${models.join(', ')} on fal.` : null, song?.credits ?? null].filter(Boolean).join(' ') || null),
|
|
815
|
+
honesty: flags.get('honesty') ?? cutInfo?.edl?.honesty ?? recInfo?.honesty ?? null,
|
|
816
|
+
rights: budget ? { provider: 'fal', models, commercial: null } : null,
|
|
817
|
+
...(flags.has('for-game') || flags.has('for-song') ? { for: { ...(flags.has('for-game') ? { game: String(flags.get('for-game')) } : {}), ...(flags.has('for-song') ? { song: String(flags.get('for-song')) } : {}) } } : {}),
|
|
818
|
+
made: { at: new Date().toISOString(), provider: models.length ? 'fal' : recInfo ? 'recording' : 'capture', receipt: budget ? rel(root, join(dir, 'budget.json')) : null, spentUsd: budget?.spent ?? 0 },
|
|
819
|
+
};
|
|
820
|
+
upsertEntry(root, 'videos', entry);
|
|
821
|
+
return { ok: true, command: 'add', slug, published: entry.published, files: files.map((f) => `${f.role} ${f.path}`), manifest: 'videos/manifest.json' };
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/* ---------------------------------------------------------------- main */
|
|
825
|
+
|
|
826
|
+
function print(r) {
|
|
827
|
+
if (asJson) { process.stdout.write(`${JSON.stringify(r, null, 2)}\n`); return; }
|
|
828
|
+
if (r.ok === false && r.why) { process.stdout.write(`video: ${r.why}\n`); return; }
|
|
829
|
+
const L = [];
|
|
830
|
+
switch (r.command) {
|
|
831
|
+
case 'check': L.push(`fal: ${r.fal.connection}`, `ffmpeg: ${r.ffmpeg ? 'yes' : 'NO'}; Chrome: ${r.chrome ?? 'NO'}`, `Studio: ${r.studio.ok ? `${r.studio.root}${r.studio.puppeteer ? '' : ' (no puppeteer-core: npm install)'}` : r.studio.why}`); break;
|
|
832
|
+
case 'grid': L.push(`${r.bpm} BPM (confidence ${r.confidence}), bars of ${r.barSeconds} s from ${r.offset} s; ${r.bars} bars, ${r.words} sung words. ${r.file}`); break;
|
|
833
|
+
case 'lag': L.push(`${r.base}: ${r.lagMs} ms (${r.lagFramesAt24} frames at 24 fps), peak ${r.peak}: ${r.verdict}`); break;
|
|
834
|
+
case 'record':
|
|
835
|
+
L.push(r.failed ? `recorded ${r.seconds} s, but step ${r.failed.step} (${r.failed.do}) failed: ${r.failed.why}` : `recorded ${r.seconds} s (${r.steps} steps) to ${r.file}`,
|
|
836
|
+
` the page drew ${r.pageFps?.page ?? '?'} fps${r.pageFps?.game ? `, the game ${r.pageFps.game} fps` : ''}; ${r.heldFrames} frames held; input received: ${Object.entries(r.inputs ?? {}).map(([k, v]) => `${k} ${v.keys} keys, ${v.pointers} pointer presses, ${v.touches} touches`).join('; ') || 'none'}`,
|
|
837
|
+
...(r.captions ? [` captions: ${r.captions}`] : []), ...(r.warnings ?? []).map((w) => ` warning: ${w}`), ` next: ${r.next}`);
|
|
838
|
+
break;
|
|
839
|
+
case 'capture': L.push(`captured ${r.seconds} s (${r.sourceFps} fps from the page, ${r.heldFrames} held) to ${r.file}; ${r.audio ? `game sound ${r.audio.seconds} s, peak ${r.audio.peakDb} dB` : 'the game made no sound'}`); if (r.advice) L.push(` note: ${r.advice}`); break;
|
|
840
|
+
case 'edl': L.push(`${r.shots} shots of ${r.shotSeconds} s${r.title ? `, title ${r.title} s` : ''}, end card ${r.end} s; cuts at ${r.cuts.join(', ')}. ${r.file}`); break;
|
|
841
|
+
case 'cut': L.push(...(r.problems ?? []).map((x) => `PROBLEM: ${x}`), `${r.frames} frames at ${r.fps} fps, ${r.cuts.length} cuts each on its frame; ${r.outputs['16x9'].picture.pixFmt} ${r.outputs['16x9'].picture.range} ${r.outputs['16x9'].picture.matrix}`, `${r.seconds} s: ${r.outputs['16x9'].file} (${r.outputs['16x9'].loudness?.lufs ?? '-'} LUFS), ${Object.entries(r.outputs).filter(([k]) => k !== '16x9').map(([, o]) => o.file).join(', ')}; poster ${r.poster}`); break;
|
|
842
|
+
case 'trailer':
|
|
843
|
+
L.push(...(r.problems ?? []).map((x) => `PROBLEM: ${x}`), `${r.seconds} s trailer of ${r.game}: ${Object.values(r.outputs).map((o) => o.file).join(', ')}`,
|
|
844
|
+
` filmed ${r.capture.seconds} s frame by frame (${r.capture.frames} frames, ${r.capture.heldFrames} held) in ${r.capture.realSeconds} s; sound: ${r.capture.audio ? `${r.capture.audio.placed} sounds rebuilt from the game's files${r.capture.audio.missing ? `, ${r.capture.audio.missing} file(s) missing` : ''}` : 'none logged'}`,
|
|
845
|
+
` ${r.edit.shots} shots of ${r.edit.shotSeconds} s, picked ${r.edit.picked}; ${r.edit.bed ?? 'no music bed'}; end card ${r.edit.end} s`, ...(r.warnings ?? []).map((w) => ` warning: ${w}`), ` next: ${r.next}`);
|
|
846
|
+
break;
|
|
847
|
+
case 'sync': L.push(`${r.ok ? 'in sync' : 'OUT OF SYNC'}: ${r.judged} of ${r.of} hits judged, ${r.outOfSync} off by more than a frame`, ...r.rows.map((x) => ` ${x.at}s sound ${x.soundAt ?? '-'} picture f${x.pictureFrame} offset ${x.offsetFrames ?? '-'}`)); break;
|
|
848
|
+
default: L.push(JSON.stringify(r, null, 2));
|
|
849
|
+
}
|
|
850
|
+
process.stdout.write(`${L.join('\n')}\n`);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
async function main() {
|
|
854
|
+
const cmd = pos[0];
|
|
855
|
+
if (!cmd || cmd === 'help' || flags.has('help')) {
|
|
856
|
+
process.stdout.write(`${readFileSync(new URL(import.meta.url), 'utf8').split('*/')[0].split('\n').slice(2).map((l) => l.replace(/^ \* ?/, '')).join('\n')}\n`);
|
|
857
|
+
return null;
|
|
858
|
+
}
|
|
859
|
+
if (cmd === 'check') return check();
|
|
860
|
+
const root = requireStudio();
|
|
861
|
+
switch (cmd) {
|
|
862
|
+
case 'budget': return budget(root);
|
|
863
|
+
case 'grid': return grid(root);
|
|
864
|
+
case 'slice': return slice(root);
|
|
865
|
+
case 'lag': return lag(root);
|
|
866
|
+
case 'capture': return capture(root);
|
|
867
|
+
case 'record': return record(root);
|
|
868
|
+
case 'edl': return edl(root);
|
|
869
|
+
case 'card': return card(root);
|
|
870
|
+
case 'cut': return cut(root);
|
|
871
|
+
case 'trailer': return trailer(root);
|
|
872
|
+
case 'film': return film(root);
|
|
873
|
+
case 'sheet': return sheet(root);
|
|
874
|
+
case 'words': return words(root);
|
|
875
|
+
case 'sync': return sync(root);
|
|
876
|
+
case 'add': return add(root);
|
|
877
|
+
case 'publish': return publishEntry(root, 'videos', pos[1], { deploy: !flags.has('no-deploy') });
|
|
878
|
+
default: return { ok: false, command: cmd, why: `unknown command "${cmd}" (video.mjs help)` };
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
try {
|
|
883
|
+
const r = await main();
|
|
884
|
+
if (r) { print(r); if (r.ok === false) process.exitCode = 1; }
|
|
885
|
+
} catch (error) {
|
|
886
|
+
print({ ok: false, why: error instanceof Error ? error.message : String(error) });
|
|
887
|
+
process.exitCode = 1;
|
|
888
|
+
}
|