mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
lets see . . .
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
const { execFile } = require('child_process');
|
||||
const fsp = require('fs/promises');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { promisify } = require('util');
|
||||
const logger = require('../globals/logger').child('replayBuild');
|
||||
const { replaySegmentsDir, segmentSeconds } = require('./replaySegmentManager');
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const REPLAY_DURATION_MS = 20000;
|
||||
const REPLAY_FPS = 15;
|
||||
const MAX_REPLAY_WIDTH = 1280;
|
||||
const MAX_REPLAY_HEIGHT = 720;
|
||||
const REPLAY_MAX_BYTES = Math.floor(9.5 * 1024 * 1024);
|
||||
|
||||
function buildGridLayout(count) {
|
||||
const cols = Math.ceil(Math.sqrt(count));
|
||||
const rows = Math.ceil(count / cols);
|
||||
return { cols, rows };
|
||||
}
|
||||
|
||||
function clampEven(value) {
|
||||
return Math.max(2, Math.floor(value / 2) * 2);
|
||||
}
|
||||
|
||||
function buildScalePadFilter(tileWidth, tileHeight) {
|
||||
return `scale=${tileWidth}:${tileHeight}:force_original_aspect_ratio=decrease:flags=lanczos,pad=${tileWidth}:${tileHeight}:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1`;
|
||||
}
|
||||
|
||||
async function probeMaxFrameSize(paths) {
|
||||
let maxWidth = 0;
|
||||
let maxHeight = 0;
|
||||
for (const filePath of paths) {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('ffprobe', [
|
||||
'-v',
|
||||
'error',
|
||||
'-select_streams',
|
||||
'v:0',
|
||||
'-show_entries',
|
||||
'stream=width,height',
|
||||
'-of',
|
||||
'csv=p=0',
|
||||
filePath,
|
||||
]);
|
||||
const [widthRaw, heightRaw] = stdout.trim().split(',');
|
||||
const width = Number(widthRaw);
|
||||
const height = Number(heightRaw);
|
||||
if (Number.isFinite(width) && Number.isFinite(height)) {
|
||||
maxWidth = Math.max(maxWidth, width);
|
||||
maxHeight = Math.max(maxHeight, height);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Failed to probe replay clip size', err.message);
|
||||
}
|
||||
}
|
||||
return { maxWidth, maxHeight };
|
||||
}
|
||||
|
||||
async function listLatestSegments(sourceKey, neededCount) {
|
||||
const dir = path.join(replaySegmentsDir, sourceKey);
|
||||
const entries = await fsp.readdir(dir, { withFileTypes: true });
|
||||
const files = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.mp4')) continue;
|
||||
const filePath = path.join(dir, entry.name);
|
||||
const stat = await fsp.stat(filePath);
|
||||
files.push({ filePath, mtimeMs: stat.mtimeMs });
|
||||
}
|
||||
files.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
||||
return files.slice(-neededCount).map((file) => file.filePath);
|
||||
}
|
||||
|
||||
async function buildReplayVideo({ sources = [] } = {}) {
|
||||
if (!sources.length) {
|
||||
throw new Error('No replay sources selected');
|
||||
}
|
||||
const segmentCount = Math.max(1, Math.ceil(REPLAY_DURATION_MS / (segmentSeconds * 1000)));
|
||||
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'rover-replay-'));
|
||||
try {
|
||||
const clipPaths = [];
|
||||
for (let i = 0; i < sources.length; i += 1) {
|
||||
const source = sources[i];
|
||||
const key = `${source.type}__${source.id}`;
|
||||
let segmentPaths;
|
||||
try {
|
||||
segmentPaths = await listLatestSegments(key, segmentCount);
|
||||
} catch {
|
||||
throw new Error(`Missing replay segments for ${source.type}:${source.id}`);
|
||||
}
|
||||
if (segmentPaths.length < segmentCount) {
|
||||
throw new Error(`Not enough replay segments for ${source.type}:${source.id}`);
|
||||
}
|
||||
const concatPath = path.join(tmpDir, `concat-${i}.txt`);
|
||||
const concatBody = segmentPaths.map((file) => `file '${file}'`).join('\n');
|
||||
await fsp.writeFile(concatPath, concatBody);
|
||||
const clipPath = path.join(tmpDir, `clip-${i}.mp4`);
|
||||
await execFileAsync('ffmpeg', [
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'error',
|
||||
'-f',
|
||||
'concat',
|
||||
'-safe',
|
||||
'0',
|
||||
'-i',
|
||||
concatPath,
|
||||
'-c',
|
||||
'copy',
|
||||
clipPath,
|
||||
]);
|
||||
clipPaths.push(clipPath);
|
||||
}
|
||||
|
||||
const { maxWidth, maxHeight } = await probeMaxFrameSize(clipPaths);
|
||||
const layout = buildGridLayout(clipPaths.length);
|
||||
let tileWidth = maxWidth || 640;
|
||||
let tileHeight = maxHeight || 360;
|
||||
let outputWidth = tileWidth * layout.cols;
|
||||
let outputHeight = tileHeight * layout.rows;
|
||||
if (outputWidth > MAX_REPLAY_WIDTH || outputHeight > MAX_REPLAY_HEIGHT) {
|
||||
const scale = Math.min(MAX_REPLAY_WIDTH / outputWidth, MAX_REPLAY_HEIGHT / outputHeight);
|
||||
tileWidth *= scale;
|
||||
tileHeight *= scale;
|
||||
outputWidth = tileWidth * layout.cols;
|
||||
outputHeight = tileHeight * layout.rows;
|
||||
}
|
||||
tileWidth = clampEven(tileWidth);
|
||||
tileHeight = clampEven(tileHeight);
|
||||
outputWidth = clampEven(tileWidth * layout.cols);
|
||||
outputHeight = clampEven(tileHeight * layout.rows);
|
||||
|
||||
const inputArgs = [];
|
||||
const filterParts = [];
|
||||
const layoutParts = [];
|
||||
for (let i = 0; i < clipPaths.length; i += 1) {
|
||||
inputArgs.push('-i', clipPaths[i]);
|
||||
filterParts.push(`[${i}:v]${buildScalePadFilter(tileWidth, tileHeight)}[v${i}]`);
|
||||
const x = (i % layout.cols) * tileWidth;
|
||||
const y = Math.floor(i / layout.cols) * tileHeight;
|
||||
layoutParts.push(`${x}_${y}`);
|
||||
}
|
||||
if (clipPaths.length === 1) {
|
||||
filterParts.push('[v0]null[v]');
|
||||
} else {
|
||||
filterParts.push(
|
||||
`${clipPaths.map((_, i) => `[v${i}]`).join('')}` +
|
||||
`xstack=inputs=${clipPaths.length}:layout=${layoutParts.join('|')}:fill=black[v]`,
|
||||
);
|
||||
}
|
||||
|
||||
const durationSec = Math.max(1, REPLAY_DURATION_MS / 1000);
|
||||
const targetBitrateKbps = Math.max(300, Math.floor((REPLAY_MAX_BYTES * 8) / durationSec / 1000));
|
||||
const maxrateKbps = Math.floor(targetBitrateKbps * 1.1);
|
||||
const bufsizeKbps = Math.floor(targetBitrateKbps * 2);
|
||||
|
||||
const outPath = path.join(tmpDir, 'replay.mp4');
|
||||
await execFileAsync('ffmpeg', [
|
||||
'-y',
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'error',
|
||||
...inputArgs,
|
||||
'-filter_complex',
|
||||
filterParts.join(';'),
|
||||
'-map',
|
||||
'[v]',
|
||||
'-r',
|
||||
String(REPLAY_FPS),
|
||||
'-c:v',
|
||||
'libx264',
|
||||
'-b:v',
|
||||
`${targetBitrateKbps}k`,
|
||||
'-maxrate',
|
||||
`${maxrateKbps}k`,
|
||||
'-bufsize',
|
||||
`${bufsizeKbps}k`,
|
||||
'-pix_fmt',
|
||||
'yuv420p',
|
||||
outPath,
|
||||
]);
|
||||
const buffer = await fsp.readFile(outPath);
|
||||
return buffer;
|
||||
} finally {
|
||||
try {
|
||||
await fsp.rm(tmpDir, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
logger.warn('Failed to cleanup replay temp dir', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildReplayVideo,
|
||||
};
|
||||
Reference in New Issue
Block a user