export working
This commit is contained in:
@@ -0,0 +1,503 @@
|
||||
import * as PIXI from 'pixi.js';
|
||||
import type { ZoomRegion, CropRegion } from '@/components/video-editor/types';
|
||||
import { ZOOM_DEPTH_SCALES } from '@/components/video-editor/types';
|
||||
import { findDominantRegion } from '@/components/video-editor/videoPlayback/zoomRegionUtils';
|
||||
import { applyZoomTransform } from '@/components/video-editor/videoPlayback/zoomTransform';
|
||||
import { DEFAULT_FOCUS, SMOOTHING_FACTOR, MIN_DELTA, VIEWPORT_SCALE } from '@/components/video-editor/videoPlayback/constants';
|
||||
import { clampFocusToStage as clampFocusToStageUtil } from '@/components/video-editor/videoPlayback/focusUtils';
|
||||
|
||||
interface FrameRenderConfig {
|
||||
width: number;
|
||||
height: number;
|
||||
wallpaper: string;
|
||||
zoomRegions: ZoomRegion[];
|
||||
showShadow: boolean;
|
||||
showBlur: boolean;
|
||||
cropRegion: CropRegion;
|
||||
videoWidth: number;
|
||||
videoHeight: number;
|
||||
}
|
||||
|
||||
interface AnimationState {
|
||||
scale: number;
|
||||
focusX: number;
|
||||
focusY: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders video frames with all effects (background, zoom, crop, blur, shadow)
|
||||
* to an offscreen canvas for export.
|
||||
*/
|
||||
export class FrameRenderer {
|
||||
private app: PIXI.Application | null = null;
|
||||
private cameraContainer: PIXI.Container | null = null;
|
||||
private videoContainer: PIXI.Container | null = null;
|
||||
private videoSprite: PIXI.Sprite | null = null;
|
||||
private backgroundSprite: PIXI.Sprite | null = null;
|
||||
private maskGraphics: PIXI.Graphics | null = null;
|
||||
private blurFilter: PIXI.BlurFilter | null = null;
|
||||
private shadowCanvas: HTMLCanvasElement | null = null;
|
||||
private shadowCtx: CanvasRenderingContext2D | null = null;
|
||||
private compositeCanvas: HTMLCanvasElement | null = null;
|
||||
private compositeCtx: CanvasRenderingContext2D | null = null;
|
||||
private config: FrameRenderConfig;
|
||||
private animationState: AnimationState;
|
||||
private layoutCache: any = null;
|
||||
private currentVideoTime = 0;
|
||||
|
||||
constructor(config: FrameRenderConfig) {
|
||||
this.config = config;
|
||||
this.animationState = {
|
||||
scale: 1,
|
||||
focusX: DEFAULT_FOCUS.cx,
|
||||
focusY: DEFAULT_FOCUS.cy,
|
||||
};
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
// Create offscreen canvas
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = this.config.width;
|
||||
canvas.height = this.config.height;
|
||||
|
||||
// Initialize PixiJS app with transparent background (background rendered separately)
|
||||
// Use 2x resolution to match Retina displays and ensure blur quality matches preview
|
||||
this.app = new PIXI.Application();
|
||||
await this.app.init({
|
||||
canvas,
|
||||
width: this.config.width,
|
||||
height: this.config.height,
|
||||
backgroundAlpha: 0,
|
||||
antialias: true,
|
||||
resolution: 2, // Match typical Retina/high-DPI displays for blur quality
|
||||
autoDensity: true,
|
||||
});
|
||||
|
||||
// Setup containers
|
||||
this.cameraContainer = new PIXI.Container();
|
||||
this.videoContainer = new PIXI.Container();
|
||||
this.app.stage.addChild(this.cameraContainer);
|
||||
this.cameraContainer.addChild(this.videoContainer);
|
||||
|
||||
// Setup background (render separately, not in PixiJS)
|
||||
await this.setupBackground();
|
||||
|
||||
// Setup blur filter for video container
|
||||
this.blurFilter = new PIXI.BlurFilter();
|
||||
this.blurFilter.quality = 3;
|
||||
this.blurFilter.resolution = this.app.renderer.resolution;
|
||||
this.blurFilter.blur = 0;
|
||||
this.videoContainer.filters = [this.blurFilter];
|
||||
|
||||
// Setup composite canvas for final output with shadows
|
||||
this.compositeCanvas = document.createElement('canvas');
|
||||
this.compositeCanvas.width = this.config.width;
|
||||
this.compositeCanvas.height = this.config.height;
|
||||
this.compositeCtx = this.compositeCanvas.getContext('2d', { willReadFrequently: false });
|
||||
|
||||
// Setup shadow canvas if needed
|
||||
if (this.config.showShadow) {
|
||||
this.shadowCanvas = document.createElement('canvas');
|
||||
this.shadowCanvas.width = this.config.width;
|
||||
this.shadowCanvas.height = this.config.height;
|
||||
this.shadowCtx = this.shadowCanvas.getContext('2d', { willReadFrequently: false });
|
||||
}
|
||||
|
||||
// Setup mask
|
||||
this.maskGraphics = new PIXI.Graphics();
|
||||
this.videoContainer.addChild(this.maskGraphics);
|
||||
this.videoContainer.mask = this.maskGraphics;
|
||||
}
|
||||
|
||||
private async setupBackground(): Promise<void> {
|
||||
const wallpaper = this.config.wallpaper;
|
||||
|
||||
// Create background canvas for separate rendering (not affected by zoom)
|
||||
const bgCanvas = document.createElement('canvas');
|
||||
bgCanvas.width = this.config.width;
|
||||
bgCanvas.height = this.config.height;
|
||||
const bgCtx = bgCanvas.getContext('2d')!;
|
||||
|
||||
try {
|
||||
// Render background based on type
|
||||
if (wallpaper.startsWith('/') || wallpaper.startsWith('http')) {
|
||||
// Image background
|
||||
const img = new Image();
|
||||
// Don't set crossOrigin for same-origin images to avoid CORS taint
|
||||
// Only set it for cross-origin URLs
|
||||
const imageUrl = wallpaper.startsWith('http') ? wallpaper : window.location.origin + wallpaper;
|
||||
if (wallpaper.startsWith('http') && !imageUrl.startsWith(window.location.origin)) {
|
||||
img.crossOrigin = 'anonymous';
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = (err) => {
|
||||
console.error('[FrameRenderer] Failed to load background image:', imageUrl, err);
|
||||
reject(new Error(`Failed to load background image: ${imageUrl}`));
|
||||
};
|
||||
img.src = imageUrl;
|
||||
});
|
||||
|
||||
// Draw the image using cover and center positioning (like CSS bg-cover bg-center)
|
||||
const imgAspect = img.width / img.height;
|
||||
const canvasAspect = this.config.width / this.config.height;
|
||||
|
||||
let drawWidth, drawHeight, drawX, drawY;
|
||||
|
||||
if (imgAspect > canvasAspect) {
|
||||
// Image is wider - fit to height and crop width
|
||||
drawHeight = this.config.height;
|
||||
drawWidth = drawHeight * imgAspect;
|
||||
drawX = (this.config.width - drawWidth) / 2;
|
||||
drawY = 0;
|
||||
} else {
|
||||
// Image is taller - fit to width and crop height
|
||||
drawWidth = this.config.width;
|
||||
drawHeight = drawWidth / imgAspect;
|
||||
drawX = 0;
|
||||
drawY = (this.config.height - drawHeight) / 2;
|
||||
}
|
||||
|
||||
bgCtx.drawImage(img, drawX, drawY, drawWidth, drawHeight);
|
||||
} else if (wallpaper.startsWith('#')) {
|
||||
// Solid color
|
||||
bgCtx.fillStyle = wallpaper;
|
||||
bgCtx.fillRect(0, 0, this.config.width, this.config.height);
|
||||
} else if (wallpaper.startsWith('linear-gradient') || wallpaper.startsWith('radial-gradient')) {
|
||||
// Gradient - parse and create CanvasGradient"}
|
||||
|
||||
// Simple gradient parser for common cases
|
||||
const gradientMatch = wallpaper.match(/(linear|radial)-gradient\((.+)\)/);
|
||||
if (gradientMatch) {
|
||||
const [, type, params] = gradientMatch;
|
||||
const parts = params.split(',').map(s => s.trim());
|
||||
|
||||
let gradient: CanvasGradient;
|
||||
|
||||
if (type === 'linear') {
|
||||
// Default to top-to-bottom if no direction specified
|
||||
gradient = bgCtx.createLinearGradient(0, 0, 0, this.config.height);
|
||||
|
||||
// Parse color stops
|
||||
parts.forEach((part, index) => {
|
||||
// Skip direction keywords
|
||||
if (part.startsWith('to ') || part.includes('deg')) return;
|
||||
|
||||
// Extract color (everything before optional percentage/position)
|
||||
const colorMatch = part.match(/^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|[a-z]+)/);
|
||||
if (colorMatch) {
|
||||
const color = colorMatch[1];
|
||||
const position = index / (parts.length - 1);
|
||||
gradient.addColorStop(position, color);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Radial gradient - center circle
|
||||
const cx = this.config.width / 2;
|
||||
const cy = this.config.height / 2;
|
||||
const radius = Math.max(this.config.width, this.config.height) / 2;
|
||||
gradient = bgCtx.createRadialGradient(cx, cy, 0, cx, cy, radius);
|
||||
|
||||
parts.forEach((part, index) => {
|
||||
const colorMatch = part.match(/^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|[a-z]+)/);
|
||||
if (colorMatch) {
|
||||
const color = colorMatch[1];
|
||||
const position = index / (parts.length - 1);
|
||||
gradient.addColorStop(position, color);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bgCtx.fillStyle = gradient;
|
||||
bgCtx.fillRect(0, 0, this.config.width, this.config.height);
|
||||
} else {
|
||||
console.warn('[FrameRenderer] Could not parse gradient, using black fallback');
|
||||
bgCtx.fillStyle = '#000000';
|
||||
bgCtx.fillRect(0, 0, this.config.width, this.config.height);
|
||||
}
|
||||
} else {
|
||||
// Unknown format, try to use as fillStyle (might be a named color like 'red', 'blue', etc.)
|
||||
bgCtx.fillStyle = wallpaper;
|
||||
bgCtx.fillRect(0, 0, this.config.width, this.config.height);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[FrameRenderer] Error setting up background, using fallback:', error);
|
||||
// Fallback to black background
|
||||
bgCtx.fillStyle = '#000000';
|
||||
bgCtx.fillRect(0, 0, this.config.width, this.config.height);
|
||||
}
|
||||
|
||||
// Store the background canvas for compositing
|
||||
this.backgroundSprite = bgCanvas as any; // Reuse the field to store canvas"}
|
||||
}
|
||||
|
||||
async renderFrame(videoFrame: VideoFrame, timestamp: number): Promise<void> {
|
||||
if (!this.app || !this.videoContainer || !this.cameraContainer) {
|
||||
throw new Error('Renderer not initialized');
|
||||
}
|
||||
|
||||
this.currentVideoTime = timestamp / 1000000; // convert microseconds to seconds
|
||||
|
||||
// Create or update video sprite from VideoFrame
|
||||
if (!this.videoSprite) {
|
||||
const texture = PIXI.Texture.from(videoFrame as any);
|
||||
this.videoSprite = new PIXI.Sprite(texture);
|
||||
this.videoContainer.addChild(this.videoSprite);
|
||||
} else {
|
||||
// Update texture with new frame
|
||||
const texture = PIXI.Texture.from(videoFrame as any);
|
||||
this.videoSprite.texture = texture;
|
||||
}
|
||||
|
||||
// Apply layout
|
||||
this.updateLayout();
|
||||
|
||||
// Apply zoom effects normalized to 60fps (1 tick per video frame)
|
||||
// This ensures consistent animation speed regardless of display refresh rate
|
||||
const timeMs = this.currentVideoTime * 1000;
|
||||
const TICKS_PER_FRAME = 1; // 60fps standard - 1 animation update per video frame
|
||||
|
||||
let maxMotionIntensity = 0;
|
||||
for (let i = 0; i < TICKS_PER_FRAME; i++) {
|
||||
const motionIntensity = this.updateAnimationState(timeMs);
|
||||
maxMotionIntensity = Math.max(maxMotionIntensity, motionIntensity);
|
||||
}
|
||||
|
||||
// Apply transform once with maximum motion intensity from all ticks
|
||||
applyZoomTransform({
|
||||
cameraContainer: this.cameraContainer,
|
||||
blurFilter: this.blurFilter,
|
||||
stageSize: this.layoutCache.stageSize,
|
||||
baseMask: this.layoutCache.maskRect,
|
||||
zoomScale: this.animationState.scale,
|
||||
focusX: this.animationState.focusX,
|
||||
focusY: this.animationState.focusY,
|
||||
motionIntensity: maxMotionIntensity,
|
||||
isPlaying: true, // Enable motion blur
|
||||
});
|
||||
|
||||
// Render the PixiJS stage to its canvas (video only, transparent background)
|
||||
this.app.renderer.render(this.app.stage);
|
||||
|
||||
// Composite with shadows to final output canvas
|
||||
this.compositeWithShadows();
|
||||
}
|
||||
|
||||
private updateLayout(): void {
|
||||
if (!this.app || !this.videoSprite || !this.maskGraphics || !this.videoContainer) return;
|
||||
|
||||
const { width, height } = this.config;
|
||||
const { cropRegion } = this.config;
|
||||
const videoWidth = this.config.videoWidth;
|
||||
const videoHeight = this.config.videoHeight;
|
||||
|
||||
// Calculate cropped video dimensions
|
||||
const cropStartX = cropRegion.x;
|
||||
const cropStartY = cropRegion.y;
|
||||
const cropEndX = cropRegion.x + cropRegion.width;
|
||||
const cropEndY = cropRegion.y + cropRegion.height;
|
||||
|
||||
const croppedVideoWidth = videoWidth * (cropEndX - cropStartX);
|
||||
const croppedVideoHeight = videoHeight * (cropEndY - cropStartY);
|
||||
|
||||
// Calculate scale to fit in viewport
|
||||
const viewportWidth = width * VIEWPORT_SCALE;
|
||||
const viewportHeight = height * VIEWPORT_SCALE;
|
||||
const scale = Math.min(viewportWidth / croppedVideoWidth, viewportHeight / croppedVideoHeight);
|
||||
|
||||
// Position video sprite
|
||||
this.videoSprite.width = videoWidth * scale;
|
||||
this.videoSprite.height = videoHeight * scale;
|
||||
|
||||
const cropPixelX = cropStartX * videoWidth * scale;
|
||||
const cropPixelY = cropStartY * videoHeight * scale;
|
||||
this.videoSprite.x = -cropPixelX;
|
||||
this.videoSprite.y = -cropPixelY;
|
||||
|
||||
// Position video container
|
||||
const croppedDisplayWidth = croppedVideoWidth * scale;
|
||||
const croppedDisplayHeight = croppedVideoHeight * scale;
|
||||
const centerOffsetX = (width - croppedDisplayWidth) / 2;
|
||||
const centerOffsetY = (height - croppedDisplayHeight) / 2;
|
||||
this.videoContainer.x = centerOffsetX;
|
||||
this.videoContainer.y = centerOffsetY;
|
||||
|
||||
// Update mask
|
||||
const radius = Math.min(croppedDisplayWidth, croppedDisplayHeight) * 0.02;
|
||||
this.maskGraphics.clear();
|
||||
this.maskGraphics.roundRect(0, 0, croppedDisplayWidth, croppedDisplayHeight, radius);
|
||||
this.maskGraphics.fill({ color: 0xffffff });
|
||||
|
||||
// Cache layout info
|
||||
this.layoutCache = {
|
||||
stageSize: { width, height },
|
||||
videoSize: { width: croppedVideoWidth, height: croppedVideoHeight },
|
||||
baseScale: scale,
|
||||
baseOffset: { x: centerOffsetX, y: centerOffsetY },
|
||||
maskRect: { x: 0, y: 0, width: croppedDisplayWidth, height: croppedDisplayHeight },
|
||||
};
|
||||
}
|
||||
|
||||
private clampFocusToStage(focus: { cx: number; cy: number }, depth: number): { cx: number; cy: number } {
|
||||
if (!this.layoutCache) return focus;
|
||||
return clampFocusToStageUtil(focus, depth as any, this.layoutCache);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates animation state for one tick and returns motion intensity.
|
||||
* This simulates one PixiJS ticker update.
|
||||
*/
|
||||
private updateAnimationState(timeMs: number): number {
|
||||
if (!this.cameraContainer || !this.layoutCache) return 0;
|
||||
|
||||
const { region, strength } = findDominantRegion(this.config.zoomRegions, timeMs);
|
||||
|
||||
const defaultFocus = DEFAULT_FOCUS;
|
||||
let targetScaleFactor = 1;
|
||||
let targetFocus = { ...defaultFocus };
|
||||
|
||||
// Match the preview logic exactly
|
||||
if (region && strength > 0) {
|
||||
const zoomScale = ZOOM_DEPTH_SCALES[region.depth];
|
||||
const regionFocus = this.clampFocusToStage(region.focus, region.depth);
|
||||
|
||||
// Interpolate scale and focus based on region strength (exponential easing)
|
||||
targetScaleFactor = 1 + (zoomScale - 1) * strength;
|
||||
targetFocus = {
|
||||
cx: defaultFocus.cx + (regionFocus.cx - defaultFocus.cx) * strength,
|
||||
cy: defaultFocus.cy + (regionFocus.cy - defaultFocus.cy) * strength,
|
||||
};
|
||||
}
|
||||
|
||||
const state = this.animationState;
|
||||
|
||||
const prevScale = state.scale;
|
||||
const prevFocusX = state.focusX;
|
||||
const prevFocusY = state.focusY;
|
||||
|
||||
const scaleDelta = targetScaleFactor - state.scale;
|
||||
const focusXDelta = targetFocus.cx - state.focusX;
|
||||
const focusYDelta = targetFocus.cy - state.focusY;
|
||||
|
||||
let nextScale = prevScale;
|
||||
let nextFocusX = prevFocusX;
|
||||
let nextFocusY = prevFocusY;
|
||||
|
||||
// Apply smooth exponential easing
|
||||
if (Math.abs(scaleDelta) > MIN_DELTA) {
|
||||
nextScale = prevScale + scaleDelta * SMOOTHING_FACTOR;
|
||||
} else {
|
||||
nextScale = targetScaleFactor;
|
||||
}
|
||||
|
||||
if (Math.abs(focusXDelta) > MIN_DELTA) {
|
||||
nextFocusX = prevFocusX + focusXDelta * SMOOTHING_FACTOR;
|
||||
} else {
|
||||
nextFocusX = targetFocus.cx;
|
||||
}
|
||||
|
||||
if (Math.abs(focusYDelta) > MIN_DELTA) {
|
||||
nextFocusY = prevFocusY + focusYDelta * SMOOTHING_FACTOR;
|
||||
} else {
|
||||
nextFocusY = targetFocus.cy;
|
||||
}
|
||||
|
||||
state.scale = nextScale;
|
||||
state.focusX = nextFocusX;
|
||||
state.focusY = nextFocusY;
|
||||
|
||||
// Calculate and return motion intensity for blur
|
||||
return Math.max(
|
||||
Math.abs(nextScale - prevScale),
|
||||
Math.abs(nextFocusX - prevFocusX),
|
||||
Math.abs(nextFocusY - prevFocusY)
|
||||
);
|
||||
}
|
||||
|
||||
private compositeWithShadows(): void {
|
||||
if (!this.compositeCanvas || !this.compositeCtx || !this.app) return;
|
||||
|
||||
const videoCanvas = this.app.canvas as HTMLCanvasElement;
|
||||
const ctx = this.compositeCtx;
|
||||
const w = this.compositeCanvas.width;
|
||||
const h = this.compositeCanvas.height;
|
||||
|
||||
// Clear composite canvas
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// Step 1: Draw background layer (with optional blur, not affected by zoom)
|
||||
if (this.backgroundSprite) {
|
||||
const bgCanvas = this.backgroundSprite as any as HTMLCanvasElement;
|
||||
|
||||
if (this.config.showBlur) {
|
||||
// Apply CSS blur(2px) to background
|
||||
ctx.save();
|
||||
ctx.filter = 'blur(2px)';
|
||||
ctx.drawImage(bgCanvas, 0, 0, w, h);
|
||||
ctx.restore();
|
||||
} else {
|
||||
ctx.drawImage(bgCanvas, 0, 0, w, h);
|
||||
}
|
||||
} else {
|
||||
console.warn('[FrameRenderer] No background sprite found during compositing!');
|
||||
}
|
||||
|
||||
// Step 2: Draw video layer with shadows on top of background
|
||||
if (this.config.showShadow && this.shadowCanvas && this.shadowCtx) {
|
||||
// CSS drop-shadow creates layered shadows. We need to composite them properly.
|
||||
// The key is to draw all shadows UNDER the video content, not draw video multiple times
|
||||
const shadowCtx = this.shadowCtx;
|
||||
shadowCtx.clearRect(0, 0, w, h);
|
||||
|
||||
// Apply all three shadow layers in a single draw call using composite filter
|
||||
// This matches CSS drop-shadow behavior exactly - note: no 'px' on X offset in CSS syntax
|
||||
shadowCtx.save();
|
||||
shadowCtx.filter = 'drop-shadow(0 12px 48px rgba(0,0,0,0.7)) drop-shadow(0 4px 16px rgba(0,0,0,0.5)) drop-shadow(0 2px 8px rgba(0,0,0,0.3))';
|
||||
shadowCtx.drawImage(videoCanvas, 0, 0, w, h);
|
||||
shadowCtx.restore();
|
||||
|
||||
// Draw shadow canvas (which has shadows + video) on top of background
|
||||
ctx.drawImage(this.shadowCanvas, 0, 0, w, h);
|
||||
} else {
|
||||
// No shadows, just draw video directly on top of background
|
||||
ctx.drawImage(videoCanvas, 0, 0, w, h);
|
||||
}
|
||||
}
|
||||
|
||||
getCanvas(): HTMLCanvasElement {
|
||||
if (!this.compositeCanvas) {
|
||||
throw new Error('Renderer not initialized');
|
||||
}
|
||||
// Return the composite canvas which includes shadows
|
||||
return this.compositeCanvas;
|
||||
}
|
||||
|
||||
updateConfig(config: Partial<FrameRenderConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
if (config.wallpaper) {
|
||||
this.setupBackground();
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.videoSprite) {
|
||||
this.videoSprite.destroy();
|
||||
this.videoSprite = null;
|
||||
}
|
||||
// backgroundSprite is now a canvas, just null it
|
||||
this.backgroundSprite = null;
|
||||
if (this.app) {
|
||||
this.app.destroy(true, { children: true, texture: true, textureSource: true });
|
||||
this.app = null;
|
||||
}
|
||||
this.cameraContainer = null;
|
||||
this.videoContainer = null;
|
||||
this.maskGraphics = null;
|
||||
this.blurFilter = null;
|
||||
this.shadowCanvas = null;
|
||||
this.shadowCtx = null;
|
||||
this.compositeCanvas = null;
|
||||
this.compositeCtx = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { VideoExporter } from './videoExporter';
|
||||
export { VideoFileDecoder } from './videoDecoder';
|
||||
export { FrameRenderer } from './frameRenderer';
|
||||
export { VideoMuxer } from './muxer';
|
||||
export type { ExportConfig, ExportProgress, ExportResult, VideoFrameData } from './types';
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { ExportConfig } from './types';
|
||||
|
||||
interface MP4MuxerOptions {
|
||||
target: any; // ArrayBufferTarget instance
|
||||
video: {
|
||||
codec: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
audio?: {
|
||||
codec: string;
|
||||
numberOfChannels: number;
|
||||
sampleRate: number;
|
||||
};
|
||||
fastStart?: 'in-memory' | 'fragmented';
|
||||
}
|
||||
|
||||
interface Muxer {
|
||||
addVideoChunk(chunk: EncodedVideoChunk, meta?: EncodedVideoChunkMetadata): void;
|
||||
addAudioChunk(chunk: EncodedAudioChunk, meta?: EncodedAudioChunkMetadata): void;
|
||||
finalize(): void;
|
||||
target?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Video muxer that combines encoded video and audio tracks into a final MP4 file.
|
||||
* Uses mp4-muxer library for efficient muxing without re-encoding.
|
||||
*/
|
||||
export class VideoMuxer {
|
||||
private muxer: Muxer | null = null;
|
||||
private config: ExportConfig;
|
||||
private hasAudio: boolean;
|
||||
|
||||
constructor(config: ExportConfig, hasAudio = false) {
|
||||
this.config = config;
|
||||
this.hasAudio = hasAudio;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
// Dynamically import mp4-muxer
|
||||
const MP4MuxerModule = await import('mp4-muxer');
|
||||
const MP4MuxerClass = (MP4MuxerModule as any).Muxer || MP4MuxerModule.default;
|
||||
const ArrayBufferTarget = (MP4MuxerModule as any).ArrayBufferTarget;
|
||||
|
||||
const target = new ArrayBufferTarget();
|
||||
|
||||
const options: MP4MuxerOptions = {
|
||||
target,
|
||||
video: {
|
||||
codec: 'avc', // mp4-muxer only accepts 'avc', not full codec string
|
||||
width: this.config.width,
|
||||
height: this.config.height,
|
||||
},
|
||||
fastStart: 'in-memory',
|
||||
};
|
||||
|
||||
if (this.hasAudio) {
|
||||
options.audio = {
|
||||
codec: 'opus',
|
||||
numberOfChannels: 2,
|
||||
sampleRate: 48000,
|
||||
};
|
||||
}
|
||||
|
||||
this.muxer = new MP4MuxerClass(options) as Muxer;
|
||||
}
|
||||
|
||||
addVideoChunk(chunk: EncodedVideoChunk, meta?: EncodedVideoChunkMetadata): void {
|
||||
if (!this.muxer) {
|
||||
throw new Error('Muxer not initialized');
|
||||
}
|
||||
this.muxer.addVideoChunk(chunk, meta);
|
||||
}
|
||||
|
||||
addAudioChunk(chunk: EncodedAudioChunk, meta?: EncodedAudioChunkMetadata): void {
|
||||
if (!this.muxer) {
|
||||
throw new Error('Muxer not initialized');
|
||||
}
|
||||
if (!this.hasAudio) {
|
||||
throw new Error('Audio not configured for this muxer');
|
||||
}
|
||||
this.muxer.addAudioChunk(chunk, meta);
|
||||
}
|
||||
|
||||
finalize(): Blob {
|
||||
if (!this.muxer) {
|
||||
throw new Error('Muxer not initialized');
|
||||
}
|
||||
|
||||
this.muxer.finalize();
|
||||
const buffer = this.muxer.target.buffer;
|
||||
return new Blob([buffer], { type: 'video/mp4' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export interface ExportConfig {
|
||||
width: number;
|
||||
height: number;
|
||||
frameRate: number;
|
||||
bitrate: number;
|
||||
codec?: string;
|
||||
}
|
||||
|
||||
export interface ExportProgress {
|
||||
currentFrame: number;
|
||||
totalFrames: number;
|
||||
percentage: number;
|
||||
estimatedTimeRemaining: number; // in seconds
|
||||
}
|
||||
|
||||
export interface ExportResult {
|
||||
success: boolean;
|
||||
blob?: Blob;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface VideoFrameData {
|
||||
frame: VideoFrame;
|
||||
timestamp: number; // in microseconds
|
||||
duration: number; // in microseconds
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
export interface DecodedVideoInfo {
|
||||
width: number;
|
||||
height: number;
|
||||
duration: number; // in seconds
|
||||
frameRate: number;
|
||||
codec: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple video decoder for WebM files using native VideoDecoder API.
|
||||
* For export, we'll use a different approach - directly rendering from the HTML video element.
|
||||
*/
|
||||
export class VideoFileDecoder {
|
||||
private decoder: VideoDecoder | null = null;
|
||||
private info: DecodedVideoInfo | null = null;
|
||||
private videoElement: HTMLVideoElement | null = null;
|
||||
|
||||
async loadVideo(videoUrl: string): Promise<DecodedVideoInfo> {
|
||||
// Create a video element to get video info
|
||||
this.videoElement = document.createElement('video');
|
||||
this.videoElement.src = videoUrl;
|
||||
this.videoElement.preload = 'metadata';
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.videoElement!.addEventListener('loadedmetadata', () => {
|
||||
const video = this.videoElement!;
|
||||
|
||||
this.info = {
|
||||
width: video.videoWidth,
|
||||
height: video.videoHeight,
|
||||
duration: video.duration,
|
||||
frameRate: 60, // 60fps for smooth playback
|
||||
codec: 'avc1.640033', // H.264 High Profile Level 5.1
|
||||
};
|
||||
|
||||
resolve(this.info);
|
||||
});
|
||||
|
||||
this.videoElement!.addEventListener('error', (e) => {
|
||||
reject(new Error(`Failed to load video: ${e}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get video element for seeking
|
||||
*/
|
||||
getVideoElement(): HTMLVideoElement | null {
|
||||
return this.videoElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek to a specific time and wait for the frame to be ready
|
||||
*/
|
||||
async seekToTime(timeInSeconds: number): Promise<void> {
|
||||
if (!this.videoElement) {
|
||||
throw new Error('Video not loaded');
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const video = this.videoElement!;
|
||||
|
||||
const onSeeked = () => {
|
||||
video.removeEventListener('seeked', onSeeked);
|
||||
resolve();
|
||||
};
|
||||
|
||||
video.addEventListener('seeked', onSeeked);
|
||||
video.currentTime = timeInSeconds;
|
||||
});
|
||||
}
|
||||
|
||||
getInfo(): DecodedVideoInfo | null {
|
||||
return this.info;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.videoElement) {
|
||||
this.videoElement.pause();
|
||||
this.videoElement.src = '';
|
||||
this.videoElement = null;
|
||||
}
|
||||
|
||||
if (this.decoder) {
|
||||
if (this.decoder.state !== 'closed') {
|
||||
this.decoder.close();
|
||||
}
|
||||
this.decoder = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast video exporter using VideoFrame and VideoEncoder APIs.
|
||||
* Avoids reading pixel data into JavaScript memory for maximum performance.
|
||||
*
|
||||
* Based on: https://pietrasiak.com/fast-video-rendering-and-encoding-using-web-apis
|
||||
*/
|
||||
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 encodedChunks: EncodedVideoChunk[] = [];
|
||||
private encodeQueue = 0;
|
||||
private readonly MAX_ENCODE_QUEUE = 30;
|
||||
private videoDescription: Uint8Array | undefined;
|
||||
|
||||
constructor(config: VideoExporterConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
async export(): Promise<ExportResult> {
|
||||
try {
|
||||
// Clean up any previous export state
|
||||
this.cleanup();
|
||||
this.cancelled = false;
|
||||
|
||||
// Step 1: Initialize decoder and load video
|
||||
this.decoder = new VideoFileDecoder();
|
||||
const videoInfo = await this.decoder.loadVideo(this.config.videoUrl);
|
||||
|
||||
// Step 2: 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();
|
||||
|
||||
// Step 3: Initialize video encoder
|
||||
const totalFrames = Math.ceil(videoInfo.duration * this.config.frameRate);
|
||||
await this.initializeEncoder();
|
||||
|
||||
// Step 4: Initialize muxer
|
||||
this.muxer = new VideoMuxer(this.config, false);
|
||||
await this.muxer.initialize();
|
||||
|
||||
// Step 5: Get the video element for frame extraction
|
||||
const videoElement = this.decoder.getVideoElement();
|
||||
if (!videoElement) {
|
||||
throw new Error('Video element not available');
|
||||
}
|
||||
|
||||
// Step 6: Process frames
|
||||
const frameDuration = 1_000_000 / this.config.frameRate; // in microseconds
|
||||
let frameIndex = 0;
|
||||
const startTime = performance.now();
|
||||
const timeStep = 1 / this.config.frameRate;
|
||||
|
||||
while (frameIndex < totalFrames && !this.cancelled) {
|
||||
const timestamp = frameIndex * frameDuration;
|
||||
const videoTime = frameIndex * timeStep;
|
||||
|
||||
// Seek to the frame time
|
||||
await this.decoder.seekToTime(videoTime);
|
||||
|
||||
// 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);
|
||||
|
||||
// Close the video frame as we're done with it
|
||||
videoFrame.close();
|
||||
|
||||
// Wait if encode queue is too large
|
||||
while (this.encodeQueue >= this.MAX_ENCODE_QUEUE && !this.cancelled) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
if (this.cancelled) break;
|
||||
|
||||
// Create VideoFrame from rendered canvas (on GPU, no pixel read!)
|
||||
const canvas = this.renderer.getCanvas();
|
||||
const exportFrame = new VideoFrame(canvas, {
|
||||
timestamp,
|
||||
duration: frameDuration,
|
||||
});
|
||||
|
||||
// Encode the frame (check if encoder is still valid)
|
||||
if (this.encoder && this.encoder.state === 'configured') {
|
||||
this.encodeQueue++;
|
||||
this.encoder.encode(exportFrame, { keyFrame: frameIndex % 150 === 0 });
|
||||
}
|
||||
exportFrame.close();
|
||||
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (this.cancelled) {
|
||||
return { success: false, error: 'Export cancelled' };
|
||||
}
|
||||
|
||||
// Step 7: Finalize encoding
|
||||
if (this.encoder && this.encoder.state === 'configured') {
|
||||
await this.encoder.flush();
|
||||
}
|
||||
|
||||
// Step 8: Add all chunks to muxer with metadata
|
||||
for (let i = 0; i < this.encodedChunks.length; i++) {
|
||||
const chunk = this.encodedChunks[i];
|
||||
const meta: EncodedVideoChunkMetadata = {};
|
||||
|
||||
// Add decoder config for the first chunk
|
||||
if (i === 0 && this.videoDescription) {
|
||||
meta.decoderConfig = {
|
||||
codec: this.config.codec || 'avc1.640033',
|
||||
codedWidth: this.config.width,
|
||||
codedHeight: this.config.height,
|
||||
description: this.videoDescription,
|
||||
};
|
||||
}
|
||||
|
||||
this.muxer!.addVideoChunk(chunk, meta);
|
||||
}
|
||||
|
||||
// Step 9: Finalize muxer and get output blob
|
||||
const blob = 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.encodedChunks = [];
|
||||
this.encodeQueue = 0;
|
||||
let videoDescription: Uint8Array | undefined;
|
||||
|
||||
this.encoder = new VideoEncoder({
|
||||
output: (chunk, meta) => {
|
||||
// Store the first chunk's metadata (contains codec description)
|
||||
if (meta?.decoderConfig?.description && !videoDescription) {
|
||||
const desc = meta.decoderConfig.description;
|
||||
videoDescription = new Uint8Array(desc instanceof ArrayBuffer ? desc : (desc as any));
|
||||
this.videoDescription = videoDescription;
|
||||
}
|
||||
this.encodedChunks.push(chunk);
|
||||
this.encodeQueue--;
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('VideoEncoder error:', error);
|
||||
},
|
||||
});
|
||||
|
||||
// Configure encoder for H.264 (AVC) with level 5.1 for high resolution support
|
||||
const codec = this.config.codec || 'avc1.640033';
|
||||
|
||||
this.encoder.configure({
|
||||
codec,
|
||||
width: this.config.width,
|
||||
height: this.config.height,
|
||||
bitrate: this.config.bitrate,
|
||||
framerate: this.config.frameRate,
|
||||
latencyMode: 'quality',
|
||||
bitrateMode: 'variable',
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.cancelled = true;
|
||||
// Immediately cleanup to stop encoding
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
// Close encoder safely
|
||||
if (this.encoder) {
|
||||
try {
|
||||
if (this.encoder.state === 'configured') {
|
||||
this.encoder.close();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Error closing encoder:', e);
|
||||
}
|
||||
this.encoder = null;
|
||||
}
|
||||
|
||||
// Destroy decoder
|
||||
if (this.decoder) {
|
||||
try {
|
||||
this.decoder.destroy();
|
||||
} catch (e) {
|
||||
console.warn('Error destroying decoder:', e);
|
||||
}
|
||||
this.decoder = null;
|
||||
}
|
||||
|
||||
// Destroy renderer
|
||||
if (this.renderer) {
|
||||
try {
|
||||
this.renderer.destroy();
|
||||
} catch (e) {
|
||||
console.warn('Error destroying renderer:', e);
|
||||
}
|
||||
this.renderer = null;
|
||||
}
|
||||
|
||||
this.muxer = null;
|
||||
this.encodedChunks = [];
|
||||
this.encodeQueue = 0;
|
||||
this.videoDescription = undefined;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user