Files
openscreen/src/lib/exporter/videoExporter.ts
T
2025-11-24 17:11:37 -07:00

316 lines
10 KiB
TypeScript

import type { ExportConfig, ExportProgress, ExportResult } from './types';
import { VideoFileDecoder } from './videoDecoder';
import { FrameRenderer } from './frameRenderer';
import { VideoMuxer } from './muxer';
import type { ZoomRegion, CropRegion } from '@/components/video-editor/types';
interface VideoExporterConfig extends ExportConfig {
videoUrl: string;
wallpaper: string;
zoomRegions: ZoomRegion[];
showShadow: boolean;
showBlur: boolean;
cropRegion: CropRegion;
onProgress?: (progress: ExportProgress) => void;
}
export class VideoExporter {
private config: VideoExporterConfig;
private decoder: VideoFileDecoder | null = null;
private renderer: FrameRenderer | null = null;
private encoder: VideoEncoder | null = null;
private muxer: VideoMuxer | null = null;
private cancelled = false;
private encodeQueue = 0;
// Increased queue size for better throughput with hardware encoding
private readonly MAX_ENCODE_QUEUE = 120;
private videoDescription: Uint8Array | undefined;
private videoColorSpace: VideoColorSpaceInit | undefined;
// Track muxing promises for parallel processing
private muxingPromises: Promise<void>[] = [];
private chunkCount = 0;
constructor(config: VideoExporterConfig) {
this.config = config;
}
async export(): Promise<ExportResult> {
try {
this.cleanup();
this.cancelled = false;
// Initialize decoder and load video
this.decoder = new VideoFileDecoder();
const videoInfo = await this.decoder.loadVideo(this.config.videoUrl);
// Initialize frame renderer
this.renderer = new FrameRenderer({
width: this.config.width,
height: this.config.height,
wallpaper: this.config.wallpaper,
zoomRegions: this.config.zoomRegions,
showShadow: this.config.showShadow,
showBlur: this.config.showBlur,
cropRegion: this.config.cropRegion,
videoWidth: videoInfo.width,
videoHeight: videoInfo.height,
});
await this.renderer.initialize();
// Initialize video encoder
const totalFrames = Math.ceil(videoInfo.duration * this.config.frameRate);
await this.initializeEncoder();
// Initialize muxer
this.muxer = new VideoMuxer(this.config, false);
await this.muxer.initialize();
// Get the video element for frame extraction
const videoElement = this.decoder.getVideoElement();
if (!videoElement) {
throw new Error('Video element not available');
}
// Process frames continuously without batching delays
const frameDuration = 1_000_000 / this.config.frameRate; // in microseconds
let frameIndex = 0;
const timeStep = 1 / this.config.frameRate;
while (frameIndex < totalFrames && !this.cancelled) {
const i = frameIndex;
const timestamp = i * frameDuration;
const videoTime = i * timeStep;
// Seek if needed or wait for first frame to be ready
const needsSeek = Math.abs(videoElement.currentTime - videoTime) > 0.001;
if (needsSeek || i === 0) {
if (needsSeek) {
videoElement.currentTime = videoTime;
}
// Wait for video frame to be ready
await new Promise<void>(resolve => {
videoElement.requestVideoFrameCallback(() => resolve());
});
}
// Create a VideoFrame from the video element (on GPU!)
const videoFrame = new VideoFrame(videoElement, {
timestamp,
});
// Render the frame with all effects
await this.renderer!.renderFrame(videoFrame, timestamp);
videoFrame.close();
const canvas = this.renderer!.getCanvas();
// Create VideoFrame from canvas on GPU without reading pixels
// @ts-ignore - colorSpace not in TypeScript definitions but works at runtime
const exportFrame = new VideoFrame(canvas, {
timestamp,
duration: frameDuration,
colorSpace: {
primaries: 'bt709',
transfer: 'iec61966-2-1',
matrix: 'rgb',
fullRange: true,
},
});
// Check encoder queue before encoding to keep it full
while (this.encodeQueue >= this.MAX_ENCODE_QUEUE && !this.cancelled) {
await new Promise(resolve => setTimeout(resolve, 0));
}
if (this.encoder && this.encoder.state === 'configured') {
this.encodeQueue++;
this.encoder.encode(exportFrame, { keyFrame: i % 150 === 0 });
}
exportFrame.close();
frameIndex++;
// Update progress
if (this.config.onProgress) {
this.config.onProgress({
currentFrame: frameIndex,
totalFrames,
percentage: (frameIndex / totalFrames) * 100,
estimatedTimeRemaining: 0,
});
}
}
if (this.cancelled) {
return { success: false, error: 'Export cancelled' };
}
// Finalize encoding
if (this.encoder && this.encoder.state === 'configured') {
await this.encoder.flush();
}
// Wait for all muxing operations to complete
await Promise.all(this.muxingPromises);
// Finalize muxer and get output blob
const blob = await this.muxer!.finalize();
return { success: true, blob };
} catch (error) {
console.error('Export error:', error);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
} finally {
this.cleanup();
}
}
private async initializeEncoder(): Promise<void> {
this.encodeQueue = 0;
this.muxingPromises = [];
this.chunkCount = 0;
let videoDescription: Uint8Array | undefined;
this.encoder = new VideoEncoder({
output: (chunk, meta) => {
// Capture decoder config metadata from encoder output
if (meta?.decoderConfig?.description && !videoDescription) {
const desc = meta.decoderConfig.description;
videoDescription = new Uint8Array(desc instanceof ArrayBuffer ? desc : (desc as any));
this.videoDescription = videoDescription;
}
// Capture colorSpace from encoder metadata if provided
if (meta?.decoderConfig?.colorSpace && !this.videoColorSpace) {
this.videoColorSpace = meta.decoderConfig.colorSpace;
}
// Stream chunk to muxer immediately (parallel processing)
const isFirstChunk = this.chunkCount === 0;
this.chunkCount++;
const muxingPromise = (async () => {
try {
if (isFirstChunk && this.videoDescription) {
// Add decoder config for the first chunk
const colorSpace = this.videoColorSpace || {
primaries: 'bt709',
transfer: 'iec61966-2-1',
matrix: 'rgb',
fullRange: true,
};
const metadata: EncodedVideoChunkMetadata = {
decoderConfig: {
codec: this.config.codec || 'avc1.64001f',
codedWidth: this.config.width,
codedHeight: this.config.height,
description: this.videoDescription,
colorSpace,
},
};
await this.muxer!.addVideoChunk(chunk, metadata);
} else {
await this.muxer!.addVideoChunk(chunk, meta);
}
} catch (error) {
console.error('Muxing error:', error);
}
})();
this.muxingPromises.push(muxingPromise);
this.encodeQueue--;
},
error: (error) => {
console.error('VideoEncoder error:', error);
},
});
const codec = this.config.codec || 'avc1.64001f';
const encoderConfig: VideoEncoderConfig = {
codec,
width: this.config.width,
height: this.config.height,
bitrate: this.config.bitrate,
framerate: this.config.frameRate,
latencyMode: 'realtime',
bitrateMode: 'variable',
hardwareAcceleration: 'prefer-hardware',
};
try {
console.log('[VideoExporter] Configuring encoder with hardware acceleration...', {
codec,
resolution: `${this.config.width}x${this.config.height}`,
bitrate: this.config.bitrate,
framerate: this.config.frameRate,
});
this.encoder.configure(encoderConfig as VideoEncoderConfig);
console.log('[VideoExporter] Hardware encoder configured successfully');
} catch (error) {
console.warn('[VideoExporter] Hardware encoding failed, falling back to software encoding...', error);
// Fallback to software encoding if hardware fails
encoderConfig.hardwareAcceleration = 'prefer-software';
try {
this.encoder.configure(encoderConfig as VideoEncoderConfig);
console.log('[VideoExporter] Software encoder configured successfully');
} catch (softwareError) {
console.error('[VideoExporter] Software encoding also failed:', softwareError);
throw new Error(`Failed to initialize video encoder: ${softwareError instanceof Error ? softwareError.message : String(softwareError)}`);
}
}
}
cancel(): void {
this.cancelled = true;
this.cleanup();
}
private cleanup(): void {
if (this.encoder) {
try {
if (this.encoder.state === 'configured') {
this.encoder.close();
}
} catch (e) {
console.warn('Error closing encoder:', e);
}
this.encoder = null;
}
if (this.decoder) {
try {
this.decoder.destroy();
} catch (e) {
console.warn('Error destroying decoder:', e);
}
this.decoder = null;
}
if (this.renderer) {
try {
this.renderer.destroy();
} catch (e) {
console.warn('Error destroying renderer:', e);
}
this.renderer = null;
}
this.muxer = null;
this.encodeQueue = 0;
this.muxingPromises = [];
this.chunkCount = 0;
this.videoDescription = undefined;
this.videoColorSpace = undefined;
}
}