demuxer and CFR conversion

This commit is contained in:
Siddharth
2026-02-13 20:46:12 -08:00
parent d9177b4a44
commit fac4af40c7
7 changed files with 436 additions and 237 deletions
+13 -3
View File
@@ -1,12 +1,12 @@
{
"name": "openscreen",
"version": "1.0.2",
"version": "1.1.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openscreen",
"version": "1.0.2",
"version": "1.1.3",
"dependencies": {
"@fix-webm-duration/fix": "^1.0.1",
"@pixi/filter-drop-shadow": "^5.2.0",
@@ -44,7 +44,8 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"uuid": "^13.0.0"
"uuid": "^13.0.0",
"web-demuxer": "^4.0.0"
},
"devDependencies": {
"@types/node": "^25.0.3",
@@ -13765,6 +13766,15 @@
"defaults": "^1.0.3"
}
},
"node_modules/web-demuxer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/web-demuxer/-/web-demuxer-4.0.0.tgz",
"integrity": "sha512-QFsKe8SNjP6MDtAw2lWfyVmX2wXIpDUT+9p2KHXJb5OPWdhVbjBHcV06tDMXzuU1T6Y1P9TRm9bkeVXEwy0dVw==",
"engines": {
"node": ">=18",
"pnpm": ">=8"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+2 -1
View File
@@ -51,7 +51,8 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"uuid": "^13.0.0"
"uuid": "^13.0.0",
"web-demuxer": "^4.0.0"
},
"devDependencies": {
"@types/node": "^25.0.3",
BIN
View File
Binary file not shown.
+46 -106
View File
@@ -1,6 +1,6 @@
import GIF from 'gif.js';
import type { ExportProgress, ExportResult, GifFrameRate, GifSizePreset, GIF_SIZE_PRESETS } from './types';
import { VideoFileDecoder } from './videoDecoder';
import { StreamingVideoDecoder } from './streamingDecoder';
import { FrameRenderer } from './frameRenderer';
import type { ZoomRegion, CropRegion, TrimRegion, AnnotationRegion } from '@/components/video-editor/types';
@@ -66,7 +66,7 @@ export function calculateOutputDimensions(
export class GifExporter {
private config: GifExporterConfig;
private decoder: VideoFileDecoder | null = null;
private streamingDecoder: StreamingVideoDecoder | null = null;
private renderer: FrameRenderer | null = null;
private gif: GIF | null = null;
private cancelled = false;
@@ -75,49 +75,14 @@ export class GifExporter {
this.config = config;
}
/**
* Calculate the total duration excluding trim regions (in seconds)
*/
private getEffectiveDuration(totalDuration: number): number {
const trimRegions = this.config.trimRegions || [];
const totalTrimDuration = trimRegions.reduce((sum, region) => {
return sum + (region.endMs - region.startMs) / 1000;
}, 0);
return totalDuration - totalTrimDuration;
}
/**
* Map effective time (excluding trims) to source time (including trims)
*/
private mapEffectiveToSourceTime(effectiveTimeMs: number): number {
const trimRegions = this.config.trimRegions || [];
// Sort trim regions by start time
const sortedTrims = [...trimRegions].sort((a, b) => a.startMs - b.startMs);
let sourceTimeMs = effectiveTimeMs;
for (const trim of sortedTrims) {
// If the source time hasn't reached this trim region yet, we're done
if (sourceTimeMs < trim.startMs) {
break;
}
// Add the duration of this trim region to the source time
const trimDuration = trim.endMs - trim.startMs;
sourceTimeMs += trimDuration;
}
return sourceTimeMs;
}
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 streaming decoder and load video metadata
this.streamingDecoder = new StreamingVideoDecoder();
const videoInfo = await this.streamingDecoder.loadMetadata(this.config.videoUrl);
// Initialize frame renderer
this.renderer = new FrameRenderer({
@@ -143,7 +108,7 @@ export class GifExporter {
// Initialize GIF encoder
// Loop: 0 = infinite loop, 1 = play once (no loop)
const repeat = this.config.loop ? 0 : 1;
this.gif = new GIF({
workers: 4,
quality: 10,
@@ -156,16 +121,10 @@ export class GifExporter {
dither: 'FloydSteinberg',
});
// Get the video element for frame extraction
const videoElement = this.decoder.getVideoElement();
if (!videoElement) {
throw new Error('Video element not available');
}
// Calculate effective duration and frame count (excluding trim regions)
const effectiveDuration = this.getEffectiveDuration(videoInfo.duration);
const effectiveDuration = this.streamingDecoder.getEffectiveDuration(this.config.trimRegions);
const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate);
// Calculate frame delay in milliseconds (gif.js uses ms)
const frameDelay = Math.round(1000 / this.config.frameRate);
@@ -175,66 +134,44 @@ export class GifExporter {
console.log('[GifExporter] Frame rate:', this.config.frameRate, 'FPS');
console.log('[GifExporter] Frame delay:', frameDelay, 'ms');
console.log('[GifExporter] Loop:', this.config.loop ? 'infinite' : 'once');
console.log('[GifExporter] Using streaming decode (web-demuxer + VideoDecoder)');
// Process frames
const timeStep = 1 / this.config.frameRate;
let frameIndex = 0;
while (frameIndex < totalFrames && !this.cancelled) {
const i = frameIndex;
const timestamp = i * (1_000_000 / this.config.frameRate); // in microseconds
// Stream decode and process frames — no seeking!
await this.streamingDecoder.decodeAll(
this.config.frameRate,
this.config.trimRegions,
async (videoFrame, _exportTimestampUs, sourceTimestampMs) => {
if (this.cancelled) {
videoFrame.close();
return;
}
// Map effective time to source time (accounting for trim regions)
const effectiveTimeMs = (i * timeStep) * 1000;
const sourceTimeMs = this.mapEffectiveToSourceTime(effectiveTimeMs);
const videoTime = sourceTimeMs / 1000;
// Render the frame with all effects using source timestamp
const sourceTimestampUs = sourceTimestampMs * 1000; // Convert to microseconds
await this.renderer!.renderFrame(videoFrame, sourceTimestampUs);
videoFrame.close();
// Seek if needed
const needsSeek = Math.abs(videoElement.currentTime - videoTime) > 0.001;
// Get the rendered canvas and add to GIF
const canvas = this.renderer!.getCanvas();
if (needsSeek) {
const seekedPromise = new Promise<void>(resolve => {
videoElement.addEventListener('seeked', () => resolve(), { once: true });
});
videoElement.currentTime = videoTime;
await seekedPromise;
} else if (i === 0) {
// Only for the very first frame, wait for it to be ready
await new Promise<void>(resolve => {
videoElement.requestVideoFrameCallback(() => resolve());
});
// Add frame to GIF encoder with delay
this.gif!.addFrame(canvas, { delay: frameDelay, copy: true });
frameIndex++;
// Update progress
if (this.config.onProgress) {
this.config.onProgress({
currentFrame: frameIndex,
totalFrames,
percentage: (frameIndex / totalFrames) * 100,
estimatedTimeRemaining: 0,
});
}
}
// Create a VideoFrame from the video element
const videoFrame = new VideoFrame(videoElement, {
timestamp,
});
// Render the frame with all effects using source timestamp
const sourceTimestamp = sourceTimeMs * 1000; // Convert to microseconds
await this.renderer!.renderFrame(videoFrame, sourceTimestamp);
videoFrame.close();
// Get the rendered canvas and add to GIF
const canvas = this.renderer!.getCanvas();
// Add frame to GIF encoder with delay
this.gif!.addFrame(canvas, { delay: frameDelay, copy: true });
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' };
@@ -289,6 +226,9 @@ export class GifExporter {
cancel(): void {
this.cancelled = true;
if (this.streamingDecoder) {
this.streamingDecoder.cancel();
}
if (this.gif) {
this.gif.abort();
}
@@ -296,13 +236,13 @@ export class GifExporter {
}
private cleanup(): void {
if (this.decoder) {
if (this.streamingDecoder) {
try {
this.decoder.destroy();
this.streamingDecoder.destroy();
} catch (e) {
console.warn('Error destroying decoder:', e);
console.warn('Error destroying streaming decoder:', e);
}
this.decoder = null;
this.streamingDecoder = null;
}
if (this.renderer) {
+1
View File
@@ -1,5 +1,6 @@
export { VideoExporter } from './videoExporter';
export { VideoFileDecoder } from './videoDecoder';
export { StreamingVideoDecoder } from './streamingDecoder';
export { FrameRenderer } from './frameRenderer';
export { VideoMuxer } from './muxer';
export { GifExporter, calculateOutputDimensions } from './gifExporter';
+301
View File
@@ -0,0 +1,301 @@
import { WebDemuxer } from 'web-demuxer';
import type { TrimRegion } from '@/components/video-editor/types';
export interface DecodedVideoInfo {
width: number;
height: number;
duration: number; // seconds
frameRate: number;
codec: string;
}
/** Caller must close the VideoFrame after use. */
type OnFrameCallback = (
frame: VideoFrame,
exportTimestampUs: number,
sourceTimestampMs: number
) => Promise<void>;
/**
* Decodes video frames via web-demuxer + VideoDecoder in a single forward pass.
* Way faster than seeking an HTMLVideoElement per frame.
*
* Frames in trimmed regions are decoded (needed for P/B-frame state) but discarded.
* Non-trimmed frames get buffered per segment and resampled to the target frame rate.
*/
export class StreamingVideoDecoder {
private demuxer: WebDemuxer | null = null;
private decoder: VideoDecoder | null = null;
private cancelled = false;
private metadata: DecodedVideoInfo | null = null;
async loadMetadata(videoUrl: string): Promise<DecodedVideoInfo> {
const response = await fetch(videoUrl);
const blob = await response.blob();
const filename = videoUrl.split('/').pop() || 'video';
const file = new File([blob], filename, { type: blob.type });
// Absolute URL required — web-demuxer loads WASM in a Worker
const wasmUrl = new URL('/wasm/web-demuxer.wasm', window.location.href).href;
this.demuxer = new WebDemuxer({ wasmFilePath: wasmUrl });
await this.demuxer.load(file);
const mediaInfo = await this.demuxer.getMediaInfo();
const videoStream = mediaInfo.streams.find(s => s.codec_type_string === 'video');
let frameRate = 60;
if (videoStream?.avg_frame_rate) {
const parts = videoStream.avg_frame_rate.split('/');
if (parts.length === 2) {
const num = parseInt(parts[0], 10);
const den = parseInt(parts[1], 10);
if (den > 0 && num > 0) frameRate = num / den;
}
}
this.metadata = {
width: videoStream?.width || 1920,
height: videoStream?.height || 1080,
duration: mediaInfo.duration,
frameRate,
codec: videoStream?.codec_string || 'unknown',
};
return this.metadata;
}
async decodeAll(
targetFrameRate: number,
trimRegions: TrimRegion[] | undefined,
onFrame: OnFrameCallback
): Promise<void> {
if (!this.demuxer || !this.metadata) {
throw new Error('Must call loadMetadata() before decodeAll()');
}
const decoderConfig = await this.demuxer.getDecoderConfig('video');
const segments = this.computeSegments(this.metadata.duration, trimRegions);
const frameDurationUs = 1_000_000 / targetFrameRate;
// Async frame queue — decoder pushes, consumer pulls
const pendingFrames: VideoFrame[] = [];
let frameResolve: ((frame: VideoFrame | null) => void) | null = null;
let decodeError: Error | null = null;
let decodeDone = false;
this.decoder = new VideoDecoder({
output: (frame: VideoFrame) => {
if (frameResolve) {
const resolve = frameResolve;
frameResolve = null;
resolve(frame);
} else {
pendingFrames.push(frame);
}
},
error: (e: DOMException) => {
decodeError = new Error(`VideoDecoder error: ${e.message}`);
if (frameResolve) {
const resolve = frameResolve;
frameResolve = null;
resolve(null);
}
},
});
this.decoder.configure(decoderConfig);
const getNextFrame = (): Promise<VideoFrame | null> => {
if (decodeError) throw decodeError;
if (pendingFrames.length > 0) return Promise.resolve(pendingFrames.shift()!);
if (decodeDone) return Promise.resolve(null);
return new Promise(resolve => { frameResolve = resolve; });
};
// One forward stream through the whole file
const reader = this.demuxer.read('video').getReader();
// Feed chunks to decoder in background with backpressure
const feedPromise = (async () => {
try {
while (!this.cancelled) {
const { done, value: chunk } = await reader.read();
if (done || !chunk) break;
while (this.decoder!.decodeQueueSize > 10 && !this.cancelled) {
await new Promise(resolve => setTimeout(resolve, 1));
}
if (this.cancelled) break;
this.decoder!.decode(chunk);
}
if (!this.cancelled && this.decoder!.state === 'configured') {
await this.decoder!.flush();
}
} catch (e) {
decodeError = e instanceof Error ? e : new Error(String(e));
} finally {
decodeDone = true;
if (frameResolve) {
const resolve = frameResolve;
frameResolve = null;
resolve(null);
}
}
})();
// Route decoded frames into segments by timestamp, then deliver with VFR→CFR resampling
let segmentIdx = 0;
let exportFrameIndex = 0;
let segmentBuffer: VideoFrame[] = [];
while (!this.cancelled && segmentIdx < segments.length) {
const frame = await getNextFrame();
if (!frame) break;
const frameTimeSec = frame.timestamp / 1_000_000;
const currentSegment = segments[segmentIdx];
// Before current segment — trimmed or pre-video
if (frameTimeSec < currentSegment.startSec - 0.001) {
frame.close();
continue;
}
// Past current segment — flush buffer and advance
if (frameTimeSec >= currentSegment.endSec - 0.001) {
exportFrameIndex = await this.deliverSegment(
segmentBuffer, currentSegment, targetFrameRate, frameDurationUs, exportFrameIndex, onFrame
);
for (const f of segmentBuffer) f.close();
segmentBuffer = [];
segmentIdx++;
while (segmentIdx < segments.length && frameTimeSec >= segments[segmentIdx].endSec - 0.001) {
segmentIdx++;
}
if (segmentIdx < segments.length && frameTimeSec >= segments[segmentIdx].startSec - 0.001) {
segmentBuffer.push(frame);
} else {
frame.close();
}
continue;
}
segmentBuffer.push(frame);
}
// Flush last segment
if (segmentBuffer.length > 0 && segmentIdx < segments.length) {
exportFrameIndex = await this.deliverSegment(
segmentBuffer, segments[segmentIdx], targetFrameRate, frameDurationUs, exportFrameIndex, onFrame
);
for (const f of segmentBuffer) f.close();
}
// Drain leftover decoded frames
while (!decodeDone) {
const frame = await getNextFrame();
if (!frame) break;
frame.close();
}
try { reader.cancel(); } catch { /* already closed */ }
await feedPromise;
for (const f of pendingFrames) f.close();
pendingFrames.length = 0;
if (this.decoder?.state === 'configured') {
this.decoder.close();
}
this.decoder = null;
}
/**
* Resample buffered frames to fill the target frame count for this segment.
* Handles VFR sources by duplicating/decimating as needed.
*/
private async deliverSegment(
frames: VideoFrame[],
segment: { startSec: number; endSec: number },
targetFrameRate: number,
frameDurationUs: number,
startExportFrameIndex: number,
onFrame: OnFrameCallback
): Promise<number> {
if (frames.length === 0) return startExportFrameIndex;
const segmentFrameCount = Math.ceil((segment.endSec - segment.startSec) * targetFrameRate);
let exportFrameIndex = startExportFrameIndex;
for (let i = 0; i < segmentFrameCount && !this.cancelled; i++) {
const sourceIdx = Math.min(
Math.floor(i * frames.length / segmentFrameCount),
frames.length - 1
);
const sourceFrame = frames[sourceIdx];
const clone = new VideoFrame(sourceFrame, { timestamp: sourceFrame.timestamp });
await onFrame(clone, exportFrameIndex * frameDurationUs, sourceFrame.timestamp / 1000);
exportFrameIndex++;
}
return exportFrameIndex;
}
private computeSegments(
totalDuration: number,
trimRegions?: TrimRegion[]
): Array<{ startSec: number; endSec: number }> {
if (!trimRegions || trimRegions.length === 0) {
return [{ startSec: 0, endSec: totalDuration }];
}
const sorted = [...trimRegions].sort((a, b) => a.startMs - b.startMs);
const segments: Array<{ startSec: number; endSec: number }> = [];
let cursor = 0;
for (const trim of sorted) {
const trimStart = trim.startMs / 1000;
const trimEnd = trim.endMs / 1000;
if (cursor < trimStart) {
segments.push({ startSec: cursor, endSec: trimStart });
}
cursor = trimEnd;
}
if (cursor < totalDuration) {
segments.push({ startSec: cursor, endSec: totalDuration });
}
return segments;
}
getEffectiveDuration(trimRegions?: TrimRegion[]): number {
if (!this.metadata) throw new Error('Must call loadMetadata() first');
const trimmed = (trimRegions || []).reduce(
(sum, r) => sum + (r.endMs - r.startMs) / 1000, 0
);
return this.metadata.duration - trimmed;
}
cancel(): void {
this.cancelled = true;
}
destroy(): void {
this.cancelled = true;
if (this.decoder) {
try {
if (this.decoder.state === 'configured') this.decoder.close();
} catch { /* ignore */ }
this.decoder = null;
}
if (this.demuxer) {
try { this.demuxer.destroy(); } catch { }
this.demuxer = null;
}
}
}
+73 -127
View File
@@ -1,5 +1,5 @@
import type { ExportConfig, ExportProgress, ExportResult } from './types';
import { VideoFileDecoder } from './videoDecoder';
import { StreamingVideoDecoder } from './streamingDecoder';
import { FrameRenderer } from './frameRenderer';
import { VideoMuxer } from './muxer';
import type { ZoomRegion, CropRegion, TrimRegion, AnnotationRegion } from '@/components/video-editor/types';
@@ -25,7 +25,7 @@ interface VideoExporterConfig extends ExportConfig {
export class VideoExporter {
private config: VideoExporterConfig;
private decoder: VideoFileDecoder | null = null;
private streamingDecoder: StreamingVideoDecoder | null = null;
private renderer: FrameRenderer | null = null;
private encoder: VideoEncoder | null = null;
private muxer: VideoMuxer | null = null;
@@ -43,44 +43,14 @@ export class VideoExporter {
this.config = config;
}
// Calculate the total duration excluding trim regions (in seconds)
private getEffectiveDuration(totalDuration: number): number {
const trimRegions = this.config.trimRegions || [];
const totalTrimDuration = trimRegions.reduce((sum, region) => {
return sum + (region.endMs - region.startMs) / 1000;
}, 0);
return totalDuration - totalTrimDuration;
}
private mapEffectiveToSourceTime(effectiveTimeMs: number): number {
const trimRegions = this.config.trimRegions || [];
// Sort trim regions by start time
const sortedTrims = [...trimRegions].sort((a, b) => a.startMs - b.startMs);
let sourceTimeMs = effectiveTimeMs;
for (const trim of sortedTrims) {
// If the source time hasn't reached this trim region yet, we're done
if (sourceTimeMs < trim.startMs) {
break;
}
// Add the duration of this trim region to the source time
const trimDuration = trim.endMs - trim.startMs;
sourceTimeMs += trimDuration;
}
return sourceTimeMs;
}
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 streaming decoder and load video metadata
this.streamingDecoder = new StreamingVideoDecoder();
const videoInfo = await this.streamingDecoder.loadMetadata(this.config.videoUrl);
// Initialize frame renderer
this.renderer = new FrameRenderer({
@@ -110,104 +80,77 @@ export class VideoExporter {
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');
}
// Calculate effective duration and frame count (excluding trim regions)
const effectiveDuration = this.getEffectiveDuration(videoInfo.duration);
const effectiveDuration = this.streamingDecoder.getEffectiveDuration(this.config.trimRegions);
const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate);
console.log('[VideoExporter] Original duration:', videoInfo.duration, 's');
console.log('[VideoExporter] Effective duration:', effectiveDuration, 's');
console.log('[VideoExporter] Total frames to export:', totalFrames);
console.log('[VideoExporter] Using streaming decode (web-demuxer + VideoDecoder)');
// 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;
// Stream decode and process frames — no seeking!
await this.streamingDecoder.decodeAll(
this.config.frameRate,
this.config.trimRegions,
async (videoFrame, _exportTimestampUs, sourceTimestampMs) => {
if (this.cancelled) {
videoFrame.close();
return;
}
// Map effective time to source time (accounting for trim regions)
const effectiveTimeMs = (i * timeStep) * 1000;
const sourceTimeMs = this.mapEffectiveToSourceTime(effectiveTimeMs);
const videoTime = sourceTimeMs / 1000;
// Seek if needed or wait for first frame to be ready
const needsSeek = Math.abs(videoElement.currentTime - videoTime) > 0.001;
const timestamp = frameIndex * frameDuration;
if (needsSeek) {
// Attach listener BEFORE setting currentTime to avoid race condition
const seekedPromise = new Promise<void>(resolve => {
videoElement.addEventListener('seeked', () => resolve(), { once: true });
});
videoElement.currentTime = videoTime;
await seekedPromise;
} else if (i === 0) {
// Only for the very first frame, wait for it to be ready
await new Promise<void>(resolve => {
videoElement.requestVideoFrameCallback(() => resolve());
// Render the frame with all effects using source timestamp
const sourceTimestampUs = sourceTimestampMs * 1000; // Convert to microseconds
await this.renderer!.renderFrame(videoFrame, sourceTimestampUs);
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: frameIndex % 150 === 0 });
} else {
console.warn(`[Frame ${frameIndex}] Encoder not ready! State: ${this.encoder?.state}`);
}
exportFrame.close();
frameIndex++;
// Update progress
if (this.config.onProgress) {
this.config.onProgress({
currentFrame: frameIndex,
totalFrames,
percentage: (frameIndex / totalFrames) * 100,
estimatedTimeRemaining: 0,
});
}
}
// Create a VideoFrame from the video element (on GPU!)
const videoFrame = new VideoFrame(videoElement, {
timestamp,
});
// Render the frame with all effects using source timestamp
const sourceTimestamp = sourceTimeMs * 1000; // Convert to microseconds
await this.renderer!.renderFrame(videoFrame, sourceTimestamp);
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 });
} else {
console.warn(`[Frame ${i}] Encoder not ready! State: ${this.encoder?.state}`);
}
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' };
@@ -300,7 +243,7 @@ export class VideoExporter {
});
const codec = this.config.codec || 'avc1.640033';
const encoderConfig: VideoEncoderConfig = {
codec,
width: this.config.width,
@@ -323,18 +266,21 @@ export class VideoExporter {
// Fall back to software encoding
console.log('[VideoExporter] Hardware not supported, using software encoding');
encoderConfig.hardwareAcceleration = 'prefer-software';
const softwareSupport = await VideoEncoder.isConfigSupported(encoderConfig);
if (!softwareSupport.supported) {
throw new Error('Video encoding not supported on this system');
}
this.encoder.configure(encoderConfig);
}
}
cancel(): void {
this.cancelled = true;
if (this.streamingDecoder) {
this.streamingDecoder.cancel();
}
this.cleanup();
}
@@ -350,13 +296,13 @@ export class VideoExporter {
this.encoder = null;
}
if (this.decoder) {
if (this.streamingDecoder) {
try {
this.decoder.destroy();
this.streamingDecoder.destroy();
} catch (e) {
console.warn('Error destroying decoder:', e);
console.warn('Error destroying streaming decoder:', e);
}
this.decoder = null;
this.streamingDecoder = null;
}
if (this.renderer) {