优化目录结构

This commit is contained in:
2026-05-25 20:37:36 +08:00
parent bbe7e71274
commit 40fd7f7e08
101 changed files with 108 additions and 110 deletions

View File

@@ -0,0 +1,80 @@
export const AUDIO_CONFIG = {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
};
export const VAD_CONFIG = {
threshold: 15,
debounceTime: 500,
fftSize: 256
};
export const MEDIA_CONSTRAINTS = {
video: {
width: { ideal: 1920, max: 1920 },
height: { ideal: 1080, max: 1080 },
frameRate: { ideal: 30, max: 30 }
},
audio: AUDIO_CONFIG
};
export const VIDEO_ONLY_CONSTRAINT = {
video: {
width: { ideal: 1920, max: 1920 },
height: { ideal: 1080, max: 1080 },
frameRate: { ideal: 30, max: 30 }
},
audio: false
};
const TARGET_RESOLUTION_BITRATE_MAP = {
270: 1000000,
480: 1500000,
720: 2500000,
1080: 4000000,
1440: 6000000
};
export function buildVideoConstraints(savedResolution) {
if (!savedResolution) {
return MEDIA_CONSTRAINTS.video;
}
return {
width: { ideal: savedResolution.width, max: savedResolution.width },
height: { ideal: savedResolution.height, max: savedResolution.height },
frameRate: { ideal: 30, max: 30 }
};
}
export function getResolutionLabel(height) {
if (height >= 1440) {
return '2K 1440p';
}
if (height >= 1080) {
return '1080p 超清';
}
if (height >= 720) {
return '720p 高清';
}
return '480p 流畅';
}
export function getTargetResolutionBitrate(height) {
return TARGET_RESOLUTION_BITRATE_MAP[height] || 2500000;
}
export function getAdaptiveVideoBitrate(height) {
const supportedHeights = Object.keys(TARGET_RESOLUTION_BITRATE_MAP).map(Number).sort((a, b) => a - b);
let maxBitrate = TARGET_RESOLUTION_BITRATE_MAP[1080];
for (const supportedHeight of supportedHeights) {
if (height <= supportedHeight) {
return TARGET_RESOLUTION_BITRATE_MAP[supportedHeight];
}
maxBitrate = TARGET_RESOLUTION_BITRATE_MAP[supportedHeight];
}
return maxBitrate;
}

View File

@@ -0,0 +1,62 @@
export function createAudioAnalyser(stream, fftSize) {
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
const audioContext = new AudioContextCtor();
const source = audioContext.createMediaStreamSource(stream);
const analyser = audioContext.createAnalyser();
analyser.fftSize = fftSize;
source.connect(analyser);
return {
audioContext,
analyser,
dataArray: new Uint8Array(analyser.frequencyBinCount)
};
}
export function getAudioLevel(analyser, dataArray) {
analyser.getByteTimeDomainData(dataArray);
let sum = 0;
for (let i = 0; i < dataArray.length; i++) {
const amplitude = dataArray[i] - 128;
sum += amplitude * amplitude;
}
const rms = Math.sqrt(sum / dataArray.length);
return rms / 128;
}
function formatPacketLossRate(packetsLost, packetsReceived) {
if (packetsReceived <= 0) {
return '0%';
}
return `${((packetsLost / (packetsLost + packetsReceived)) * 100).toFixed(2)}%`;
}
export function buildStatsLogPayload(networkQuality, statsSummary) {
return {
networkQuality,
video: {
'Packets Lost': statsSummary.video.packetsLost,
'Packets Received': statsSummary.video.packetsReceived,
'Packet Loss Rate': formatPacketLossRate(
statsSummary.video.packetsLost,
statsSummary.video.packetsReceived
),
'Jitter': `${(statsSummary.video.jitter * 1000).toFixed(2)}ms`,
'Round Trip Time': `${(statsSummary.video.roundTripTime * 1000).toFixed(2)}ms`,
'FPS': statsSummary.video.fps.toFixed(1),
'Bitrate': `${statsSummary.video.bitrate}kbps`
},
audio: {
'Packets Lost': statsSummary.audio.packetsLost,
'Packets Received': statsSummary.audio.packetsReceived,
'Packet Loss Rate': formatPacketLossRate(
statsSummary.audio.packetsLost,
statsSummary.audio.packetsReceived
),
'Jitter': `${(statsSummary.audio.jitter * 1000).toFixed(2)}ms`
}
};
}

View File

