export working

This commit is contained in:
Siddharth
2025-11-16 16:02:21 -07:00
parent 75388e1218
commit 34e9efdb73
18 changed files with 1336 additions and 4 deletions
+20 -1
View File
@@ -119,7 +119,7 @@ function startMouseTracking() {
isHookStarted = true;
return { success: true, message: "Mouse tracking started", startTime: recordingStartTime };
} catch (error) {
console.error("Failed to start mouse tracking:", error);
console.error("Failed to start mouse tracking:", error);
isMouseTrackingActive = false;
return { success: false, message: "Failed to start hook", error };
}
@@ -329,6 +329,25 @@ function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, g
return { success: false, error: String(error) };
}
});
ipcMain.handle("save-exported-video", async (_, videoData, fileName) => {
try {
const downloadsPath = app.getPath("downloads");
const videoPath = path.join(downloadsPath, fileName);
await fs.writeFile(videoPath, Buffer.from(videoData));
return {
success: true,
path: videoPath,
message: "Video exported successfully"
};
} catch (error) {
console.error("Failed to save exported video:", error);
return {
success: false,
message: "Failed to save exported video",
error: String(error)
};
}
});
}
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings");
+3
View File
@@ -41,5 +41,8 @@ electron.contextBridge.exposeInMainWorld("electronAPI", {
},
openExternalUrl: (url) => {
return electron.ipcRenderer.invoke("open-external-url", url);
},
saveExportedVideo: (videoData, fileName) => {
return electron.ipcRenderer.invoke("save-exported-video", videoData, fileName);
}
});
+1
View File
@@ -37,6 +37,7 @@ interface Window {
setRecordingState: (recording: boolean) => Promise<void>
onStopRecordingFromTray: (callback: () => void) => () => void
openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>
saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ success: boolean; path?: string; message?: string }>
}
}
+22 -1
View File
@@ -1,4 +1,4 @@
import { ipcMain, desktopCapturer, BrowserWindow, shell } from 'electron'
import { ipcMain, desktopCapturer, BrowserWindow, shell, app } from 'electron'
import { startMouseTracking, stopMouseTracking, getTrackingData } from './mouseTracking'
import fs from 'node:fs/promises'
import path from 'node:path'
@@ -144,4 +144,25 @@ export function registerIpcHandlers(
return { success: false, error: String(error) }
}
})
ipcMain.handle('save-exported-video', async (_, videoData: ArrayBuffer, fileName: string) => {
try {
const downloadsPath = app.getPath('downloads')
const videoPath = path.join(downloadsPath, fileName)
await fs.writeFile(videoPath, Buffer.from(videoData))
return {
success: true,
path: videoPath,
message: 'Video exported successfully'
}
} catch (error) {
console.error('Failed to save exported video:', error)
return {
success: false,
message: 'Failed to save exported video',
error: String(error)
}
}
})
}
+4 -1
View File
@@ -41,5 +41,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
},
openExternalUrl: (url: string) => {
return ipcRenderer.invoke('open-external-url', url)
}
},
saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => {
return ipcRenderer.invoke('save-exported-video', videoData, fileName)
},
})
+34
View File
@@ -20,6 +20,8 @@
"clsx": "^2.1.1",
"dnd-timeline": "^2.2.0",
"lucide-react": "^0.545.0",
"mp4-muxer": "^5.2.2",
"mp4box": "^2.2.0",
"pixi.js": "^8.14.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@@ -2830,6 +2832,12 @@
"@types/ms": "*"
}
},
"node_modules/@types/dom-webcodecs": {
"version": "0.1.17",
"resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.17.tgz",
"integrity": "sha512-IwKW5uKL0Zrv5ccUJpjIlqf7ppk2v29l/ZLQxLlwHxljBfnDD9Gxm+hzMkGM0AOAL/21H0pp7cTUYLiiVUGchA==",
"license": "MIT"
},
"node_modules/@types/earcut": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz",
@@ -2945,6 +2953,12 @@
"license": "MIT",
"optional": true
},
"node_modules/@types/wicg-file-system-access": {
"version": "2020.9.8",
"resolved": "https://registry.npmjs.org/@types/wicg-file-system-access/-/wicg-file-system-access-2020.9.8.tgz",
"integrity": "sha512-ggMz8nOygG7d/stpH40WVaNvBwuyYLnrg5Mbyf6bmsj/8+gb6Ei4ZZ9/4PNpcPNTT8th9Q8sM8wYmWGjMWLX/A==",
"license": "MIT"
},
"node_modules/@types/yauzl": {
"version": "2.10.3",
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
@@ -7821,6 +7835,26 @@
"node": ">=10"
}
},
"node_modules/mp4-muxer": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/mp4-muxer/-/mp4-muxer-5.2.2.tgz",
"integrity": "sha512-dhozjTywI0h2qFzeShagt8YYw811fh1XlwiDCE2f6Aeqf6xG2CyuShoSa5E0AZDO8pPF0JOZ3wOmWBNWIGdSpQ==",
"deprecated": "This library is superseded by Mediabunny. Please migrate to it.",
"license": "MIT",
"dependencies": {
"@types/dom-webcodecs": "^0.1.6",
"@types/wicg-file-system-access": "^2020.9.5"
}
},
"node_modules/mp4box": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/mp4box/-/mp4box-2.2.0.tgz",
"integrity": "sha512-tE+L7wdhSuwBKZGjUzj03Qzj4lWyOw8pHSPyLnvHTKx92NJGkJls0pcEusUHWEh5gWVBlhdu79STJh4Bubz9mQ==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=20.8.1"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+2
View File
@@ -22,6 +22,8 @@
"clsx": "^2.1.1",
"dnd-timeline": "^2.2.0",
"lucide-react": "^0.545.0",
"mp4-muxer": "^5.2.2",
"mp4box": "^2.2.0",
"pixi.js": "^8.14.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@@ -0,0 +1,153 @@
import { useEffect, useState } from 'react';
import { X, Download, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { ExportProgress } from '@/lib/exporter';
interface ExportDialogProps {
isOpen: boolean;
onClose: () => void;
progress: ExportProgress | null;
isExporting: boolean;
error: string | null;
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,
progress,
isExporting,
error,
onCancel,
}: ExportDialogProps) {
const [showSuccess, setShowSuccess] = useState(false);
useEffect(() => {
if (!isExporting && progress && progress.percentage >= 100 && !error) {
setShowSuccess(true);
const timer = setTimeout(() => {
setShowSuccess(false);
onClose();
}, 2000);
return () => clearTimeout(timer);
}
}, [isExporting, progress, error, onClose]);
if (!isOpen) return null;
return (
<>
<div
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="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
{showSuccess ? (
<>
<div className="w-10 h-10 rounded-full bg-[#34B27B] flex items-center justify-center">
<Download className="w-6 h-6 text-white" />
</div>
<span className="text-xl font-bold text-slate-200">Export Complete!</span>
</>
) : (
<>
{isExporting ? (
<Loader2 className="w-6 h-6 text-[#34B27B] animate-spin" />
) : (
<Download className="w-6 h-6 text-[#34B27B]" />
)}
<span className="text-xl font-bold text-slate-200">
{error ? 'Export Failed' : isExporting ? 'Exporting Video' : 'Export Video'}
</span>
</>
)}
</div>
{!isExporting && (
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="hover:bg-[#34B27B]/20 text-slate-200"
>
<X className="w-5 h-5" />
</Button>
)}
</div>
{error && (
<div className="mb-6">
<div className="bg-red-500/10 border border-red-500/30 rounded-lg p-4">
<p className="text-sm text-red-400">{error}</p>
</div>
</div>
)}
{isExporting && progress && (
<div className="space-y-4">
<div className="space-y-2">
<div className="flex justify-between text-sm text-slate-400">
<span>Progress</span>
<span className="font-mono">{progress.percentage.toFixed(1)}%</span>
</div>
<div className="h-3 bg-[#18181b] rounded-full overflow-hidden border border-[#34B27B]/30">
<div
className="h-full bg-gradient-to-r from-[#34B27B] to-[#2a9964] transition-all duration-300 ease-out"
style={{ width: `${Math.min(progress.percentage, 100)}%` }}
/>
</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>
</div>
{onCancel && (
<div className="pt-4 border-t border-[#34B27B]/20">
<Button
onClick={onCancel}
variant="outline"
className="w-full bg-transparent border-slate-600 text-slate-300 hover:bg-slate-800"
>
Cancel Export
</Button>
</div>
)}
</div>
)}
{showSuccess && (
<div className="text-center py-8">
<p className="text-lg text-slate-200">Video saved successfully!</p>
</div>
)}
{!isExporting && !error && !showSuccess && (
<div className="text-center py-4">
<p className="text-slate-400">Ready to export your video</p>
</div>
)}
</div>
</>
);
}
@@ -40,6 +40,7 @@ interface SettingsPanelProps {
cropRegion?: CropRegion;
onCropChange?: (region: CropRegion) => void;
videoElement?: HTMLVideoElement | null;
onExport?: () => void;
}
const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [
@@ -50,7 +51,7 @@ const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [
{ depth: 5, label: "3.5×" },
];
export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth, onZoomDepthChange, selectedZoomId, onZoomDelete, showShadow, onShadowChange, showBlur, onBlurChange, cropRegion, onCropChange, videoElement }: SettingsPanelProps) {
export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth, onZoomDepthChange, selectedZoomId, onZoomDelete, showShadow, onShadowChange, showBlur, onBlurChange, cropRegion, onCropChange, videoElement, onExport }: SettingsPanelProps) {
const [hsva, setHsva] = useState({ h: 0, s: 0, v: 68, a: 1 });
const [gradient, setGradient] = useState<string>(GRADIENTS[0]);
const [showCropDropdown, setShowCropDropdown] = useState(false);
@@ -241,6 +242,7 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
<Button
type="button"
size="lg"
onClick={onExport}
className="w-full py-5 text-lg flex items-center justify-center gap-3 bg-[#34B27B] text-white rounded-xl shadow-lg hover:bg-[#34B27B]/80 transition-all"
>
<Download className="w-6 h-6" />
+106
View File
@@ -2,11 +2,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback";
import PlaybackControls from "./PlaybackControls";
import TimelineEditor from "./timeline/TimelineEditor";
import { SettingsPanel } from "./SettingsPanel";
import { ExportDialog } from "./ExportDialog";
import type { Span } from "dnd-timeline";
import {
DEFAULT_ZOOM_DEPTH,
@@ -17,6 +19,7 @@ import {
type ZoomRegion,
type CropRegion,
} from "./types";
import { VideoExporter, type ExportProgress } from "@/lib/exporter";
const WALLPAPER_COUNT = 12;
const WALLPAPER_PATHS = Array.from({ length: WALLPAPER_COUNT }, (_, i) => `/wallpapers/wallpaper${i + 1}.jpg`);
@@ -34,9 +37,14 @@ export default function VideoEditor() {
const [cropRegion, setCropRegion] = useState<CropRegion>(DEFAULT_CROP_REGION);
const [zoomRegions, setZoomRegions] = useState<ZoomRegion[]>([]);
const [selectedZoomId, setSelectedZoomId] = useState<string | null>(null);
const [isExporting, setIsExporting] = useState(false);
const [exportProgress, setExportProgress] = useState<ExportProgress | null>(null);
const [exportError, setExportError] = useState<string | null>(null);
const [showExportDialog, setShowExportDialog] = useState(false);
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
const nextZoomIdRef = useRef(1);
const exporterRef = useRef<VideoExporter | null>(null);
useEffect(() => {
async function loadVideo() {
@@ -155,6 +163,95 @@ export default function VideoEditor() {
}
}, [selectedZoomId, zoomRegions]);
const handleExport = useCallback(async () => {
if (!videoPath) {
toast.error('No video loaded');
return;
}
const video = videoPlaybackRef.current?.video;
if (!video) {
toast.error('Video not ready');
return;
}
setShowExportDialog(true);
setIsExporting(true);
setExportProgress(null);
setExportError(null);
try {
// Pause video during export
const wasPlaying = isPlaying;
if (wasPlaying) {
videoPlaybackRef.current?.pause();
}
// Determine export dimensions (use video dimensions)
const width = video.videoWidth;
const height = video.videoHeight;
const exporter = new VideoExporter({
videoUrl: videoPath,
width,
height,
frameRate: 60,
bitrate: 15_000_000,
codec: 'avc1.640033',
wallpaper,
zoomRegions,
showShadow,
showBlur,
cropRegion,
onProgress: (progress) => {
setExportProgress(progress);
},
});
exporterRef.current = exporter;
const result = await exporter.export();
if (result.success && result.blob) {
// Save the blob using Electron
const arrayBuffer = await result.blob.arrayBuffer();
const timestamp = Date.now();
const fileName = `export-${timestamp}.mp4`;
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
if (saveResult.success) {
toast.success('Video exported successfully!');
} else {
setExportError(saveResult.message || 'Failed to save video');
toast.error(saveResult.message || 'Failed to save video');
}
} else {
setExportError(result.error || 'Export failed');
toast.error(result.error || 'Export failed');
}
// Resume playback if it was playing
if (wasPlaying) {
videoPlaybackRef.current?.play();
}
} catch (error) {
console.error('Export error:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
setExportError(errorMessage);
toast.error(`Export failed: ${errorMessage}`);
} finally {
setIsExporting(false);
exporterRef.current = null;
}
}, [videoPath, wallpaper, zoomRegions, showShadow, showBlur, cropRegion, isPlaying]);
const handleCancelExport = useCallback(() => {
if (exporterRef.current) {
exporterRef.current.cancel();
toast.info('Export cancelled');
}
}, []);
if (loading) {
return (
<div className="flex items-center justify-center h-screen bg-background">
@@ -173,6 +270,14 @@ export default function VideoEditor() {
return (
<div className="flex h-screen bg-background bg-black p-8 gap-8">
<Toaster position="top-center" />
<ExportDialog
isOpen={showExportDialog}
onClose={() => setShowExportDialog(false)}
progress={exportProgress}
isExporting={isExporting}
error={exportError}
onCancel={handleCancelExport}
/>
<div className="flex flex-col flex-[7] min-w-0 gap-6">
<div className="flex flex-col gap-3 flex-1">
{videoPath && (
@@ -232,6 +337,7 @@ export default function VideoEditor() {
cropRegion={cropRegion}
onCropChange={setCropRegion}
videoElement={videoPlaybackRef.current?.video || null}
onExport={handleExport}
/>
</div>
);
@@ -422,6 +422,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
autoDensity: true,
});
// Lock ticker to 60fps for consistent animation speed across all displays
app.ticker.maxFPS = 60;
if (!mounted) {
app.destroy(true, { children: true, texture: true, textureSource: true });
return;
+503
View File
@@ -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;
}
}
+5
View File
@@ -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';
+94
View File
@@ -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' });
}
}
+26
View File
@@ -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
}
+91
View File
@@ -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;
}
}
}
+260
View File
@@ -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;
}
}
+6
View File
@@ -1,4 +1,5 @@
/// <reference types="vite/client" />
/// <reference types="../electron/electron-env" />
interface ProcessedDesktopSource {
id: string;
@@ -39,5 +40,10 @@ interface Window {
setRecordingState: (recording: boolean) => Promise<void>
onStopRecordingFromTray: (callback: () => void) => () => void
openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>
saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{
success: boolean
path?: string
message?: string
}>
}
}