feat: implement full session recording with muxing support

- Add session recording metadata and mux filter builder in src/recorder/sessionRecording.ts.
- Update SegmentMetadata to include recordingSessionId in src/types.ts and src/recorder/metadata.ts.
- Modify recorder lifecycle to track sessions, register segments, and finalize recordings on stop.
- Create tests for session recording functionality in tests/recorder/sessionRecording.test.ts and tests/recorder/metadata.test.ts.
- Document session recording design and implementation plan in docs/superpowers/specs/2026-05-16-session-full-recording-design.md and docs/superpowers/plans/2026-05-16-session-full-recording.md.
This commit is contained in:
MythEclipse
2026-05-16 17:59:17 +07:00
parent 8b33af8286
commit 2744e7035b
9 changed files with 1338 additions and 40 deletions
+2
View File
@@ -55,6 +55,7 @@ export function createSegmentMetadata(
user: UserMetadata,
segment: SegmentState,
sessionId: string,
recordingSessionId: string,
sessionStartTime: number,
recordingSegmentMs: number,
): SegmentMetadata {
@@ -62,6 +63,7 @@ export function createSegmentMetadata(
return {
...user,
sessionId,
recordingSessionId,
sessionStartTime,
segmentIndex: segment.index,
segmentMs: recordingSegmentMs,
+192
View File
@@ -0,0 +1,192 @@
import fs from "node:fs";
import path from "node:path";
import {
buildMuxFfmpegArgs,
runFfmpeg as defaultRunFfmpeg,
} from "../audio/ffmpegProcess";
import type { UserMetadata } from "../types";
export type SessionRecordingStatus =
| "pending"
| "completed"
| "failed"
| "empty";
export interface RecordingSessionOptions {
guildId: string;
channelId: string;
channelName: string;
startTime: number;
recordingsDir: string;
}
export interface SessionSegmentInput {
user: UserMetadata;
oggPath: string;
jsonPath: string;
startTime: number;
endTime: number;
}
export interface SessionParticipant {
userId: string;
username: string;
tag: string;
displayName: string;
avatarUrl: string;
}
export interface SessionSegmentRef {
userId: string;
oggPath: string;
jsonPath: string;
startTime: number;
endTime: number;
durationMs: number;
offsetMs: number;
}
export interface SessionRecordingMetadata {
sessionId: string;
guildId: string;
channelId: string;
channelName: string;
startTime: number;
endTime: number;
durationMs: number;
status: SessionRecordingStatus;
outputFile: string | null;
participants: SessionParticipant[];
segments: SessionSegmentRef[];
error?: string;
}
export interface RecordingSession {
readonly sessionId: string;
readonly recordingsDir: string;
readonly startTime: number;
registerSegment(input: SessionSegmentInput): void;
snapshot(endTime: number): SessionRecordingMetadata;
}
export interface FinalizeRecordingSessionDependencies {
endTime?: number;
mkdir?: (dir: string) => void;
writeJson?: (file: string, metadata: SessionRecordingMetadata) => void;
runFfmpeg?: (args: string[]) => Promise<void>;
}
export function createRecordingSession(
options: RecordingSessionOptions,
): RecordingSession {
const sessionId = `${options.guildId}-${options.channelId}-${options.startTime}`;
const participants = new Map<string, SessionParticipant>();
const segments: SessionSegmentRef[] = [];
return {
sessionId,
recordingsDir: options.recordingsDir,
startTime: options.startTime,
registerSegment(input: SessionSegmentInput): void {
participants.set(input.user.userId, {
userId: input.user.userId,
username: input.user.username,
tag: input.user.tag,
displayName: input.user.displayName,
avatarUrl: input.user.avatarUrl,
});
segments.push({
userId: input.user.userId,
oggPath: input.oggPath,
jsonPath: input.jsonPath,
startTime: input.startTime,
endTime: input.endTime,
durationMs: input.endTime - input.startTime,
offsetMs: input.startTime - options.startTime,
});
},
snapshot(endTime: number): SessionRecordingMetadata {
return {
sessionId,
guildId: options.guildId,
channelId: options.channelId,
channelName: options.channelName,
startTime: options.startTime,
endTime,
durationMs: endTime - options.startTime,
status: "pending",
outputFile: null,
participants: Array.from(participants.values()),
segments: [...segments],
};
},
};
}
export function buildSessionMuxFilter(
segments: Array<{ startTime: number }>,
sessionStartTime: number,
): string {
const filters = segments.map((segment, index) => {
const delayMs = Math.max(0, segment.startTime - sessionStartTime);
return `[${index}:a]adelay=${delayMs}|${delayMs}[pad${index}]`;
});
const inputs = segments.map((_, index) => `[pad${index}]`).join("");
filters.push(
`${inputs}amix=inputs=${segments.length}:dropout_transition=0[out]`,
);
return filters.join(";");
}
export async function finalizeRecordingSession(
session: RecordingSession,
dependencies: FinalizeRecordingSessionDependencies = {},
): Promise<void> {
const endTime = dependencies.endTime ?? Date.now();
const sessionDir = path.join(
session.recordingsDir,
"sessions",
session.sessionId,
);
const outputFile = path.join(sessionDir, "full.ogg");
const metadataFile = path.join(sessionDir, "session.json");
const mkdir =
dependencies.mkdir ?? ((dir) => fs.mkdirSync(dir, { recursive: true }));
const writeJson =
dependencies.writeJson ??
((file, metadata) =>
fs.writeFileSync(file, JSON.stringify(metadata, null, 2)));
const runFfmpeg = dependencies.runFfmpeg ?? defaultRunFfmpeg;
mkdir(sessionDir);
const metadata = session.snapshot(endTime);
if (metadata.segments.length === 0) {
writeJson(metadataFile, { ...metadata, status: "empty" });
return;
}
try {
await runFfmpeg(
buildMuxFfmpegArgs({
inputs: metadata.segments.map((segment) => segment.oggPath),
filter: buildSessionMuxFilter(metadata.segments, metadata.startTime),
output: outputFile,
codec: "libopus",
}),
);
writeJson(metadataFile, {
...metadata,
status: "completed",
outputFile,
});
} catch (error) {
writeJson(metadataFile, {
...metadata,
status: "failed",
error: error instanceof Error ? error.message : String(error),
});
}
}