mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
ereplay
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -11,7 +11,7 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||||
<title>Multi Roomba Rover</title>
|
<title>Multi Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-CnDISap4.js"></script>
|
<script type="module" crossorigin src="/assets/index-lJfgnUIc.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DVTOmRBl.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-DVTOmRBl.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -365,6 +365,43 @@ function normalizeReplayQuery(input) {
|
|||||||
return String(input || '').trim().toLowerCase();
|
return String(input || '').trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeReplayTitleForFilename(title) {
|
||||||
|
const cleaned = String(title || '')
|
||||||
|
.replace(/[\\/:*?"<>|]+/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.slice(0, 96);
|
||||||
|
return cleaned || 'replay';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDefaultReplayTitle(requester, sources = []) {
|
||||||
|
const requesterLabel = String(requester || 'Someone').trim() || 'Someone';
|
||||||
|
const roverSource = sources.find((entry) => entry?.type === 'rover');
|
||||||
|
const roverLabel = roverSource?.label || roverSource?.id || 'a rover';
|
||||||
|
return `${requesterLabel} driving ${roverLabel}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReplayDriverLines(requester, usedSources = []) {
|
||||||
|
const activeDrivers = getActiveDrivers();
|
||||||
|
const roverRecords = Array.from(rovers.values());
|
||||||
|
const roverById = new Map(roverRecords.map((record) => [String(record.id), record]));
|
||||||
|
const requestedRoverIds = new Set(
|
||||||
|
usedSources.filter((entry) => entry?.type === 'rover').map((entry) => String(entry.id)),
|
||||||
|
);
|
||||||
|
const lines = [];
|
||||||
|
requestedRoverIds.forEach((roverId) => {
|
||||||
|
const socketId = activeDrivers[roverId];
|
||||||
|
if (!socketId) return;
|
||||||
|
const socket = io.sockets.sockets.get(socketId);
|
||||||
|
const nickname = getNickname(socket) || socket?.data?.user?.username || socketId;
|
||||||
|
const roverRecord = roverById.get(roverId);
|
||||||
|
const roverName = roverRecord?.meta?.name || roverRecord?.id || roverId;
|
||||||
|
const isAuthor = String(nickname).toLowerCase() === String(requester || '').toLowerCase();
|
||||||
|
lines.push(`${nickname} driving ${roverName}${isAuthor ? ' **author**' : ''}`);
|
||||||
|
});
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveReplaySources(query) {
|
function resolveReplaySources(query) {
|
||||||
const cleaned = normalizeReplayQuery(query);
|
const cleaned = normalizeReplayQuery(query);
|
||||||
if (!cleaned || cleaned === 'all' || cleaned === '*') {
|
if (!cleaned || cleaned === 'all' || cleaned === '*') {
|
||||||
@@ -393,31 +430,39 @@ function resolveReplaySources(query) {
|
|||||||
return { sources };
|
return { sources };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildReplayCaption(requester, sources = [], missingSources = []) {
|
function buildReplayCaption({ requester, usedSources = [], missingSources = [], title }) {
|
||||||
const requesterLabel = requester || 'unknown';
|
const lines = [];
|
||||||
const sourceLabel = sources.length
|
if (title) {
|
||||||
? `Sources: ${sources.map((source) => source.label || `${source.type}:${source.id}`).join(', ')}.`
|
lines.push(`**${title}**`);
|
||||||
: 'No sources.';
|
lines.push('');
|
||||||
const missingLabel = missingSources.length
|
}
|
||||||
? `Missing: ${missingSources.map((source) => source.label || `${source.type}:${source.id}`).join(', ')}.`
|
const driverLines = buildReplayDriverLines(requester, usedSources);
|
||||||
: null;
|
if (driverLines.length) {
|
||||||
return [
|
lines.push(...driverLines);
|
||||||
`Replay requested by ${requesterLabel}.`,
|
}
|
||||||
sourceLabel,
|
if (missingSources.length) {
|
||||||
missingLabel,
|
if (driverLines.length) lines.push('');
|
||||||
buildDriverCaption(),
|
lines.push(`Missing: ${missingSources.map((source) => source.label || `${source.type}:${source.id}`).join(', ')}`);
|
||||||
]
|
}
|
||||||
.filter(Boolean)
|
if (!lines.length) {
|
||||||
.join(' ');
|
lines.push(buildDriverCaption());
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendReplayToChannel(channelId, requester, sources = []) {
|
async function sendReplayToChannel(channelId, requester, sources = [], explicitTitle = '') {
|
||||||
if (!channelId) {
|
if (!channelId) {
|
||||||
throw new Error('Replay channel not configured');
|
throw new Error('Replay channel not configured');
|
||||||
}
|
}
|
||||||
const { buffer, usedSources, missingSources } = await buildReplayVideo({ sources });
|
const resolvedTitle = String(explicitTitle || '').trim() || buildDefaultReplayTitle(requester, sources);
|
||||||
const attachment = new AttachmentBuilder(buffer, { name: 'replay.mp4' });
|
const { buffer, usedSources, missingSources } = await buildReplayVideo({
|
||||||
const caption = buildReplayCaption(requester, usedSources, missingSources);
|
sources,
|
||||||
|
title: resolvedTitle,
|
||||||
|
requester,
|
||||||
|
});
|
||||||
|
const filenameBase = sanitizeReplayTitleForFilename(resolvedTitle);
|
||||||
|
const attachment = new AttachmentBuilder(buffer, { name: `${filenameBase}.mp4` });
|
||||||
|
const caption = buildReplayCaption({ requester, usedSources, missingSources, title: resolvedTitle });
|
||||||
await sendToChannel(channelId, caption, { files: [attachment] }, { parse: [] });
|
await sendToChannel(channelId, caption, { files: [attachment] }, { parse: [] });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,9 +498,15 @@ async function handleReplayCommand(message, query) {
|
|||||||
const requester =
|
const requester =
|
||||||
message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord';
|
message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord';
|
||||||
try {
|
try {
|
||||||
const { buffer, usedSources, missingSources } = await buildReplayVideo({ sources });
|
const resolvedTitle = buildDefaultReplayTitle(requester, sources);
|
||||||
const attachment = new AttachmentBuilder(buffer, { name: 'replay.mp4' });
|
const { buffer, usedSources, missingSources } = await buildReplayVideo({
|
||||||
const caption = buildReplayCaption(requester, usedSources, missingSources);
|
sources,
|
||||||
|
title: resolvedTitle,
|
||||||
|
requester,
|
||||||
|
});
|
||||||
|
const filenameBase = sanitizeReplayTitleForFilename(resolvedTitle);
|
||||||
|
const attachment = new AttachmentBuilder(buffer, { name: `${filenameBase}.mp4` });
|
||||||
|
const caption = buildReplayCaption({ requester, usedSources, missingSources, title: resolvedTitle });
|
||||||
await message.reply({
|
await message.reply({
|
||||||
content: sanitizeMentions(caption),
|
content: sanitizeMentions(caption),
|
||||||
files: [attachment],
|
files: [attachment],
|
||||||
@@ -1568,7 +1619,7 @@ function handleBusEvent(event) {
|
|||||||
schedulePresenceRotation();
|
schedulePresenceRotation();
|
||||||
break;
|
break;
|
||||||
case 'replay.requested':
|
case 'replay.requested':
|
||||||
sendReplayToChannel(payload?.channelId, payload?.requester, payload?.sources || []).catch((err) => {
|
sendReplayToChannel(payload?.channelId, payload?.requester, payload?.sources || [], payload?.title || '').catch((err) => {
|
||||||
logger.warn('Replay send failed', err.message);
|
logger.warn('Replay send failed', err.message);
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ const EventEmitter = require('events');
|
|||||||
const logger = require('../globals/logger').child('replayEngineV2');
|
const logger = require('../globals/logger').child('replayEngineV2');
|
||||||
const roverManager = require('./roverManager');
|
const roverManager = require('./roverManager');
|
||||||
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
|
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
|
||||||
|
const io = require('../globals/io');
|
||||||
|
const { getActiveDrivers } = require('./turnService');
|
||||||
|
const { getNickname } = require('./nicknameService');
|
||||||
|
const { getRecentMessages } = require('./chatService');
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
@@ -22,6 +26,7 @@ const TARGET_FPS = Math.max(10, Number.parseInt(process.env.REPLAY_TARGET_FPS ||
|
|||||||
const MAX_WIDTH = Math.max(320, Number.parseInt(process.env.REPLAY_MAX_WIDTH || '1280', 10));
|
const MAX_WIDTH = Math.max(320, Number.parseInt(process.env.REPLAY_MAX_WIDTH || '1280', 10));
|
||||||
const MAX_HEIGHT = Math.max(180, Number.parseInt(process.env.REPLAY_MAX_HEIGHT || '720', 10));
|
const MAX_HEIGHT = Math.max(180, Number.parseInt(process.env.REPLAY_MAX_HEIGHT || '720', 10));
|
||||||
const MAX_BYTES = Math.floor(Number.parseFloat(process.env.REPLAY_MAX_OUTPUT_MB || '9.5') * 1024 * 1024);
|
const MAX_BYTES = Math.floor(Number.parseFloat(process.env.REPLAY_MAX_OUTPUT_MB || '9.5') * 1024 * 1024);
|
||||||
|
const SIDEBAR_WIDTH = Math.max(220, Number.parseInt(process.env.REPLAY_SIDEBAR_WIDTH || '360', 10));
|
||||||
|
|
||||||
const events = new EventEmitter();
|
const events = new EventEmitter();
|
||||||
|
|
||||||
@@ -358,6 +363,141 @@ function scalePadFilter(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`;
|
return `scale=${tileWidth}:${tileHeight}:force_original_aspect_ratio=decrease:flags=lanczos,pad=${tileWidth}:${tileHeight}:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeAssText(text) {
|
||||||
|
return String(text || '')
|
||||||
|
.replace(/\\/g, '\\\\')
|
||||||
|
.replace(/\{/g, '\\{')
|
||||||
|
.replace(/\}/g, '\\}')
|
||||||
|
.replace(/\r?\n/g, '\\N');
|
||||||
|
}
|
||||||
|
|
||||||
|
function assTimeFromSeconds(totalSeconds) {
|
||||||
|
const safe = Math.max(0, Number(totalSeconds) || 0);
|
||||||
|
const hours = Math.floor(safe / 3600);
|
||||||
|
const mins = Math.floor((safe % 3600) / 60);
|
||||||
|
const secs = Math.floor(safe % 60);
|
||||||
|
const centis = Math.floor((safe - Math.floor(safe)) * 100);
|
||||||
|
return `${hours}:${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}.${String(centis).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeReplayTitle(title, fallback = 'Replay') {
|
||||||
|
const value = String(title || '').trim();
|
||||||
|
if (!value) return fallback;
|
||||||
|
return value.slice(0, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDefaultReplayTitle(requester = '', sources = []) {
|
||||||
|
const requesterLabel = String(requester || 'Someone').trim() || 'Someone';
|
||||||
|
const rover = (Array.isArray(sources) ? sources : []).find((entry) => entry?.type === 'rover');
|
||||||
|
const roverLabel = rover?.label || rover?.id || 'a rover';
|
||||||
|
return `${requesterLabel} driving ${roverLabel}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDriverBatterySnapshot(selectedRoverIds = []) {
|
||||||
|
const activeDrivers = getActiveDrivers();
|
||||||
|
const byId = new Map(roverManager.getRoster().map((rover) => [String(rover.id), rover]));
|
||||||
|
const lines = [];
|
||||||
|
for (const roverId of selectedRoverIds) {
|
||||||
|
const socketId = activeDrivers[String(roverId)];
|
||||||
|
if (!socketId) continue;
|
||||||
|
const socket = io.sockets.sockets.get(socketId);
|
||||||
|
const nickname = getNickname(socket) || socket?.data?.user?.username || String(socketId);
|
||||||
|
const rover = byId.get(String(roverId));
|
||||||
|
const roverName = rover?.name || roverId;
|
||||||
|
const percent = rover?.batteryState?.percentDisplay;
|
||||||
|
const batteryLabel = Number.isFinite(percent) ? `${percent}%` : '--%';
|
||||||
|
lines.push(`${nickname} driving ${roverName} (${batteryLabel})`);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildChatEventsForWindow(startMs, endMs, limit = 18) {
|
||||||
|
const all = getRecentMessages(300, { includeSystem: false });
|
||||||
|
const windowed = all
|
||||||
|
.filter((msg) => Number.isFinite(msg?.ts) && msg.ts >= startMs && msg.ts <= endMs)
|
||||||
|
.slice(-limit);
|
||||||
|
return windowed.map((msg) => {
|
||||||
|
const nickname = String(msg?.nickname || msg?.discordUserName || 'user').trim() || 'user';
|
||||||
|
const text = String(msg?.text || '').replace(/\s+/g, ' ').trim();
|
||||||
|
return {
|
||||||
|
ts: Number(msg.ts),
|
||||||
|
text: `${nickname}: ${text}`.slice(0, 120),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderSidebarVideo({
|
||||||
|
tmpDir,
|
||||||
|
title,
|
||||||
|
durationSec,
|
||||||
|
height,
|
||||||
|
windowStartMs,
|
||||||
|
driverBatteryLines = [],
|
||||||
|
chatEvents = [],
|
||||||
|
}) {
|
||||||
|
const assPath = path.join(tmpDir, 'sidebar.ass');
|
||||||
|
const sidebarPath = path.join(tmpDir, 'sidebar.mp4');
|
||||||
|
const headerLines = [];
|
||||||
|
headerLines.push('[Script Info]');
|
||||||
|
headerLines.push('ScriptType: v4.00+');
|
||||||
|
headerLines.push(`PlayResX: ${SIDEBAR_WIDTH}`);
|
||||||
|
headerLines.push(`PlayResY: ${height}`);
|
||||||
|
headerLines.push('ScaledBorderAndShadow: yes');
|
||||||
|
headerLines.push('');
|
||||||
|
headerLines.push('[V4+ Styles]');
|
||||||
|
headerLines.push(
|
||||||
|
'Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding',
|
||||||
|
);
|
||||||
|
headerLines.push(
|
||||||
|
'Style: Sidebar,DejaVu Sans,24,&H00FFFFFF,&H00FFFFFF,&H64000000,&H96000000,0,0,0,0,100,100,0,0,1,2,0,7,18,18,18,1',
|
||||||
|
);
|
||||||
|
headerLines.push(
|
||||||
|
'Style: Chat,DejaVu Sans,21,&H00E6F2FF,&H00E6F2FF,&H64000000,&H96000000,0,0,0,0,100,100,0,0,1,2,0,7,18,18,280,1',
|
||||||
|
);
|
||||||
|
headerLines.push('');
|
||||||
|
headerLines.push('[Events]');
|
||||||
|
headerLines.push('Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text');
|
||||||
|
|
||||||
|
const fullEnd = assTimeFromSeconds(durationSec);
|
||||||
|
const staticLines = [escapeAssText(title), '', ...driverBatteryLines.map(escapeAssText)];
|
||||||
|
headerLines.push(`Dialogue: 0,0:00:00.00,${fullEnd},Sidebar,,0,0,0,,${staticLines.join('\\N')}`);
|
||||||
|
|
||||||
|
for (let i = 0; i < chatEvents.length; i += 1) {
|
||||||
|
const event = chatEvents[i];
|
||||||
|
const start = Math.max(0, (event.ts - windowStartMs) / 1000);
|
||||||
|
const end = Math.min(durationSec, start + 4.5);
|
||||||
|
if (end <= start) continue;
|
||||||
|
headerLines.push(
|
||||||
|
`Dialogue: 0,${assTimeFromSeconds(start)},${assTimeFromSeconds(end)},Chat,,0,0,0,,${escapeAssText(
|
||||||
|
event.text,
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await fsp.writeFile(assPath, `${headerLines.join('\n')}\n`, 'utf8');
|
||||||
|
|
||||||
|
await execFileAsync(FFMPEG_BIN, [
|
||||||
|
'-y',
|
||||||
|
'-hide_banner',
|
||||||
|
'-loglevel',
|
||||||
|
'error',
|
||||||
|
'-f',
|
||||||
|
'lavfi',
|
||||||
|
'-i',
|
||||||
|
`color=c=0x111111:s=${SIDEBAR_WIDTH}x${height}:r=${TARGET_FPS}:d=${durationSec.toFixed(3)}`,
|
||||||
|
'-vf',
|
||||||
|
`subtitles=${assPath}`,
|
||||||
|
'-c:v',
|
||||||
|
'libx264',
|
||||||
|
'-preset',
|
||||||
|
'veryfast',
|
||||||
|
'-pix_fmt',
|
||||||
|
'yuv420p',
|
||||||
|
sidebarPath,
|
||||||
|
]);
|
||||||
|
return sidebarPath;
|
||||||
|
}
|
||||||
|
|
||||||
async function concatFiles(inputPaths, outPath) {
|
async function concatFiles(inputPaths, outPath) {
|
||||||
const listPath = `${outPath}.concat.txt`;
|
const listPath = `${outPath}.concat.txt`;
|
||||||
const body = inputPaths.map((file) => `file '${file.replace(/'/g, "'\\''")}'`).join('\n');
|
const body = inputPaths.map((file) => `file '${file.replace(/'/g, "'\\''")}'`).join('\n');
|
||||||
@@ -409,13 +549,14 @@ async function probeMaxFrameSize(paths) {
|
|||||||
return { maxWidth, maxHeight };
|
return { maxWidth, maxHeight };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildReplayVideo({ sources = [] } = {}) {
|
async function buildReplayVideo({ sources = [], title = '', requester = '' } = {}) {
|
||||||
if (!Array.isArray(sources) || !sources.length) {
|
if (!Array.isArray(sources) || !sources.length) {
|
||||||
throw new Error('No replay sources selected');
|
throw new Error('No replay sources selected');
|
||||||
}
|
}
|
||||||
|
|
||||||
const tEnd = Date.now() - BUILD_GUARD_MS;
|
const tEnd = Date.now() - BUILD_GUARD_MS;
|
||||||
const tStart = tEnd - BUILD_DURATION_MS;
|
const tStart = tEnd - BUILD_DURATION_MS;
|
||||||
|
const resolvedTitle = sanitizeReplayTitle(title, resolveDefaultReplayTitle(requester, sources));
|
||||||
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'mrr-replay-v2-'));
|
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'mrr-replay-v2-'));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -527,6 +668,22 @@ async function buildReplayVideo({ sources = [] } = {}) {
|
|||||||
tileWidth = clampEven(tileWidth);
|
tileWidth = clampEven(tileWidth);
|
||||||
tileHeight = clampEven(tileHeight);
|
tileHeight = clampEven(tileHeight);
|
||||||
|
|
||||||
|
const selectedRoverIds = usedSources
|
||||||
|
.filter((entry) => entry?.type === 'rover')
|
||||||
|
.map((entry) => String(entry.id));
|
||||||
|
const driverBatteryLines = buildDriverBatterySnapshot(selectedRoverIds);
|
||||||
|
const chatEvents = buildChatEventsForWindow(tStart, tEnd);
|
||||||
|
const durationSec = BUILD_DURATION_MS / 1000;
|
||||||
|
const sidebarPath = await renderSidebarVideo({
|
||||||
|
tmpDir,
|
||||||
|
title: resolvedTitle,
|
||||||
|
durationSec,
|
||||||
|
height: clampEven(tileHeight * layout.rows),
|
||||||
|
windowStartMs: tStart,
|
||||||
|
driverBatteryLines,
|
||||||
|
chatEvents,
|
||||||
|
});
|
||||||
|
|
||||||
const inputArgs = [];
|
const inputArgs = [];
|
||||||
const filterParts = [];
|
const filterParts = [];
|
||||||
const layoutParts = [];
|
const layoutParts = [];
|
||||||
@@ -539,26 +696,27 @@ async function buildReplayVideo({ sources = [] } = {}) {
|
|||||||
layoutParts.push(`${x}_${y}`);
|
layoutParts.push(`${x}_${y}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const audioInputStart = normalizedVideos.length;
|
inputArgs.push('-i', sidebarPath);
|
||||||
|
const sidebarInputIndex = normalizedVideos.length;
|
||||||
|
const audioInputStart = normalizedVideos.length + 1;
|
||||||
for (const audioPath of normalizedAudios) {
|
for (const audioPath of normalizedAudios) {
|
||||||
inputArgs.push('-i', audioPath);
|
inputArgs.push('-i', audioPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedVideos.length === 1) {
|
if (normalizedVideos.length === 1) {
|
||||||
filterParts.push('[v0]null[vout]');
|
filterParts.push('[v0]null[vgrid]');
|
||||||
} else {
|
} else {
|
||||||
filterParts.push(
|
filterParts.push(
|
||||||
`${normalizedVideos.map((_, i) => `[v${i}]`).join('')}` +
|
`${normalizedVideos.map((_, i) => `[v${i}]`).join('')}` +
|
||||||
`xstack=inputs=${normalizedVideos.length}:layout=${layoutParts.join('|')}:fill=black[vout]`,
|
`xstack=inputs=${normalizedVideos.length}:layout=${layoutParts.join('|')}:fill=black[vgrid]`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
filterParts.push(`[vgrid][${sidebarInputIndex}:v]hstack=inputs=2[vout]`);
|
||||||
|
|
||||||
if (normalizedAudios.length) {
|
if (normalizedAudios.length) {
|
||||||
const audioRefs = normalizedAudios.map((_, idx) => `[${audioInputStart + idx}:a]`).join('');
|
const audioRefs = normalizedAudios.map((_, idx) => `[${audioInputStart + idx}:a]`).join('');
|
||||||
filterParts.push(`${audioRefs}amix=inputs=${normalizedAudios.length}:normalize=0,alimiter=limit=0.9[aout]`);
|
filterParts.push(`${audioRefs}amix=inputs=${normalizedAudios.length}:normalize=0,alimiter=limit=0.9[aout]`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const durationSec = BUILD_DURATION_MS / 1000;
|
|
||||||
const targetBitrateKbps = Math.max(400, Math.floor((MAX_BYTES * 8) / durationSec / 1000));
|
const targetBitrateKbps = Math.max(400, Math.floor((MAX_BYTES * 8) / durationSec / 1000));
|
||||||
const outPath = path.join(tmpDir, 'replay.mp4');
|
const outPath = path.join(tmpDir, 'replay.mp4');
|
||||||
|
|
||||||
@@ -596,7 +754,7 @@ async function buildReplayVideo({ sources = [] } = {}) {
|
|||||||
|
|
||||||
await execFileAsync(FFMPEG_BIN, args);
|
await execFileAsync(FFMPEG_BIN, args);
|
||||||
const buffer = await fsp.readFile(outPath);
|
const buffer = await fsp.readFile(outPath);
|
||||||
return { buffer, usedSources, missingSources };
|
return { buffer, usedSources, missingSources, title: resolvedTitle };
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
await fsp.rm(tmpDir, { recursive: true, force: true });
|
await fsp.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ function buildRequesterLabel(socket) {
|
|||||||
return getNickname(socket) || socket?.data?.user?.username || socket?.id || 'unknown';
|
return getNickname(socket) || socket?.data?.user?.username || socket?.id || 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeReplayTitle(value) {
|
||||||
|
if (typeof value !== 'string') return '';
|
||||||
|
return value.trim().slice(0, 120);
|
||||||
|
}
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
socket.on('replay:trigger', (payload = {}, cb = () => {}) => {
|
socket.on('replay:trigger', (payload = {}, cb = () => {}) => {
|
||||||
if (getMode() === MODES.LOCKDOWN) {
|
if (getMode() === MODES.LOCKDOWN) {
|
||||||
@@ -37,6 +42,7 @@ io.on('connection', (socket) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const requester = buildRequesterLabel(socket);
|
const requester = buildRequesterLabel(socket);
|
||||||
|
const title = normalizeReplayTitle(payload?.title);
|
||||||
const attempt = tryTriggerReplay({ by: { source: 'web', requester } });
|
const attempt = tryTriggerReplay({ by: { source: 'web', requester } });
|
||||||
if (!attempt.ok) {
|
if (!attempt.ok) {
|
||||||
cb({ error: 'Replay cooldown active', remainingMs: attempt.remainingMs, state: attempt.state });
|
cb({ error: 'Replay cooldown active', remainingMs: attempt.remainingMs, state: attempt.state });
|
||||||
@@ -48,6 +54,7 @@ io.on('connection', (socket) => {
|
|||||||
payload: {
|
payload: {
|
||||||
channelId,
|
channelId,
|
||||||
requester,
|
requester,
|
||||||
|
title,
|
||||||
sources,
|
sources,
|
||||||
requestedBy: { socketId: socket.id },
|
requestedBy: { socketId: socket.id },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [success, setSuccess] = useState(null);
|
const [success, setSuccess] = useState(null);
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [titleDirty, setTitleDirty] = useState(false);
|
||||||
const replayState = session?.replay || null;
|
const replayState = session?.replay || null;
|
||||||
const [remainingMs, setRemainingMs] = useState(0);
|
const [remainingMs, setRemainingMs] = useState(0);
|
||||||
|
|
||||||
@@ -37,6 +39,19 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
|||||||
return [];
|
return [];
|
||||||
}, [session?.assignment?.roverId]);
|
}, [session?.assignment?.roverId]);
|
||||||
|
|
||||||
|
const defaultTitle = useMemo(() => {
|
||||||
|
const self = Array.isArray(session?.users)
|
||||||
|
? session.users.find((entry) => entry?.socketId === session?.socketId)
|
||||||
|
: null;
|
||||||
|
const nickname = (self?.nickname || 'Someone').trim() || 'Someone';
|
||||||
|
const roverId = session?.assignment?.roverId || null;
|
||||||
|
const roverName =
|
||||||
|
roverId && Array.isArray(session?.roster)
|
||||||
|
? session.roster.find((entry) => String(entry?.id) === String(roverId))?.name || roverId
|
||||||
|
: 'a rover';
|
||||||
|
return `${nickname} driving ${roverName}`;
|
||||||
|
}, [session?.users, session?.socketId, session?.assignment?.roverId, session?.roster]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const saved = settings?.[panelId];
|
const saved = settings?.[panelId];
|
||||||
if (Array.isArray(saved) && saved.length) {
|
if (Array.isArray(saved) && saved.length) {
|
||||||
@@ -46,6 +61,12 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
|||||||
setSelected(defaults);
|
setSelected(defaults);
|
||||||
}, [settings?.[panelId], defaults, panelId]);
|
}, [settings?.[panelId], defaults, panelId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!titleDirty) {
|
||||||
|
setTitle(defaultTitle);
|
||||||
|
}
|
||||||
|
}, [defaultTitle, titleDirty]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const allowed = new Set(sources.map((source) => source.key));
|
const allowed = new Set(sources.map((source) => source.key));
|
||||||
setSelected((prev) => prev.filter((key) => allowed.has(key)));
|
setSelected((prev) => prev.filter((key) => allowed.has(key)));
|
||||||
@@ -93,7 +114,8 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
|||||||
const [type, id] = key.split(':');
|
const [type, id] = key.split(':');
|
||||||
return { type, id };
|
return { type, id };
|
||||||
});
|
});
|
||||||
await triggerReplay(payload);
|
const resolvedTitle = String(title || '').trim() || defaultTitle;
|
||||||
|
await triggerReplay(payload, resolvedTitle);
|
||||||
setSuccess('Replay sent. Check the Discord replay channel.');
|
setSuccess('Replay sent. Check the Discord replay channel.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
@@ -116,6 +138,17 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
|||||||
<GroupList title="Room Cams" items={grouped.rooms} selected={selected} onToggle={toggleKey} />
|
<GroupList title="Room Cams" items={grouped.rooms} selected={selected} onToggle={toggleKey} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="field-input w-full text-xs"
|
||||||
|
value={title}
|
||||||
|
onChange={(event) => {
|
||||||
|
setTitle(event.target.value);
|
||||||
|
setTitleDirty(true);
|
||||||
|
}}
|
||||||
|
placeholder={defaultTitle}
|
||||||
|
maxLength={120}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="button-dark w-full text-xs disabled:opacity-40"
|
className="button-dark w-full text-xs disabled:opacity-40"
|
||||||
|
|||||||
@@ -171,7 +171,12 @@ export function SessionProvider({ children }) {
|
|||||||
requestVerification: () => emitWithAck('verification:request'),
|
requestVerification: () => emitWithAck('verification:request'),
|
||||||
requestPrivateRoverAccess: (roverId) =>
|
requestPrivateRoverAccess: (roverId) =>
|
||||||
emitWithAck('session:privateRover:requestAccess', { roverId }),
|
emitWithAck('session:privateRover:requestAccess', { roverId }),
|
||||||
triggerReplay: (sources = []) => emitWithAck('replay:trigger', { sources }),
|
triggerReplay: (sourcesOrPayload = [], title = '') => {
|
||||||
|
if (sourcesOrPayload && typeof sourcesOrPayload === 'object' && !Array.isArray(sourcesOrPayload)) {
|
||||||
|
return emitWithAck('replay:trigger', sourcesOrPayload);
|
||||||
|
}
|
||||||
|
return emitWithAck('replay:trigger', { sources: sourcesOrPayload, title });
|
||||||
|
},
|
||||||
setCommunityGoal: (text) => emitWithAck('communityGoal:set', { text }),
|
setCommunityGoal: (text) => emitWithAck('communityGoal:set', { text }),
|
||||||
setAdminReason: (text) => emitWithAck('adminReason:set', { text }),
|
setAdminReason: (text) => emitWithAck('adminReason:set', { text }),
|
||||||
rebootRover: (roverId) =>
|
rebootRover: (roverId) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user