export working
This commit is contained in:
+20
-1
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
Vendored
+1
@@ -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 }>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
},
|
||||
})
|
||||
Generated
+34
@@ -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",
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Vendored
+6
@@ -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
|
||||
}>
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user