demuxer and CFR conversion
This commit is contained in:
Generated
+13
-3
@@ -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
@@ -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",
|
||||
|
||||
Executable
BIN
Binary file not shown.
+46
-106
@@ -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,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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user