mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
266 lines
8.2 KiB
JavaScript
266 lines
8.2 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { execFile, execFileSync } from 'node:child_process';
|
|
import { promisify } from 'node:util';
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
import { chmodSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { createRequire } from 'node:module';
|
|
import os from 'node:os';
|
|
import { dirname, join, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const remotionRoot = resolve(__dirname, '..');
|
|
const outputRoot = resolve(remotionRoot, '..', 'app', 'public', 'generated', 'remotion');
|
|
const tempRoot = resolve(remotionRoot, 'out', 'runtime-assets');
|
|
const remotionBinary = join(remotionRoot, 'node_modules', '.bin', 'remotion');
|
|
const outputFps = 30;
|
|
const webpPackageRoot = dirname(require.resolve('webp-converter/package.json'));
|
|
|
|
function resolveWebpBinary(name) {
|
|
switch (os.platform()) {
|
|
case 'darwin':
|
|
return join(webpPackageRoot, 'bin', 'libwebp_osx', 'bin', name);
|
|
case 'linux':
|
|
return join(webpPackageRoot, 'bin', 'libwebp_linux', 'bin', name);
|
|
case 'win32':
|
|
return join(webpPackageRoot, 'bin', 'libwebp_win64', 'bin', `${name}.exe`);
|
|
default:
|
|
throw new Error(`[remotion-runtime-assets] unsupported platform for vendored webp tools: ${os.platform()}`);
|
|
}
|
|
}
|
|
|
|
const cwebpBinary = resolveWebpBinary('cwebp');
|
|
const webpmuxBinary = resolveWebpBinary('webpmux');
|
|
|
|
const ALL_COLORS = ['yellow', 'burgundy', 'black', 'navy', 'green'];
|
|
|
|
function resolveColorSet() {
|
|
const raw = (process.env.MASCOT_COLORS ?? '').trim();
|
|
if (!raw) return ['yellow'];
|
|
if (raw.toLowerCase() === 'all') return ALL_COLORS;
|
|
const requested = raw
|
|
.split(',')
|
|
.map(s => s.trim())
|
|
.filter(Boolean);
|
|
const unknown = requested.filter(c => !ALL_COLORS.includes(c));
|
|
if (unknown.length > 0) {
|
|
throw new Error(
|
|
`[remotion-runtime-assets] unknown mascot color(s): ${unknown.join(', ')}. Allowed: ${ALL_COLORS.join(', ')}, or "all".`
|
|
);
|
|
}
|
|
if (!requested.includes('yellow')) {
|
|
requested.unshift('yellow');
|
|
}
|
|
return requested;
|
|
}
|
|
|
|
const colors = resolveColorSet();
|
|
console.log(`[remotion-runtime-assets] rendering colors: ${colors.join(', ')}`);
|
|
const baseVariants = [
|
|
{ composition: 'mascot-yellow-idle', profile: 'default', props: {} },
|
|
{ composition: 'mascot-yellow-talking', profile: 'default', props: {} },
|
|
{ composition: 'mascot-yellow-thinking', profile: 'default', props: {} },
|
|
{
|
|
composition: 'mascot-yellow-idle',
|
|
profile: 'compact',
|
|
props: { groundShadowOpacity: 0.75, compactArmShading: true },
|
|
},
|
|
{
|
|
composition: 'mascot-yellow-talking',
|
|
profile: 'compact',
|
|
props: { groundShadowOpacity: 0.75, compactArmShading: true },
|
|
},
|
|
{
|
|
composition: 'mascot-yellow-thinking',
|
|
profile: 'compact',
|
|
props: { groundShadowOpacity: 0.75, compactArmShading: true },
|
|
},
|
|
];
|
|
const variants = baseVariants.flatMap(variant =>
|
|
colors.map(color => ({
|
|
...variant,
|
|
color,
|
|
props: { ...variant.props, mascotColor: color },
|
|
}))
|
|
);
|
|
|
|
function run(command, args, cwd) {
|
|
try {
|
|
execFileSync(command, args, {
|
|
cwd,
|
|
stdio: 'inherit',
|
|
env: process.env,
|
|
});
|
|
} catch (error) {
|
|
if (error?.code === 'ENOENT') {
|
|
throw new Error(
|
|
`[remotion-runtime-assets] missing required executable "${command}". Install it and ensure it is on PATH.`
|
|
);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function ensureCleanDir(dir) {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
mkdirSync(dir, { recursive: true });
|
|
}
|
|
|
|
function ensureExecutable(path) {
|
|
if (os.platform() !== 'win32') {
|
|
chmodSync(path, 0o755);
|
|
}
|
|
}
|
|
|
|
function renderMov(composition, destination, props) {
|
|
if (!existsSync(remotionBinary)) {
|
|
throw new Error(`remotion CLI missing at ${remotionBinary}; run pnpm install in remotion/ first`);
|
|
}
|
|
|
|
const args = [
|
|
'render',
|
|
composition,
|
|
destination,
|
|
'--codec=prores',
|
|
'--prores-profile=4444',
|
|
'--pixel-format=yuva444p10le',
|
|
];
|
|
if (Object.keys(props).length > 0) {
|
|
args.push('--props', JSON.stringify(props));
|
|
}
|
|
run(remotionBinary, args, remotionRoot);
|
|
}
|
|
|
|
function extractPngFrames(inputMov, frameDir) {
|
|
mkdirSync(frameDir, { recursive: true });
|
|
run(
|
|
'ffmpeg',
|
|
[
|
|
'-y',
|
|
'-i',
|
|
inputMov,
|
|
'-an',
|
|
'-vsync',
|
|
'passthrough',
|
|
'-pix_fmt',
|
|
'rgba',
|
|
'-start_number',
|
|
'0',
|
|
join(frameDir, 'frame-%04d.png'),
|
|
],
|
|
remotionRoot
|
|
);
|
|
}
|
|
|
|
function listFrames(frameDir, extension) {
|
|
return readdirSync(frameDir)
|
|
.filter(entry => entry.endsWith(extension))
|
|
.sort()
|
|
.map(entry => join(frameDir, entry));
|
|
}
|
|
|
|
async function convertPngFramesToWebp(frameDir) {
|
|
const pngFrames = listFrames(frameDir, '.png');
|
|
const webpFrames = new Array(pngFrames.length);
|
|
const concurrency = Math.max(1, Math.min(os.cpus()?.length ?? 4, 8));
|
|
let nextIndex = 0;
|
|
let completed = 0;
|
|
const total = pngFrames.length;
|
|
|
|
async function worker() {
|
|
while (true) {
|
|
const idx = nextIndex++;
|
|
if (idx >= total) return;
|
|
const pngFrame = pngFrames[idx];
|
|
const webpFrame = pngFrame.replace(/\.png$/u, '.webp');
|
|
try {
|
|
await execFileAsync(
|
|
cwebpBinary,
|
|
['-quiet', '-q', '82', '-m', '4', '-alpha_q', '100', pngFrame, '-o', webpFrame],
|
|
{ cwd: remotionRoot }
|
|
);
|
|
} catch (error) {
|
|
if (error?.code === 'ENOENT') {
|
|
throw new Error(
|
|
`[remotion-runtime-assets] missing required executable "${cwebpBinary}". Install it and ensure it is on PATH.`
|
|
);
|
|
}
|
|
throw error;
|
|
}
|
|
webpFrames[idx] = webpFrame;
|
|
completed += 1;
|
|
if (completed === total || completed % 30 === 0) {
|
|
console.log(`[remotion-runtime-assets] cwebp ${completed}/${total} frames`);
|
|
}
|
|
}
|
|
}
|
|
|
|
await Promise.all(Array.from({ length: concurrency }, () => worker()));
|
|
return webpFrames;
|
|
}
|
|
|
|
async function transcodeAnimatedWebp(inputMov, outputWebp, frameDir) {
|
|
extractPngFrames(inputMov, frameDir);
|
|
const webpFrames = await convertPngFramesToWebp(frameDir);
|
|
const frameDurationMs = String(Math.round(1000 / outputFps));
|
|
const args = ['-loop', '0', '-bgcolor', '0,0,0,0'];
|
|
|
|
// webpmux frame options: +duration+xoff+yoff+dispose+blend
|
|
// dispose=1 → clear canvas to background (transparent) before drawing the
|
|
// next frame. Without this, frames composite over previous ones and
|
|
// transparent mascot poses ghost on top of each other.
|
|
// -b → no blending; the frame's RGBA replaces the canvas pixels. With
|
|
// blending the alpha of the prior frame leaks through even after a
|
|
// dispose, producing a faint overlay around the silhouette.
|
|
for (const framePath of webpFrames) {
|
|
args.push('-frame', framePath, `+${frameDurationMs}+0+0+1-b`);
|
|
}
|
|
|
|
args.push('-o', outputWebp);
|
|
run(webpmuxBinary, args, remotionRoot);
|
|
}
|
|
|
|
ensureCleanDir(outputRoot);
|
|
ensureCleanDir(tempRoot);
|
|
ensureExecutable(cwebpBinary);
|
|
ensureExecutable(webpmuxBinary);
|
|
|
|
const manifest = {
|
|
generatedAt: new Date().toISOString(),
|
|
format: 'image/webp',
|
|
variants: [],
|
|
};
|
|
|
|
for (const variant of variants) {
|
|
const profileDir = join(outputRoot, variant.profile, variant.color);
|
|
const tempVariantDir = join(tempRoot, variant.profile, variant.color, variant.composition);
|
|
mkdirSync(profileDir, { recursive: true });
|
|
mkdirSync(tempVariantDir, { recursive: true });
|
|
|
|
const movPath = join(tempVariantDir, `${variant.composition}.mov`);
|
|
const webpPath = join(profileDir, `${variant.composition}.webp`);
|
|
const frameDir = join(tempVariantDir, 'frames');
|
|
|
|
console.log(
|
|
`[remotion-runtime-assets] rendering ${variant.profile}/${variant.color}/${variant.composition}`
|
|
);
|
|
renderMov(variant.composition, movPath, variant.props);
|
|
await transcodeAnimatedWebp(movPath, webpPath, frameDir);
|
|
|
|
manifest.variants.push({
|
|
color: variant.color,
|
|
composition: variant.composition,
|
|
profile: variant.profile,
|
|
path: `${variant.profile}/${variant.color}/${variant.composition}.webp`,
|
|
props: variant.props,
|
|
});
|
|
}
|
|
|
|
writeFileSync(join(outputRoot, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
rmSync(tempRoot, { recursive: true, force: true });
|
|
|
|
console.log(`[remotion-runtime-assets] wrote assets to ${outputRoot}`);
|