diff --git a/server/src/services/discordBotService.js b/server/src/services/discordBotService.js index 94955a15..ce26ec56 100644 --- a/server/src/services/discordBotService.js +++ b/server/src/services/discordBotService.js @@ -298,13 +298,60 @@ async function buildReplayVideo({ cameraId = null } = {}) { } const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'rover-replay-')); try { + const firstFrameByCamera = new Map(); for (let i = 0; i < frames.length; i += 1) { const filename = `frame-${String(i + 1).padStart(4, '0')}.jpg`; await fsp.writeFile(path.join(tmpDir, filename), frames[i].buffer); + const cameraId = frames[i].camera?.id; + if (cameraId && !firstFrameByCamera.has(cameraId)) { + firstFrameByCamera.set(cameraId, filename); + } } const outPath = path.join(tmpDir, 'replay.mp4'); const delayMs = getRoomCameraReplayDelayMs(); - const fps = (1000 / delayMs).toFixed(3); + const fpsValue = 1000 / delayMs; + const fps = fpsValue.toFixed(3); + let maxWidth = 0; + let maxHeight = 0; + for (const filename of firstFrameByCamera.values()) { + try { + const { stdout } = await execFileAsync('ffprobe', [ + '-v', + 'error', + '-select_streams', + 'v:0', + '-show_entries', + 'stream=width,height', + '-of', + 'csv=p=0', + path.join(tmpDir, filename), + ]); + 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 frame size', err.message); + } + } + const MAX_REPLAY_WIDTH = 1280; + const MAX_REPLAY_HEIGHT = 720; + let targetWidth = maxWidth || MAX_REPLAY_WIDTH; + let targetHeight = maxHeight || MAX_REPLAY_HEIGHT; + if (targetWidth > MAX_REPLAY_WIDTH || targetHeight > MAX_REPLAY_HEIGHT) { + const scale = Math.min(MAX_REPLAY_WIDTH / targetWidth, MAX_REPLAY_HEIGHT / targetHeight); + targetWidth = Math.max(2, Math.floor((targetWidth * scale) / 2) * 2); + targetHeight = Math.max(2, Math.floor((targetHeight * scale) / 2) * 2); + } + const sizeFilter = `scale=${targetWidth}:${targetHeight}:force_original_aspect_ratio=decrease:flags=lanczos,pad=${targetWidth}:${targetHeight}:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1`; + const maxBytes = Math.floor(9.5 * 1024 * 1024); + const durationSec = Math.max(1, frames.length / fpsValue); + const targetBitrateKbps = Math.max(300, Math.floor((maxBytes * 8) / durationSec / 1000)); + const maxrateKbps = Math.floor(targetBitrateKbps * 1.1); + const bufsizeKbps = Math.floor(targetBitrateKbps * 2); await execFileAsync('ffmpeg', [ '-y', '-hide_banner', @@ -314,8 +361,16 @@ async function buildReplayVideo({ cameraId = null } = {}) { fps, '-i', 'frame-%04d.jpg', + '-vf', + sizeFilter, '-c:v', 'libx264', + '-b:v', + `${targetBitrateKbps}k`, + '-maxrate', + `${maxrateKbps}k`, + '-bufsize', + `${bufsizeKbps}k`, '-pix_fmt', 'yuv420p', outPath,