Files
openscreen/src/lib/exporter/frameRenderer.ts
T
2026-02-06 21:28:42 -08:00

534 lines
19 KiB
TypeScript

import { Application, Container, Sprite, Graphics, BlurFilter, Texture } from 'pixi.js';
import type { ZoomRegion, CropRegion, AnnotationRegion } 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 } from '@/components/video-editor/videoPlayback/constants';
import { clampFocusToStage as clampFocusToStageUtil } from '@/components/video-editor/videoPlayback/focusUtils';
import { renderAnnotations } from './annotationRenderer';
interface FrameRenderConfig {
width: number;
height: number;
wallpaper: string;
zoomRegions: ZoomRegion[];
showShadow: boolean;
shadowIntensity: number;
showBlur: boolean;
motionBlurEnabled?: boolean;
borderRadius?: number;
padding?: number;
cropRegion: CropRegion;
videoWidth: number;
videoHeight: number;
annotationRegions?: AnnotationRegion[];
previewWidth?: number;
previewHeight?: 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: Application | null = null;
private cameraContainer: Container | null = null;
private videoContainer: Container | null = null;
private videoSprite: Sprite | null = null;
private backgroundSprite: Sprite | null = null;
private maskGraphics: Graphics | null = null;
private blurFilter: 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 canvas for rendering
const canvas = document.createElement('canvas');
canvas.width = this.config.width;
canvas.height = this.config.height;
// Try to set colorSpace if supported (may not be available on all platforms)
try {
if (canvas && 'colorSpace' in canvas) {
// @ts-ignore
canvas.colorSpace = 'srgb';
}
} catch (error) {
// Silently ignore colorSpace errors on platforms that don't support it
console.warn('[FrameRenderer] colorSpace not supported on this platform:', error);
}
// Initialize PixiJS with optimized settings for export performance
this.app = new Application();
await this.app.init({
canvas,
width: this.config.width,
height: this.config.height,
backgroundAlpha: 0,
antialias: true,
resolution: 1,
autoDensity: true,
});
// Setup containers
this.cameraContainer = new Container();
this.videoContainer = new 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 BlurFilter();
this.blurFilter.quality = 5;
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 });
if (!this.compositeCtx) {
throw new Error('Failed to get 2D context for composite canvas');
}
// 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 });
if (!this.shadowCtx) {
throw new Error('Failed to get 2D context for shadow canvas');
}
}
// Setup mask
this.maskGraphics = new 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('file://') || wallpaper.startsWith('data:') || 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
let imageUrl: string;
if (wallpaper.startsWith('http')) {
imageUrl = wallpaper;
if (!imageUrl.startsWith(window.location.origin)) {
img.crossOrigin = 'anonymous';
}
} else if (wallpaper.startsWith('file://') || wallpaper.startsWith('data:')) {
imageUrl = wallpaper;
} else {
imageUrl = window.location.origin + wallpaper;
}
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
const imgAspect = img.width / img.height;
const canvasAspect = this.config.width / this.config.height;
let drawWidth, drawHeight, drawX, drawY;
if (imgAspect > canvasAspect) {
drawHeight = this.config.height;
drawWidth = drawHeight * imgAspect;
drawX = (this.config.width - drawWidth) / 2;
drawY = 0;
} else {
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('#')) {
bgCtx.fillStyle = wallpaper;
bgCtx.fillRect(0, 0, this.config.width, this.config.height);
} else if (wallpaper.startsWith('linear-gradient') || wallpaper.startsWith('radial-gradient')) {
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') {
gradient = bgCtx.createLinearGradient(0, 0, 0, this.config.height);
parts.forEach((part, index) => {
if (part.startsWith('to ') || part.includes('deg')) return;
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 {
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 {
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);
bgCtx.fillStyle = '#000000';
bgCtx.fillRect(0, 0, this.config.width, this.config.height);
}
// Store the background canvas for compositing
this.backgroundSprite = bgCanvas as any;
}
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;
// Create or update video sprite from VideoFrame
if (!this.videoSprite) {
const texture = Texture.from(videoFrame as any);
this.videoSprite = new Sprite(texture);
this.videoContainer.addChild(this.videoSprite);
} else {
// Destroy old texture to avoid memory leaks, then create new one
const oldTexture = this.videoSprite.texture;
const newTexture = Texture.from(videoFrame as any);
this.videoSprite.texture = newTexture;
oldTexture.destroy(true);
}
// Apply layout
this.updateLayout();
const timeMs = this.currentVideoTime * 1000;
const TICKS_PER_FRAME = 1;
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,
motionBlurEnabled: this.config.motionBlurEnabled ?? false,
});
// 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();
// Render annotations on top if present
if (this.config.annotationRegions && this.config.annotationRegions.length > 0 && this.compositeCtx) {
// Calculate scale factor based on export vs preview dimensions
const previewWidth = this.config.previewWidth || 1920;
const previewHeight = this.config.previewHeight || 1080;
const scaleX = this.config.width / previewWidth;
const scaleY = this.config.height / previewHeight;
const scaleFactor = (scaleX + scaleY) / 2;
await renderAnnotations(
this.compositeCtx,
this.config.annotationRegions,
this.config.width,
this.config.height,
timeMs,
scaleFactor
);
}
}
private updateLayout(): void {
if (!this.app || !this.videoSprite || !this.maskGraphics || !this.videoContainer) return;
const { width, height } = this.config;
const { cropRegion, borderRadius = 0, padding = 0 } = 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
// Padding is a percentage (0-100), where 50% ~ 0.8 scale
const paddingScale = 1.0 - (padding / 100) * 0.4;
const viewportWidth = width * paddingScale;
const viewportHeight = height * paddingScale;
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;
// scale border radius by export/preview canvas ratio
const previewWidth = this.config.previewWidth || 1920;
const previewHeight = this.config.previewHeight || 1080;
const canvasScaleFactor = Math.min(width / previewWidth, height / previewHeight);
const scaledBorderRadius = borderRadius * canvasScaleFactor;
this.maskGraphics.clear();
this.maskGraphics.roundRect(0, 0, croppedDisplayWidth, croppedDisplayHeight, scaledBorderRadius);
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);
}
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 };
if (region && strength > 0) {
const zoomScale = ZOOM_DEPTH_SCALES[region.depth];
const regionFocus = this.clampFocusToStage(region.focus, region.depth);
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;
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;
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) {
ctx.save();
ctx.filter = 'blur(6px)'; // Canvas blur is weaker than CSS
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!');
}
// Draw video layer with shadows on top of background
if (this.config.showShadow && this.config.shadowIntensity > 0 && this.shadowCanvas && this.shadowCtx) {
const shadowCtx = this.shadowCtx;
shadowCtx.clearRect(0, 0, w, h);
shadowCtx.save();
// Calculate shadow parameters based on intensity (0-1)
const intensity = this.config.shadowIntensity;
const baseBlur1 = 48 * intensity;
const baseBlur2 = 16 * intensity;
const baseBlur3 = 8 * intensity;
const baseAlpha1 = 0.7 * intensity;
const baseAlpha2 = 0.5 * intensity;
const baseAlpha3 = 0.3 * intensity;
const baseOffset = 12 * intensity;
shadowCtx.filter = `drop-shadow(0 ${baseOffset}px ${baseBlur1}px rgba(0,0,0,${baseAlpha1})) drop-shadow(0 ${baseOffset/3}px ${baseBlur2}px rgba(0,0,0,${baseAlpha2})) drop-shadow(0 ${baseOffset/6}px ${baseBlur3}px rgba(0,0,0,${baseAlpha3}))`;
shadowCtx.drawImage(videoCanvas, 0, 0, w, h);
shadowCtx.restore();
ctx.drawImage(this.shadowCanvas, 0, 0, w, h);
} else {
ctx.drawImage(videoCanvas, 0, 0, w, h);
}
}
getCanvas(): HTMLCanvasElement {
if (!this.compositeCanvas) {
throw new Error('Renderer not initialized');
}
return this.compositeCanvas;
}
destroy(): void {
if (this.videoSprite) {
this.videoSprite.destroy();
this.videoSprite = null;
}
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;
}
}