diff --git a/package-lock.json b/package-lock.json index 3d3aca6..e190f7a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 34e2432..e537600 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/wasm/web-demuxer.wasm b/public/wasm/web-demuxer.wasm new file mode 100755 index 0000000..41d4f73 Binary files /dev/null and b/public/wasm/web-demuxer.wasm differ diff --git a/src/lib/exporter/gifExporter.ts b/src/lib/exporter/gifExporter.ts index 0d466e4..bf7a5f4 100644 --- a/src/lib/exporter/gifExporter.ts +++ b/src/lib/exporter/gifExporter.ts @@ -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 { 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(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(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) { diff --git a/src/lib/exporter/index.ts b/src/lib/exporter/index.ts index 03499b1..fde1984 100644 --- a/src/lib/exporter/index.ts +++ b/src/lib/exporter/index.ts @@ -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'; diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts new file mode 100644 index 0000000..66788f0 --- /dev/null +++ b/src/lib/exporter/streamingDecoder.ts @@ -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; + +/** + * 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 { + 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 { + 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 => { + 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 { + 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; + } + } +} diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index 4d07414..937c4a3 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -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 { 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(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(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) {