Cut two vertical Motion films, and write down the pipeline that makes them
THE PIPELINE. The five existing platform videos were cut by an ad-hoc process that was never committed, so the first time the UI moved nobody could re-shoot them — which is the same failure `scripts/screenshots.mjs` was written to stop. `scripts/learn-film.mjs` is that pipeline, and it reuses both of the screenshot script's hard-won lessons: the appearance preference is stored server-side and adopted after hydration, so the theme has to be forced by rewriting the profile response rather than by seeding localStorage; and per-device layout state has to be pinned or the framing is whatever a human last left behind. Order is load-bearing. The narration is rendered first and its MEASURED duration drives every shot length, because a shot list timed by guess leaves the narrator talking over a frozen frame. Shot durations are then proportional to the words spoken over them, so the cut lands on the sentence. Typography is composed in the browser rather than in ffmpeg. The product's face is Manrope Variable and drawtext would have fallen back to DejaVu, which reads as a different company. Playwright renders a transparent chrome layer per shot and ffmpeg only moves pixels. Detail shots crop to the content column rather than the whole viewport. The first attempt cropped a box around the element being talked about, which is narrower than the column, so it sliced the cards either side of centre and the frame read as broken rather than as close. Width first, height from the aspect. CRF is 22, not 18. The frame is a static UI with a slow zoom, and 18 spent 3.7 Mbps — a 15 MB download for a video whose whole point is that somebody opens it on a phone between meetings. THE STAGE RAIL BUG, which the films found. Every stage label was losing its last letters — "QUALIFICATIO", "PROCUREMEN" — with no ellipsis to show for it. Three attempts to fix that by widening the card did nothing, because the card was never the thing being measured: the LI holding it had `min-w-0` and no `shrink-0`, so it took its flex share of 88px while the card inside stayed 160, and every card was overpainted by the next one. The DOM reported no overflow the whole time, because there wasn't any — the clipping was one level up. `shrink-0` moves to the LI, where it belongs, and the rail scrolls as it was always meant to. The labels also wrap rather than truncate now: they are a fixed vocabulary of eight words we control, and losing a letter is worse than taking a line. THE FILMS. Two 9:16 clips for the team, narrated in Karti's cloned voice through Chatterbox at 1.20x and scored with the platform's own ambient bed, ducked and loudness-normalised so they do not jump against the five already on the page. Both open dark and switch to light at the midpoint. Shot lists live beside the narration in `docs/learn-films/`, because a script and its shot list timed against each other are one object and splitting them is how they drift.
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
/*
|
||||
* Build a vertical /learn video from a script file.
|
||||
*
|
||||
* node scripts/learn-film.mjs docs/learn-films/<slug>.json
|
||||
*
|
||||
* The five existing platform videos were cut by an ad-hoc pipeline that was
|
||||
* never committed, so the first time the UI changed nobody could re-shoot them.
|
||||
* This is that pipeline, written down. `scripts/screenshots.mjs` exists for the
|
||||
* same reason and this file deliberately reuses its two hard-won lessons: the
|
||||
* appearance preference is stored SERVER-side and adopted after hydration, so
|
||||
* seeding localStorage is not enough and the profile response has to be
|
||||
* rewritten; and per-device layout state (`pig.sidebarOpen`, `pig.piggyDockOpen`)
|
||||
* has to be forced or the framing is whatever a human last left behind.
|
||||
*
|
||||
* ORDER IS LOAD-BEARING. The narration is rendered FIRST and its measured
|
||||
* duration drives every shot length. A shot list timed by guess leaves the
|
||||
* narrator talking over a frozen frame, or gets cut mid-sentence — which is how
|
||||
* the previous set was found to be wrong.
|
||||
*
|
||||
* Typography is composed in the browser, not in ffmpeg: the product's typeface
|
||||
* is Manrope Variable and ffmpeg's drawtext would have to fall back to DejaVu,
|
||||
* which is visibly not the same product. So Playwright renders a transparent
|
||||
* chrome layer per shot and ffmpeg only moves pixels.
|
||||
*
|
||||
* Requires: a running API + web (see PIG_WEB_URL), Chatterbox reachable, ffmpeg.
|
||||
*/
|
||||
import { chromium } from 'playwright';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { promisify } from 'node:util';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const run = promisify(execFile);
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO = resolve(HERE, '..');
|
||||
|
||||
const WEB = process.env.PIG_WEB_URL ?? 'http://localhost:5179';
|
||||
const TTS = process.env.PIG_TTS_URL ?? 'http://100.127.247.67:8095';
|
||||
const VOICE = process.env.PIG_TTS_VOICE ?? 'karti';
|
||||
/* 1.20x is the rate the /learn scripts are written to; see docs/learn-scripts.md. */
|
||||
const TEMPO = Number(process.env.PIG_TTS_TEMPO ?? 1.2);
|
||||
const MUSIC = process.env.PIG_FILM_MUSIC ?? join(REPO, 'apps/web/public/audio/pig-ambient.mp3');
|
||||
|
||||
const FRAME_W = 1080;
|
||||
const FRAME_H = 1920;
|
||||
/* The app is captured at 1120 wide and lands at 1080, so it is very nearly 1:1
|
||||
* and the product's own type sizes stay legible on a phone. Any narrower and the
|
||||
* sidebar drops to the mobile tab bar (NAV_BREAKPOINT is 1024). */
|
||||
const SHOT_W = 1120;
|
||||
const SHOT_H = 1560;
|
||||
const APP_W = FRAME_W;
|
||||
const APP_H = Math.round((SHOT_H * FRAME_W) / SHOT_W);
|
||||
const APP_Y = 250;
|
||||
const FPS = 30;
|
||||
|
||||
/** Every anchor a script may name, and how to frame it. */
|
||||
/*
|
||||
* `focus` is `full` (the whole viewport) or `column` (the content column only).
|
||||
*
|
||||
* A vertical video is watched on a phone, and the whole 1120px viewport scaled
|
||||
* into 1080 leaves the product's own 12px labels at about 11px on the finished
|
||||
* frame — technically visible, actually unreadable. So a detail shot drops the
|
||||
* sidebar and keeps the content column, which is about 1.4x closer.
|
||||
*
|
||||
* The crop is driven by the column's WIDTH rather than by the height of the
|
||||
* thing being talked about, which was the first attempt: a crop sized to the
|
||||
* target element is narrower than the column, so it sliced the cards either
|
||||
* side of centre and the frame read as broken rather than as close. Width
|
||||
* first, height derived from the aspect, vertical position centred on the
|
||||
* target — that way nothing is ever cut off horizontally.
|
||||
*/
|
||||
const ANCHORS = {
|
||||
hero: { route: '/motion', scroll: 'top', focus: 'full' },
|
||||
stats: { route: '/motion', scroll: 'top', focus: 'column' },
|
||||
'stage-rail': { route: '/motion', text: 'The motion, stage by stage', focus: 'column' },
|
||||
kinds: { route: '/motion', text: 'Library cover, by kind', focus: 'column' },
|
||||
promotions: { route: '/motion', text: 'What got easier', focus: 'column' },
|
||||
'open-engagements': { route: '/motion', text: 'Open engagements', focus: 'column' },
|
||||
'library-list': { route: '/motion/library', scroll: 'top', focus: 'full' },
|
||||
'template-head': { route: '/motion/library/PLAYBOOK', scroll: 'top', focus: 'column' },
|
||||
'template-use': { route: '/motion/library/PLAYBOOK', text: 'Use this template', focus: 'column' },
|
||||
'template-body': { route: '/motion/library/PLAYBOOK', text: 'The document', focus: 'column' },
|
||||
'eng-list': { route: '/motion/engagements', scroll: 'top', focus: 'full' },
|
||||
'eng-head': { route: '/motion/engagements/POC', scroll: 'top', focus: 'column' },
|
||||
'eng-stages': { route: '/motion/engagements/POC', text: 'Where the work sits', focus: 'column' },
|
||||
'eng-qualification': { route: '/motion/engagements/POC', text: 'Qualification', focus: 'column' },
|
||||
'eng-artefacts': { route: '/motion/engagements/POC', text: 'Artefacts', focus: 'column' },
|
||||
};
|
||||
|
||||
async function ffprobeDuration(path) {
|
||||
const { stdout } = await run('ffprobe', [
|
||||
'-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', path,
|
||||
]);
|
||||
return Number(stdout.trim());
|
||||
}
|
||||
|
||||
async function narrate(text, out) {
|
||||
const response = await fetch(`${TTS}/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ text, voice: VOICE }),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Chatterbox ${response.status}: ${await response.text()}`);
|
||||
const wav = Buffer.from(await response.arrayBuffer());
|
||||
/* The service answers with raw WAV bytes, not JSON. A JSON.parse here is the
|
||||
* kind of mistake that reads as a TTS outage. */
|
||||
if (wav.subarray(0, 4).toString() !== 'RIFF') {
|
||||
throw new Error(`Chatterbox did not return WAV: ${wav.subarray(0, 120).toString()}`);
|
||||
}
|
||||
await writeFile(out, wav);
|
||||
return ffprobeDuration(out);
|
||||
}
|
||||
|
||||
/** Shot lengths are proportional to the words spoken over them. */
|
||||
function shotDurations(shots, total) {
|
||||
const words = shots.map((shot) => shot.line.trim().split(/\s+/).length);
|
||||
const sum = words.reduce((a, b) => a + b, 0);
|
||||
const raw = words.map((n) => (n / sum) * total);
|
||||
/* Rounded to whole frames, with the remainder pushed onto the last shot so
|
||||
* the video and the narration end on the same frame rather than drifting. */
|
||||
const frames = raw.map((seconds) => Math.max(FPS, Math.round(seconds * FPS)));
|
||||
const target = Math.round(total * FPS);
|
||||
frames[frames.length - 1] += target - frames.reduce((a, b) => a + b, 0);
|
||||
return frames;
|
||||
}
|
||||
|
||||
const CHROME_HTML = (opts) => `<!doctype html><html><head><meta charset="utf-8"><style>
|
||||
@font-face{font-family:'Manrope';src:url('file://${opts.font}') format('woff2');font-weight:200 800;font-display:block}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
html,body{width:${FRAME_W}px;height:${FRAME_H}px;background:transparent}
|
||||
body{font-family:'Manrope',system-ui,sans-serif;-webkit-font-smoothing:antialiased}
|
||||
.band{position:absolute;left:0;right:0;background:${opts.bg}}
|
||||
.top{top:0;height:${APP_Y}px;display:flex;flex-direction:column;justify-content:center;padding:0 64px;gap:14px}
|
||||
.bottom{top:${APP_Y + APP_H}px;height:${FRAME_H - APP_Y - APP_H}px;display:flex;align-items:center;justify-content:space-between;padding:0 64px}
|
||||
.eyebrow{font-size:26px;font-weight:700;letter-spacing:.22em;text-transform:uppercase;color:${opts.accent}}
|
||||
.caption{font-size:58px;font-weight:700;letter-spacing:-.02em;line-height:1.08;color:${opts.fg}}
|
||||
.mark{display:flex;align-items:center;gap:14px;font-size:28px;font-weight:700;color:${opts.fg};opacity:.85}
|
||||
.dot{width:12px;height:12px;border-radius:50%;background:${opts.accent}}
|
||||
.track{position:absolute;left:64px;right:64px;top:${FRAME_H - 26}px;height:6px;border-radius:3px;background:${opts.rule}}
|
||||
.fill{height:6px;border-radius:3px;background:${opts.accent};width:${Math.round(opts.progress * 100)}%}
|
||||
.hair{position:absolute;left:0;right:0;height:1px;background:${opts.rule}}
|
||||
</style></head><body>
|
||||
<div class="band top"><div class="eyebrow">${opts.eyebrow}</div><div class="caption">${opts.caption}</div></div>
|
||||
<div class="hair" style="top:${APP_Y - 1}px"></div>
|
||||
<div class="hair" style="top:${APP_Y + APP_H}px"></div>
|
||||
<div class="band bottom"><div class="mark"><span class="dot"></span>PIG · Motion</div><div class="mark" style="opacity:.5">primeintellectgrowth.com</div></div>
|
||||
<div class="track"><div class="fill"></div></div>
|
||||
</body></html>`;
|
||||
|
||||
const THEME = {
|
||||
dark: { bg: '#0a0a0b', fg: '#fafafa', rule: '#26262b', accent: '#22c55e' },
|
||||
light: { bg: '#ffffff', fg: '#0a0a0b', rule: '#e6e6ea', accent: '#16a34a' },
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const scriptPath = process.argv[2];
|
||||
if (!scriptPath) throw new Error('usage: learn-film.mjs <script.json>');
|
||||
const film = JSON.parse(await readFile(scriptPath, 'utf8'));
|
||||
const work = join(REPO, '.film', film.slug);
|
||||
await rm(work, { recursive: true, force: true });
|
||||
await mkdir(work, { recursive: true });
|
||||
|
||||
const font = join(
|
||||
REPO,
|
||||
'node_modules/.pnpm/@fontsource-variable+manrope@5.3.0/node_modules/@fontsource-variable/manrope/files/manrope-latin-wght-normal.woff2',
|
||||
);
|
||||
|
||||
// 1 ------------------------------------------------------- the narration
|
||||
const narration = film.shots.map((shot) => shot.line.trim()).join(' ');
|
||||
const words = narration.split(/\s+/).length;
|
||||
const rawVoice = join(work, 'voice-raw.wav');
|
||||
const spoken = await narrate(narration, rawVoice);
|
||||
const voice = join(work, 'voice.wav');
|
||||
await run('ffmpeg', ['-v', 'error', '-y', '-i', rawVoice, '-filter:a', `atempo=${TEMPO}`, voice]);
|
||||
const duration = await ffprobeDuration(voice);
|
||||
console.log(` narration ${words} words -> ${spoken.toFixed(2)}s at 1.0x -> ${duration.toFixed(2)}s at ${TEMPO}x`);
|
||||
|
||||
const frames = shotDurations(film.shots, duration);
|
||||
|
||||
// 2 ---------------------------------------------------------- the capture
|
||||
const engagements = await fetch(`${WEB}/api/motion/engagements`).then((r) => r.json());
|
||||
const templates = await fetch(`${WEB}/api/motion/templates`).then((r) => r.json());
|
||||
const poc = engagements.engagements.find((e) => e.stage === 'poc') ?? engagements.engagements[0];
|
||||
const playbook = templates.templates.find((t) => t.kind === 'playbook') ?? templates.templates[0];
|
||||
|
||||
const browser = await chromium.launch({ channel: 'chrome' });
|
||||
const contexts = {};
|
||||
for (const theme of ['dark', 'light']) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: SHOT_W, height: SHOT_H },
|
||||
/* 3x, because a detail shot crops to about half the viewport and is then
|
||||
* scaled back up to 1080 wide; at 2x that is an upscale and it shows. */
|
||||
deviceScaleFactor: 3,
|
||||
colorScheme: theme,
|
||||
});
|
||||
await context.addInitScript(`
|
||||
localStorage.setItem('pig.themeMode', '${theme}');
|
||||
localStorage.setItem('pig.sidebarOpen', 'true');
|
||||
localStorage.setItem('pig.piggyDockOpen', 'false');
|
||||
localStorage.setItem('pig.audio.enabled', 'false');
|
||||
`);
|
||||
await context.route('**/api/me/profile', async (route) => {
|
||||
const response = await route.fetch();
|
||||
const body = await response.json().catch(() => ({}));
|
||||
await route.fulfill({ json: { ...body, themeMode: theme } });
|
||||
});
|
||||
contexts[theme] = context;
|
||||
}
|
||||
const chromePage = await browser.newPage({ viewport: { width: FRAME_W, height: FRAME_H } });
|
||||
|
||||
const segments = [];
|
||||
for (const [index, shot] of film.shots.entries()) {
|
||||
const anchor = ANCHORS[shot.anchor];
|
||||
if (!anchor) throw new Error(`unknown anchor "${shot.anchor}" in ${film.slug}`);
|
||||
const route = anchor.route.replace('PLAYBOOK', playbook.id).replace('POC', poc.id);
|
||||
|
||||
const page = await contexts[shot.theme].newPage();
|
||||
await page.goto(`${WEB}${route}`, { waitUntil: 'networkidle' });
|
||||
/* Lazy route chunks sit behind a Suspense boundary; a screenshot taken
|
||||
* before the fallback resolves photographs the spinner. */
|
||||
await page.waitForTimeout(1400);
|
||||
const rendered = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
|
||||
if (rendered !== shot.theme) throw new Error(`shot ${index} rendered ${rendered}, wanted ${shot.theme}`);
|
||||
if (anchor.text) {
|
||||
await page.evaluate((text) => {
|
||||
const node = [...document.querySelectorAll('h1,h2,h3')].find(
|
||||
(el) => el.textContent?.trim() === text,
|
||||
);
|
||||
if (!node) throw new Error(`no heading "${text}"`);
|
||||
const card = node.closest('section,[class*="card"],div');
|
||||
(card ?? node).scrollIntoView({ block: 'center' });
|
||||
window.scrollBy(0, -60);
|
||||
}, anchor.text);
|
||||
} else {
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
}
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const appPng = join(work, `app-${index}.png`);
|
||||
const clip = anchor.focus === 'full' ? null : await page.evaluate(
|
||||
({ text, shotW, shotH }) => {
|
||||
const column = document.querySelector('main');
|
||||
if (!column) return null;
|
||||
const rect = column.getBoundingClientRect();
|
||||
const width = Math.min(shotW, Math.max(560, rect.width));
|
||||
const height = Math.min(shotH, width * (shotH / shotW));
|
||||
const heading = text
|
||||
? [...document.querySelectorAll('h1,h2,h3')].find((el) => el.textContent?.trim() === text)
|
||||
: null;
|
||||
const box = (heading?.closest('section,[class*="card"]') ?? heading)?.getBoundingClientRect();
|
||||
const centreY = box ? box.top + box.height / 2 : height / 2;
|
||||
return {
|
||||
x: Math.round(Math.max(0, Math.min(shotW - width, rect.left))),
|
||||
y: Math.round(Math.max(0, Math.min(shotH - height, centreY - height / 2))),
|
||||
width: Math.round(width),
|
||||
height: Math.round(height),
|
||||
};
|
||||
},
|
||||
{ text: anchor.text ?? null, shotW: SHOT_W, shotH: SHOT_H },
|
||||
);
|
||||
await page.screenshot(clip ? { path: appPng, clip } : { path: appPng });
|
||||
await page.close();
|
||||
|
||||
const palette = THEME[shot.theme];
|
||||
await chromePage.setContent(
|
||||
CHROME_HTML({
|
||||
...palette,
|
||||
font,
|
||||
eyebrow: index === 0 ? 'Prime Intellect Growth' : 'Motion',
|
||||
caption: shot.caption,
|
||||
progress: (index + 1) / film.shots.length,
|
||||
}),
|
||||
{ waitUntil: 'load' },
|
||||
);
|
||||
await chromePage.waitForTimeout(250);
|
||||
const chromePng = join(work, `chrome-${index}.png`);
|
||||
await chromePage.screenshot({ path: chromePng, omitBackground: true });
|
||||
|
||||
// 3 ------------------------------------------------ one shot -> one clip
|
||||
const n = frames[index];
|
||||
const zoom =
|
||||
shot.zoom === 'out'
|
||||
? `'if(eq(on,0),1.14,max(1.0,zoom-${(0.14 / n).toFixed(6)}))'`
|
||||
: shot.zoom === 'hold'
|
||||
? `'1.03'`
|
||||
: `'if(eq(on,0),1.0,min(1.12,zoom+${(0.12 / n).toFixed(6)}))'`;
|
||||
const segment = join(work, `seg-${index}.mp4`);
|
||||
await run('ffmpeg', [
|
||||
'-v', 'error', '-y',
|
||||
'-loop', '1', '-framerate', String(FPS), '-t', String(n / FPS), '-i', appPng,
|
||||
'-i', chromePng,
|
||||
'-filter_complex',
|
||||
`[0:v]zoompan=z=${zoom}:x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':d=${n}:s=${APP_W}x${APP_H}:fps=${FPS},` +
|
||||
`pad=${FRAME_W}:${FRAME_H}:0:${APP_Y}:color=${palette.bg}[base];` +
|
||||
`[base][1:v]overlay=0:0:format=auto,format=yuv420p[v]`,
|
||||
'-map', '[v]', '-frames:v', String(n),
|
||||
/* CRF 22, not 18. The frame is a mostly-static UI with a slow zoom, so 18
|
||||
* spent 3.7 Mbps on 32 seconds — a 15 MB download for a video whose whole
|
||||
* point is that somebody opens it on a phone between meetings. 22 holds
|
||||
* the product's 12px labels and lands under 6 MB. */
|
||||
'-c:v', 'libx264', '-preset', 'slow', '-crf', '22', segment,
|
||||
]);
|
||||
segments.push(segment);
|
||||
console.log(` shot ${index + 1}/${film.shots.length} ${shot.anchor} ${shot.theme} ${(n / FPS).toFixed(2)}s`);
|
||||
}
|
||||
await browser.close();
|
||||
|
||||
// 4 ------------------------------------------------- cut, score, normalise
|
||||
const list = join(work, 'segments.txt');
|
||||
await writeFile(list, segments.map((s) => `file '${s}'`).join('\n'));
|
||||
const silent = join(work, 'silent.mp4');
|
||||
await run('ffmpeg', ['-v', 'error', '-y', '-f', 'concat', '-safe', '0', '-i', list, '-c', 'copy', silent]);
|
||||
|
||||
const out = join(work, 'film.mp4');
|
||||
await run('ffmpeg', [
|
||||
'-v', 'error', '-y',
|
||||
'-i', silent,
|
||||
'-i', voice,
|
||||
'-stream_loop', '-1', '-i', MUSIC,
|
||||
'-filter_complex',
|
||||
/* The bed sits far under the voice and fades at both ends. Loudness is
|
||||
* normalised so clips do not jump against each other on the Learn page. */
|
||||
`[2:a]volume=0.085,afade=t=in:st=0:d=1.2,afade=t=out:st=${(duration - 1.6).toFixed(2)}:d=1.6[bed];` +
|
||||
`[1:a]adelay=250|250,apad[vox];` +
|
||||
`[vox][bed]amix=inputs=2:duration=first:dropout_transition=0,` +
|
||||
`atrim=0:${duration.toFixed(3)},loudnorm=I=-16:TP=-1.5:LRA=11[a]`,
|
||||
'-map', '0:v', '-map', '[a]',
|
||||
'-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k',
|
||||
'-movflags', '+faststart', '-shortest', out,
|
||||
]);
|
||||
|
||||
const finalDuration = await ffprobeDuration(out);
|
||||
const hash = createHash('sha256').update(await readFile(out)).digest('hex').slice(0, 8);
|
||||
const named = join(work, `${film.slug}.${hash}.mp4`);
|
||||
await run('cp', [out, named]);
|
||||
const poster = join(work, `${film.slug}.${hash}.jpg`);
|
||||
await run('ffmpeg', [
|
||||
'-v', 'error', '-y', '-ss', '1.2', '-i', named, '-frames:v', '1', '-q:v', '3', poster,
|
||||
]);
|
||||
|
||||
console.log(`\n ${film.slug}: ${finalDuration.toFixed(2)}s, ${words} words`);
|
||||
console.log(` ${named}`);
|
||||
console.log(` ${poster}`);
|
||||
await writeFile(
|
||||
join(work, 'result.json'),
|
||||
JSON.stringify(
|
||||
{ slug: film.slug, title: film.title, summary: film.summary, hash, durationSeconds: Math.round(finalDuration), words, file: named, poster },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await main();
|
||||
Reference in New Issue
Block a user