@@ -0,0 +1,336 @@
const DEFAULT_WIDTH = 1280;
const DEFAULT_HEIGHT = 720;
const DEFAULT_FPS = 30;
const MIME_TYPE_CANDIDATES = [
{ mimeType: 'video/mp4;codecs=avc1.42E01E,mp4a.40.2', extension: 'mp4' },
{ mimeType: 'video/mp4', extension: 'mp4' },
{ mimeType: 'video/webm;codecs=vp9,opus', extension: 'webm' },
{ mimeType: 'video/webm;codecs=vp8,opus', extension: 'webm' },
{ mimeType: 'video/webm', extension: 'webm' }
];
function getSupportedFormat(mediaRecorderCtor) {
if (!mediaRecorderCtor || typeof mediaRecorderCtor.isTypeSupported !== 'function') {
return {
mimeType: '',
extension: 'webm'
};
}
return MIME_TYPE_CANDIDATES.find(format => mediaRecorderCtor.isTypeSupported(format.mimeType)) || {
mimeType: '',
extension: 'webm'
};
}
function isElementVisible(element) {
if (!element || element.classList.contains('hidden')) {
return false;
}
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
function drawVideoCover(context, video, x, y, width, height) {
const videoWidth = video.videoWidth || width;
const videoHeight = video.videoHeight || height;
const sourceRatio = videoWidth / videoHeight;
const targetRatio = width / height;
let sourceX = 0;
let sourceY = 0;
let sourceWidth = videoWidth;
let sourceHeight = videoHeight;
if (sourceRatio > targetRatio) {
sourceWidth = videoHeight * targetRatio;
sourceX = (videoWidth - sourceWidth) / 2;
} else {
sourceHeight = videoWidth / targetRatio;
sourceY = (videoHeight - sourceHeight) / 2;
}
context.drawImage(video, sourceX, sourceY, sourceWidth, sourceHeight, x, y, width, height);
}
function drawEmptyFrame(context, canvas) {
context.fillStyle = '#111827';
context.fillRect(0, 0, canvas.width, canvas.height);
context.fillStyle = '#9ca3af';
context.font = '24px sans-serif';
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillText('等待会议画面...', canvas.width / 2, canvas.height / 2);
}
function drawGrid(context, videos, canvas) {
const columns = Math.ceil(Math.sqrt(videos.length));
const rows = Math.ceil(videos.length / columns);
const gap = 8;
const tileWidth = (canvas.width - gap * (columns - 1)) / columns;
const tileHeight = (canvas.height - gap * (rows - 1)) / rows;
videos.forEach((video, index) => {
const column = index % columns;
const row = Math.floor(index / columns);
const x = column * (tileWidth + gap);
const y = row * (tileHeight + gap);
drawVideoCover(context, video, x, y, tileWidth, tileHeight);
});
}
function drawLocalPreview(context, localVideo, canvas) {
const previewWidth = Math.floor(canvas.width * 0.22);
const previewHeight = Math.floor(previewWidth * 9 / 16);
const margin = 24;
const x = canvas.width - previewWidth - margin;
const y = canvas.height - previewHeight - margin;
context.fillStyle = 'rgba(0, 0, 0, 0.4)';
context.fillRect(x - 4, y - 4, previewWidth + 8, previewHeight + 8);
drawVideoCover(context, localVideo, x, y, previewWidth, previewHeight);
}
function collectStreams({ localStream, remoteStream, remoteStreams } = {}) {
return [
localStream,
remoteStream,
...Object.values(remoteStreams || {})
].filter(Boolean);
}
function collectLiveAudioTracks(streams) {
return streams.flatMap(stream => stream.getAudioTracks())
.filter(track => track.readyState !== 'ended');
}
export class MeetingRecorder {
constructor({
documentRef = document,
windowRef = window,
width = DEFAULT_WIDTH,
height = DEFAULT_HEIGHT,
fps = DEFAULT_FPS
} = {}) {
this.document = documentRef;
this.window = windowRef;
this.width = width;
this.height = height;
this.fps = fps;
this.mediaRecorder = null;
this.chunks = [];
this.animationFrameId = null;
this.audioContext = null;
this.audioSources = [];
this.recordingStream = null;
this.connectionId = '';
}
isSupported() {
return Boolean(
this.window.MediaRecorder &&
this.document.createElement('canvas').captureStream
);
}
isRecording() {
return Boolean(this.mediaRecorder && this.mediaRecorder.state !== 'inactive');
}
async start({ localStream, remoteStream, remoteStreams, connectionId } = {}) {
if (this.isRecording()) {
throw new Error('会议正在录制中');
}
if (!this.isSupported()) {
throw new Error('当前浏览器不支持会议录制');
}
const canvas = this.document.createElement('canvas');
canvas.width = this.width;
canvas.height = this.height;
const context = canvas.getContext('2d');
if (!context) {
throw new Error('无法创建录制画布');
}
this.connectionId = connectionId || '';
this.chunks = [];
this.canvas = canvas;
this.context = context;
const canvasStream = canvas.captureStream(this.fps);
const streams = collectStreams({ localStream, remoteStream, remoteStreams });
const audioTrack = this.createMixedAudioTrack(streams);
const tracks = [
...canvasStream.getVideoTracks(),
...(audioTrack ? [audioTrack] : [])
];
this.recordingStream = new this.window.MediaStream(tracks);
try {
this.startDrawing();
this.startMediaRecorder(this.recordingStream);
}
catch (error) {
this.cleanup();
throw error;
}
}
stop() {
if (!this.isRecording()) {
return Promise.resolve(null);
}
return new Promise((resolve, reject) => {
this.pendingStop = { resolve, reject };
this.mediaRecorder.stop();
});
}
createMixedAudioTrack(streams) {
const audioTracks = collectLiveAudioTracks(streams);
if (audioTracks.length === 0) {
return null;
}
const AudioContextCtor = this.window.AudioContext || this.window.webkitAudioContext;
if (!AudioContextCtor) {
return audioTracks[0].clone ? audioTracks[0].clone() : audioTracks[0];
}
this.audioContext = new AudioContextCtor();
const destination = this.audioContext.createMediaStreamDestination();
audioTracks.forEach(track => {
const sourceStream = new this.window.MediaStream([track]);
const source = this.audioContext.createMediaStreamSource(sourceStream);
source.connect(destination);
this.audioSources.push(source);
});
return destination.stream.getAudioTracks()[0] || null;
}
startMediaRecorder(stream) {
const MediaRecorderCtor = this.window.MediaRecorder;
const format = getSupportedFormat(MediaRecorderCtor);
const options = format.mimeType ? { mimeType: format.mimeType } : {};
this.fileExtension = format.extension;
this.mediaRecorder = new MediaRecorderCtor(stream, options);
this.mediaRecorder.ondataavailable = (event) => {
if (event.data && event.data.size > 0) {
this.chunks.push(event.data);
}
};
this.mediaRecorder.onerror = (event) => {
if (this.pendingStop) {
this.pendingStop.reject(event.error || new Error('录制失败'));
this.pendingStop = null;
}
this.cleanup();
};
this.mediaRecorder.onstop = () => {
const blob = new Blob(this.chunks, { type: this.mediaRecorder.mimeType || 'video/webm' });
const filename = this.buildFilename();
const mimeType = blob.type || this.mediaRecorder.mimeType || 'video/webm';
this.cleanup();
if (this.pendingStop) {
this.pendingStop.resolve({ blob, filename, mimeType });
this.pendingStop = null;
}
};
this.mediaRecorder.start(1000);
}
startDrawing() {
const draw = () => {
this.drawFrame();
this.animationFrameId = this.window.requestAnimationFrame(draw);
};
draw();
}
drawFrame() {
const context = this.context;
const canvas = this.canvas;
const videos = this.getRecordableVideos();
const localVideo = videos.find(video => video.id === 'localVideo');
const remoteVideos = videos.filter(video => video !== localVideo);
context.fillStyle = '#020617';
context.fillRect(0, 0, canvas.width, canvas.height);
if (remoteVideos.length > 0) {
drawGrid(context, remoteVideos, canvas);
if (localVideo) {
drawLocalPreview(context, localVideo, canvas);
}
return;
}
if (localVideo) {
drawVideoCover(context, localVideo, 0, 0, canvas.width, canvas.height);
return;
}
drawEmptyFrame(context, canvas);
}
getRecordableVideos() {
return Array.from(this.document.querySelectorAll('#participantGrid video, #remoteVideo, #localVideo'))
.filter(video => video.srcObject && isElementVisible(video) && video.readyState >= 2);
}
download(blob, filename = this.buildFilename()) {
const url = this.window.URL.createObjectURL(blob);
const link = this.document.createElement('a');
link.href = url;
link.download = filename;
link.style.display = 'none';
this.document.body.appendChild(link);
link.click();
link.remove();
this.window.setTimeout(() => {
this.window.URL.revokeObjectURL(url);
}, 1000);
return filename;
}
buildFilename() {
const datePart = new Date().toISOString().replace(/[:.]/g, '-');
const meetingPart = this.connectionId ? `-${this.connectionId}` : '';
return `meeting-recording${meetingPart}-${datePart}.${this.fileExtension || 'webm'}`;
}
cleanup() {
if (this.animationFrameId) {
this.window.cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
if (this.recordingStream) {
this.recordingStream.getTracks().forEach(track => track.stop());
this.recordingStream = null;
}
if (this.audioContext) {
this.audioContext.close();
this.audioContext = null;
}
this.audioSources = [];
this.mediaRecorder = null;
this.canvas = null;
this.context = null;
this.chunks = [];
}
}

View File

@@ -0,0 +1,191 @@
import { createParticipantTile, getParticipantTile } from '../participants/renderer-participant-grid.js';
import { createLogger } from '../../shared/logger.js';
const logger = createLogger('renderer-media');
export function getVideoResolution(track) {
if (track && track.getSettings) {
const settings = track.getSettings();
return {
width: settings.width || 640,
height: settings.height || 480
};
}
return { width: 640, height: 480 };
}
export function adjustVideoSize(videoElement) {
if (!videoElement) return;
const container = videoElement.parentElement;
if (!container) return;
videoElement.style.transform = 'translateZ(0)';
videoElement.style.willChange = 'transform';
container.style.display = 'flex';
container.style.alignItems = 'center';
container.style.justifyContent = 'center';
videoElement.style.imageRendering = 'auto';
videoElement.style.maxWidth = '100%';
videoElement.style.maxHeight = '100%';
videoElement.style.objectFit = 'contain';
}
export function renderParticipantStreamMedia({
grid,
stream,
connectionId,
displayName,
getGridTemplateColumns,
remoteVideo,
connectingOverlay,
remoteVideoPlaceholder
}) {
if (!grid) return;
grid.classList.remove('hidden');
let tile = getParticipantTile(grid, connectionId);
if (!tile) {
tile = createParticipantTile(connectionId, displayName);
grid.appendChild(tile);
logger.debug(`Created participant video tile for ${connectionId}`);
}
const video = tile.querySelector('video');
if (video && stream) {
if (video.srcObject === stream) {
logger.debug(`Same stream for participant ${connectionId}, ensuring playback`);
video.play().catch(error => logger.debug('Auto-play prevented:', error.message));
} else {
video.srcObject = stream;
video.play().catch(error => logger.debug('Auto-play prevented:', error.message));
logger.debug(`Set remote stream for participant tile ${connectionId}`);
}
}
const remoteVideoContainer = remoteVideo?.closest('.absolute.inset-0.video-fade-in');
if (remoteVideoContainer) {
remoteVideoContainer.classList.add('hidden');
}
const tileCount = grid.querySelectorAll('[data-participant-id]').length;
grid.style.gridTemplateColumns = getGridTemplateColumns(tileCount);
if (connectingOverlay) {
connectingOverlay.classList.add('hidden');
}
if (remoteVideoPlaceholder) {
remoteVideoPlaceholder.classList.add('hidden');
}
}
export function renderSingleRemoteStreamMedia({
remoteVideo,
stream,
disconnectedOverlay,
remoteVideoPlaceholder,
connectingOverlay
}) {
if (!remoteVideo || !stream) {
logger.error('Either remoteVideo element or stream is missing');
return;
}
logger.debug('Rendering remote stream:', stream, 'tracks:', stream.getTracks().map(track => `${track.kind}(${track.readyState})`));
if (remoteVideo.srcObject === stream) {
logger.debug('Same stream object, track added - ensuring playback');
remoteVideo.play().catch(error => logger.debug('Auto-play prevented:', error.message));
return;
}
remoteVideo.srcObject = stream;
remoteVideo.autoplay = true;
remoteVideo.playsinline = true;
remoteVideo.muted = false;
remoteVideo.play().catch(error => {
logger.debug('Auto-play prevented, will retry on interaction:', error.message);
});
if (disconnectedOverlay) {
disconnectedOverlay.classList.add('hidden');
}
const videoTracks = stream.getVideoTracks();
const audioTracks = stream.getAudioTracks();
logger.debug(`Stream has ${videoTracks.length} video tracks, ${audioTracks.length} audio tracks`);
if (videoTracks.length === 0) {
logger.debug('Audio-only stream, waiting for video track...');
return;
}
if (remoteVideoPlaceholder) {
remoteVideoPlaceholder.classList.add('hidden');
}
if (connectingOverlay) {
connectingOverlay.classList.add('hidden');
}
const activeVideoTrack = videoTracks.find(track => track.readyState === 'live');
if (!activeVideoTrack) return;
adjustVideoSize(remoteVideo, getVideoResolution(activeVideoTrack));
activeVideoTrack.addEventListener('resize', () => {
adjustVideoSize(remoteVideo, getVideoResolution(activeVideoTrack));
});
}
export function clearParticipantGrid(grid) {
if (!grid) return;
grid.querySelectorAll('[data-participant-id]').forEach(tile => {
const video = tile.querySelector('video');
if (video) {
video.srcObject = null;
}
tile.remove();
});
grid.classList.add('hidden');
}
export function removeParticipantTile({
grid,
connectionId,
getGridTemplateColumns,
remoteVideo,
remoteVideoPlaceholder,
remoteNetworkIndicator
}) {
if (!grid) return;
const tile = getParticipantTile(grid, connectionId);
if (tile) {
const video = tile.querySelector('video');
if (video) {
video.srcObject = null;
}
tile.remove();
logger.debug(`Removed participant video tile for ${connectionId}`);
}
const remainingTiles = grid.querySelectorAll('[data-participant-id]');
if (remainingTiles.length === 0) {
grid.classList.add('hidden');
const remoteVideoContainer = remoteVideo?.closest('.absolute.inset-0.video-fade-in');
if (remoteVideoContainer) {
remoteVideoContainer.classList.remove('hidden');
}
if (remoteVideoPlaceholder) {
remoteVideoPlaceholder.classList.remove('hidden');
}
} else {
grid.style.gridTemplateColumns = getGridTemplateColumns(remainingTiles.length);
}
if (remoteNetworkIndicator) {
remoteNetworkIndicator.className = 'w-2 h-2 bg-gray-500 rounded-full';
}
}

View File

@@ -0,0 +1,96 @@
function createStatsSummary() {
return {
video: {
packetsLost: 0,
packetsReceived: 0,
bytesReceived: 0,
jitter: 0,
roundTripTime: 0,
fps: 0,
bitrate: 0
},
audio: {
packetsLost: 0,
packetsReceived: 0,
bytesReceived: 0,
jitter: 0
},
network: {
totalPacketsLost: 0,
totalPacketsReceived: 0,
inboundRtpCount: 0,
jitter: 0,
roundTripTime: 0
}
};
}
export function summarizeInboundStats(stats) {
const summary = createStatsSummary();
stats.forEach((report) => {
if (report.type === 'inbound-rtp' && report.mediaType === 'video') {
summary.network.inboundRtpCount++;
if (report.packetsLost !== undefined && report.packetsReceived !== undefined) {
summary.network.totalPacketsLost += report.packetsLost;
summary.network.totalPacketsReceived += report.packetsReceived;
}
if (report.jitter !== undefined) {
summary.network.jitter = Math.max(summary.network.jitter, report.jitter);
}
if (report.roundTripTime !== undefined) {
summary.network.roundTripTime = Math.max(summary.network.roundTripTime, report.roundTripTime);
}
summary.video.packetsLost = report.packetsLost || 0;
summary.video.packetsReceived = report.packetsReceived || 0;
summary.video.bytesReceived = report.bytesReceived || 0;
summary.video.jitter = report.jitter || 0;
summary.video.roundTripTime = report.roundTripTime || 0;
summary.video.fps = report.framesPerSecond || 0;
if (report.bytesReceived && report.timestamp) {
const duration = report.timestamp / 1000;
summary.video.bitrate = duration > 0
? Math.round((report.bytesReceived * 8) / (duration * 1000))
: 0;
}
} else if (report.type === 'inbound-rtp' && report.mediaType === 'audio') {
summary.audio.packetsLost = report.packetsLost || 0;
summary.audio.packetsReceived = report.packetsReceived || 0;
summary.audio.bytesReceived = report.bytesReceived || 0;
summary.audio.jitter = report.jitter || 0;
}
});
return summary;
}
export function getNetworkQualityFromSummary(summary) {
const { totalPacketsLost, totalPacketsReceived, inboundRtpCount, jitter, roundTripTime } = summary.network;
if (inboundRtpCount === 0) {
return 'no_signal';
}
const packetLossRate = totalPacketsReceived > 0
? totalPacketsLost / (totalPacketsLost + totalPacketsReceived)
: 0;
const jitterMs = jitter * 1000;
const rttMs = roundTripTime * 1000;
if (packetLossRate > 0.05 || jitterMs > 100 || rttMs > 300) {
return 'poor';
}
if (packetLossRate > 0.02 || jitterMs > 50 || rttMs > 150) {
return 'fair';
}
if (packetLossRate > 0.01 || jitterMs > 30 || rttMs > 100) {
return 'good';
}
return 'excellent';
}