faster exports
This commit is contained in:
@@ -12,14 +12,6 @@ interface ExportDialogProps {
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!isFinite(seconds) || seconds < 0) return '0:00';
|
||||
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function ExportDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -49,7 +41,7 @@ export function ExportDialog({
|
||||
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 animate-in fade-in duration-200"
|
||||
onClick={isExporting ? undefined : onClose}
|
||||
/>
|
||||
<div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50 bg-[#23232a] rounded-2xl shadow-2xl border border-[#34B27B] p-8 w-[90vw] max-w-md animate-in zoom-in-95 duration-200">
|
||||
<div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50 bg-[#23232a] rounded-2xl shadow-2xl border border-[#3a3a42] p-8 w-[90vw] max-w-md animate-in zoom-in-95 duration-200">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
{showSuccess ? (
|
||||
@@ -107,27 +99,18 @@ export function ExportDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="text-slate-400 mb-1">Frame</div>
|
||||
<div className="text-slate-200 font-mono">
|
||||
{progress.currentFrame} / {progress.totalFrames}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-slate-400 mb-1">Time Remaining</div>
|
||||
<div className="text-slate-200 font-mono">
|
||||
{formatTime(progress.estimatedTimeRemaining)}
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<div className="text-slate-400 mb-1">Frame</div>
|
||||
<div className="text-slate-200 font-mono text-lg">
|
||||
{progress.currentFrame} / {progress.totalFrames}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{onCancel && (
|
||||
<div className="pt-4 border-t border-[#34B27B]/20">
|
||||
<div className="pt-4 border-t border-[#3a3a42]">
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
variant="outline"
|
||||
className="w-full bg-transparent border-slate-600 text-slate-300 hover:bg-slate-800"
|
||||
className="w-full py-3 bg-[#34B27B] text-white hover:bg-[#34B27B]/80 transition-all"
|
||||
>
|
||||
Cancel Export
|
||||
</Button>
|
||||
|
||||
@@ -249,6 +249,10 @@ export default function VideoEditor() {
|
||||
if (exporterRef.current) {
|
||||
exporterRef.current.cancel();
|
||||
toast.info('Export cancelled');
|
||||
setShowExportDialog(false);
|
||||
setIsExporting(false);
|
||||
setExportProgress(null);
|
||||
setExportError(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -55,10 +55,14 @@ export class FrameRenderer {
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
// Create offscreen canvas
|
||||
// Create offscreen canvas with sRGB color space for fidelity
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = this.config.width;
|
||||
canvas.height = this.config.height;
|
||||
if ('colorSpace' in canvas) {
|
||||
// @ts-ignore
|
||||
canvas.colorSpace = 'srgb';
|
||||
}
|
||||
|
||||
// Initialize PixiJS app with transparent background (background rendered separately)
|
||||
// Use 2x resolution to match Retina displays and ensure blur quality matches preview
|
||||
|
||||
@@ -29,7 +29,7 @@ export class VideoExporter {
|
||||
private cancelled = false;
|
||||
private encodedChunks: EncodedVideoChunk[] = [];
|
||||
private encodeQueue = 0;
|
||||
private readonly MAX_ENCODE_QUEUE = 30;
|
||||
private readonly MAX_ENCODE_QUEUE = 60; // Increased for better throughput
|
||||
private videoDescription: Uint8Array | undefined;
|
||||
|
||||
constructor(config: VideoExporterConfig) {
|
||||
@@ -74,18 +74,40 @@ export class VideoExporter {
|
||||
throw new Error('Video element not available');
|
||||
}
|
||||
|
||||
// Step 6: Process frames
|
||||
// Step 6: Process frames with optimized seeking
|
||||
const frameDuration = 1_000_000 / this.config.frameRate; // in microseconds
|
||||
let frameIndex = 0;
|
||||
const startTime = performance.now();
|
||||
const timeStep = 1 / this.config.frameRate;
|
||||
|
||||
// Optimize: Pre-load first frame
|
||||
videoElement.currentTime = 0;
|
||||
await new Promise(resolve => {
|
||||
const onSeeked = () => {
|
||||
videoElement.removeEventListener('seeked', onSeeked);
|
||||
resolve(null);
|
||||
};
|
||||
videoElement.addEventListener('seeked', onSeeked);
|
||||
});
|
||||
|
||||
while (frameIndex < totalFrames && !this.cancelled) {
|
||||
const timestamp = frameIndex * frameDuration;
|
||||
const videoTime = frameIndex * timeStep;
|
||||
|
||||
// Seek to the frame time
|
||||
await this.decoder.seekToTime(videoTime);
|
||||
// Seek to frame (optimized: only seek if not already there)
|
||||
if (Math.abs(videoElement.currentTime - videoTime) > 0.001) {
|
||||
videoElement.currentTime = videoTime;
|
||||
// Wait for seek with timeout to prevent hanging
|
||||
await Promise.race([
|
||||
new Promise(resolve => {
|
||||
const onSeeked = () => {
|
||||
videoElement.removeEventListener('seeked', onSeeked);
|
||||
// Wait for video to render the frame
|
||||
videoElement.requestVideoFrameCallback(() => resolve(null));
|
||||
};
|
||||
videoElement.addEventListener('seeked', onSeeked, { once: true });
|
||||
}),
|
||||
new Promise(resolve => setTimeout(resolve, 100)) // 100ms timeout
|
||||
]);
|
||||
}
|
||||
|
||||
// Create a VideoFrame from the video element (on GPU!)
|
||||
const videoFrame = new VideoFrame(videoElement, {
|
||||
@@ -93,20 +115,20 @@ export class VideoExporter {
|
||||
});
|
||||
|
||||
// Render the frame with all effects
|
||||
await this.renderer.renderFrame(videoFrame, timestamp);
|
||||
await this.renderer!.renderFrame(videoFrame, timestamp);
|
||||
|
||||
// Close the video frame as we're done with it
|
||||
videoFrame.close();
|
||||
|
||||
// Wait if encode queue is too large
|
||||
// Wait if encode queue is too large (backpressure)
|
||||
while (this.encodeQueue >= this.MAX_ENCODE_QUEUE && !this.cancelled) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await new Promise(resolve => setTimeout(resolve, 1));
|
||||
}
|
||||
|
||||
if (this.cancelled) break;
|
||||
|
||||
// Create VideoFrame from rendered canvas (on GPU, no pixel read!)
|
||||
const canvas = this.renderer.getCanvas();
|
||||
const canvas = this.renderer!.getCanvas();
|
||||
const exportFrame = new VideoFrame(canvas, {
|
||||
timestamp,
|
||||
duration: frameDuration,
|
||||
@@ -121,17 +143,13 @@ export class VideoExporter {
|
||||
|
||||
// Report progress
|
||||
frameIndex++;
|
||||
const elapsed = (performance.now() - startTime) / 1000;
|
||||
const framesPerSecond = frameIndex / elapsed;
|
||||
const remainingFrames = totalFrames - frameIndex;
|
||||
const estimatedTimeRemaining = remainingFrames / framesPerSecond;
|
||||
|
||||
if (this.config.onProgress) {
|
||||
this.config.onProgress({
|
||||
currentFrame: frameIndex,
|
||||
totalFrames,
|
||||
percentage: (frameIndex / totalFrames) * 100,
|
||||
estimatedTimeRemaining,
|
||||
estimatedTimeRemaining: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -208,9 +226,10 @@ export class VideoExporter {
|
||||
height: this.config.height,
|
||||
bitrate: this.config.bitrate,
|
||||
framerate: this.config.frameRate,
|
||||
latencyMode: 'quality',
|
||||
latencyMode: 'realtime', // Changed from 'quality' for faster encoding
|
||||
bitrateMode: 'variable',
|
||||
});
|
||||
hardwareAcceleration: 'prefer-hardware', // Use GPU encoding
|
||||
} as VideoEncoderConfig);
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
|
||||
Reference in New Issue
Block a user