cleanup+ readme updates
This commit is contained in:
@@ -104,7 +104,6 @@ export function SourceSelector() {
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
{/* Removed scroll hint gradient for clean look */}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="windows" className="h-full">
|
||||
@@ -144,7 +143,6 @@ export function SourceSelector() {
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
{/* Removed scroll hint gradient for clean look */}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,6 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||
const [initialCrop, setInitialCrop] = useState<CropRegion>(cropRegion);
|
||||
|
||||
// Draw video preview at high quality
|
||||
useEffect(() => {
|
||||
if (!videoElement || !canvasRef.current) return;
|
||||
|
||||
@@ -31,7 +30,6 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont
|
||||
const ctx = canvas.getContext('2d', { alpha: false });
|
||||
if (!ctx) return;
|
||||
|
||||
// Set canvas to actual video dimensions for high quality
|
||||
canvas.width = videoElement.videoWidth || 1920;
|
||||
canvas.height = videoElement.videoHeight || 1080;
|
||||
|
||||
@@ -62,7 +60,6 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont
|
||||
});
|
||||
setInitialCrop(cropRegion);
|
||||
|
||||
// Capture pointer for smooth dragging
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
@@ -79,11 +76,8 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont
|
||||
|
||||
switch (isDragging) {
|
||||
case 'top': {
|
||||
// Calculate new y position
|
||||
const newY = Math.max(0, initialCrop.y + deltaY);
|
||||
// Calculate the bottom edge (which should stay fixed)
|
||||
const bottom = initialCrop.y + initialCrop.height;
|
||||
// Ensure minimum height of 0.1
|
||||
newCrop.y = Math.min(newY, bottom - 0.1);
|
||||
newCrop.height = bottom - newCrop.y;
|
||||
break;
|
||||
@@ -92,11 +86,8 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont
|
||||
newCrop.height = Math.max(0.1, Math.min(initialCrop.height + deltaY, 1 - initialCrop.y));
|
||||
break;
|
||||
case 'left': {
|
||||
// Calculate new x position
|
||||
const newX = Math.max(0, initialCrop.x + deltaX);
|
||||
// Calculate the right edge (which should stay fixed)
|
||||
const right = initialCrop.x + initialCrop.width;
|
||||
// Ensure minimum width of 0.1
|
||||
newCrop.x = Math.min(newX, right - 0.1);
|
||||
newCrop.width = right - newCrop.x;
|
||||
break;
|
||||
@@ -114,7 +105,6 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont
|
||||
try {
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
setIsDragging(null);
|
||||
@@ -140,7 +130,6 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont
|
||||
style={{ imageRendering: 'auto' }}
|
||||
/>
|
||||
|
||||
{/* Dark overlay outside crop */}
|
||||
<div className="absolute inset-0 pointer-events-none" style={{ transition: 'none' }}>
|
||||
<svg width="100%" height="100%" className="absolute inset-0" style={{ transition: 'none' }}>
|
||||
<defs>
|
||||
@@ -167,8 +156,6 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Crop region - 4 straight lines */}
|
||||
{/* Top line */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute h-[3px] cursor-ns-resize z-20 pointer-events-auto bg-green-500"
|
||||
@@ -184,7 +171,6 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont
|
||||
onPointerDown={(e) => handlePointerDown(e, 'top')}
|
||||
/>
|
||||
|
||||
{/* Bottom line */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute h-[3px] cursor-ns-resize z-20 pointer-events-auto bg-green-500"
|
||||
@@ -200,7 +186,6 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont
|
||||
onPointerDown={(e) => handlePointerDown(e, 'bottom')}
|
||||
/>
|
||||
|
||||
{/* Left line */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute w-[3px] cursor-ew-resize z-20 pointer-events-auto bg-green-500"
|
||||
@@ -216,7 +201,6 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont
|
||||
onPointerDown={(e) => handlePointerDown(e, 'left')}
|
||||
/>
|
||||
|
||||
{/* Right line */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute w-[3px] cursor-ew-resize z-20 pointer-events-auto bg-green-500"
|
||||
|
||||
@@ -210,15 +210,13 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
|
||||
{(wallpaperPaths.length > 0 ? wallpaperPaths : WALLPAPER_RELATIVE.map(p => `/${p}`)).map((path, idx) => {
|
||||
const isSelected = (() => {
|
||||
if (!selected) return false;
|
||||
// exact match
|
||||
|
||||
if (selected === path) return true;
|
||||
// file:// vs absolute path mismatch: compare by filename suffix
|
||||
try {
|
||||
const clean = (s: string) => s.replace(/^file:\/\//, '').replace(/^\//, '')
|
||||
if (clean(selected).endsWith(clean(path))) return true;
|
||||
if (clean(path).endsWith(clean(selected))) return true;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return false;
|
||||
})();
|
||||
|
||||
@@ -181,13 +181,11 @@ export default function VideoEditor() {
|
||||
setExportError(null);
|
||||
|
||||
try {
|
||||
// Pause video during export
|
||||
const wasPlaying = isPlaying;
|
||||
if (wasPlaying) {
|
||||
videoPlaybackRef.current?.pause();
|
||||
}
|
||||
|
||||
// Always export at 1920x1080 (16:9)
|
||||
const width = 1920;
|
||||
const height = 1080;
|
||||
|
||||
@@ -212,7 +210,6 @@ export default function VideoEditor() {
|
||||
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`;
|
||||
@@ -230,7 +227,6 @@ export default function VideoEditor() {
|
||||
toast.error(result.error || 'Export failed');
|
||||
}
|
||||
|
||||
// Resume playback if it was playing
|
||||
if (wasPlaying) {
|
||||
videoPlaybackRef.current?.play();
|
||||
}
|
||||
|
||||
@@ -165,7 +165,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
}
|
||||
}, [updateOverlayForRegion, cropRegion]);
|
||||
|
||||
// Keep layoutVideoContent ref updated
|
||||
useEffect(() => {
|
||||
layoutVideoContentRef.current = layoutVideoContent;
|
||||
}, [layoutVideoContent]);
|
||||
@@ -262,7 +261,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
try {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// ignore release errors when pointer capture is already cleared
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
@@ -286,7 +285,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
isPlayingRef.current = isPlaying;
|
||||
}, [isPlaying]);
|
||||
|
||||
// Reset animation state and transforms when crop changes
|
||||
useEffect(() => {
|
||||
if (!pixiReady || !videoReady) return;
|
||||
|
||||
@@ -306,7 +304,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
video.pause();
|
||||
}
|
||||
|
||||
// Reset animation state so the ticker starts from identity once it resumes
|
||||
animationStateRef.current = {
|
||||
scale: 1,
|
||||
focusX: DEFAULT_FOCUS.cx,
|
||||
@@ -317,7 +314,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
blurFilterRef.current.blur = 0;
|
||||
}
|
||||
|
||||
// Defer layout to the next frame so DOM measurements include the new crop UI state
|
||||
requestAnimationFrame(() => {
|
||||
const container = cameraContainerRef.current;
|
||||
const videoStage = videoContainerRef.current;
|
||||
@@ -327,7 +323,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset all transform hierarchies to identity
|
||||
container.scale.set(1);
|
||||
container.position.set(0, 0);
|
||||
videoStage.scale.set(1);
|
||||
@@ -335,10 +330,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
sprite.scale.set(1);
|
||||
sprite.position.set(0, 0);
|
||||
|
||||
// Now layoutVideoContent will apply the correct transforms for the new crop
|
||||
layoutVideoContent();
|
||||
|
||||
// Apply an explicit identity transform to ensure no residual camera offset
|
||||
applyZoomTransform({
|
||||
cameraContainer: container,
|
||||
blurFilter: blurFilterRef.current,
|
||||
@@ -351,12 +344,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
isPlaying: false,
|
||||
});
|
||||
|
||||
// Restart ticker on a second frame to avoid running mid-layout
|
||||
requestAnimationFrame(() => {
|
||||
const finalApp = appRef.current;
|
||||
if (wasPlaying && video) {
|
||||
video.play().catch(() => {
|
||||
/* ignore */
|
||||
});
|
||||
}
|
||||
if (tickerWasStarted && finalApp?.ticker) {
|
||||
@@ -402,7 +393,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
overlayEl.style.pointerEvents = isPlaying ? 'none' : 'auto';
|
||||
}, [selectedZoom, isPlaying]);
|
||||
|
||||
// Initialize PixiJS application
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
@@ -410,7 +400,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
let mounted = true;
|
||||
let app: PIXI.Application | null = null;
|
||||
|
||||
// Initialize the app
|
||||
(async () => {
|
||||
app = new PIXI.Application();
|
||||
|
||||
@@ -423,7 +412,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
autoDensity: true,
|
||||
});
|
||||
|
||||
// Lock ticker to 60fps for consistent animation speed across all displays
|
||||
app.ticker.maxFPS = 60;
|
||||
|
||||
if (!mounted) {
|
||||
@@ -690,7 +678,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
return
|
||||
}
|
||||
|
||||
// If it's a solid color or CSS gradient, use it directly
|
||||
if (wallpaper.startsWith('#') || wallpaper.startsWith('linear-gradient') || wallpaper.startsWith('radial-gradient')) {
|
||||
if (mounted) setResolvedWallpaper(wallpaper)
|
||||
return
|
||||
@@ -708,8 +695,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
if (mounted) setResolvedWallpaper(wallpaper)
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise assume it's a relative path like 'wallpapers/wallpaper1.jpg'
|
||||
const p = await getAssetPath(wallpaper.replace(/^\//, ''))
|
||||
if (mounted) setResolvedWallpaper(p)
|
||||
} catch (err) {
|
||||
|
||||
@@ -151,7 +151,7 @@ function PlaybackCursor({
|
||||
<div
|
||||
className="absolute top-0 bottom-0 pointer-events-none z-50"
|
||||
style={{
|
||||
[sideProperty === "right" ? "marginRight" : "marginLeft"]: `${sidebarWidth - 8}px`, // reduce margin
|
||||
[sideProperty === "right" ? "marginRight" : "marginLeft"]: `${sidebarWidth - 8}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -185,7 +185,6 @@ function PlaybackCursor({
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{/* Subtle glow at top */}
|
||||
<div className="absolute -top-1 left-1/2 -translate-x-1/2 w-2 h-2 bg-red-500/30 rounded-full blur-sm" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -403,7 +402,6 @@ export default function TimelineEditor({
|
||||
// Snap if gap is 2ms or less
|
||||
return zoomRegions.some((region) => {
|
||||
if (region.id === excludeId) return false;
|
||||
// If the new span is within 2ms of another region, treat as overlap (snap)
|
||||
const gapBefore = newSpan.start - region.endMs;
|
||||
const gapAfter = region.startMs - newSpan.end;
|
||||
if (gapBefore > 0 && gapBefore <= 2) return true;
|
||||
|
||||
@@ -149,8 +149,6 @@ export default function TimelineWrapper({
|
||||
[clampRange, onRangeChange, totalMs],
|
||||
);
|
||||
|
||||
// To maximize granularity, disable grid snapping by not passing rangeGridSizeDefinition
|
||||
// and allow pixel-level movement for items.
|
||||
return (
|
||||
<TimelineContext
|
||||
range={range}
|
||||
@@ -158,7 +156,6 @@ export default function TimelineWrapper({
|
||||
onResizeEnd={onResizeEnd}
|
||||
onDragEnd={onDragEnd}
|
||||
autoScroll={{ enabled: false }}
|
||||
// Remove rangeGridSizeDefinition to avoid snap effect
|
||||
>
|
||||
{children}
|
||||
</TimelineContext>
|
||||
|
||||
@@ -18,8 +18,7 @@ export function clampFocusToStage(
|
||||
|
||||
const windowWidth = stageSize.width / zoomScale;
|
||||
const windowHeight = stageSize.height / zoomScale;
|
||||
|
||||
// Calculate margins - focus must stay far enough from edges so zoom window stays within stage bounds
|
||||
|
||||
const marginX = windowWidth / (2 * stageSize.width);
|
||||
const marginY = windowHeight / (2 * stageSize.height);
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ interface LayoutResult {
|
||||
export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
|
||||
const { container, app, videoSprite, maskGraphics, videoElement, cropRegion, lockedVideoDimensions } = params;
|
||||
|
||||
// Use locked dimensions if available, otherwise use current video dimensions
|
||||
|
||||
const videoWidth = lockedVideoDimensions?.width || videoElement.videoWidth;
|
||||
const videoHeight = lockedVideoDimensions?.height || videoElement.videoHeight;
|
||||
|
||||
|
||||
@@ -37,7 +37,6 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
||||
}
|
||||
|
||||
const handlePlay = () => {
|
||||
// Prevent autoplay during seek operations
|
||||
if (isSeekingRef.current) {
|
||||
video.pause();
|
||||
return;
|
||||
@@ -69,7 +68,6 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
||||
const handleSeeked = () => {
|
||||
isSeekingRef.current = false;
|
||||
|
||||
// Keep video paused after seek if it wasn't playing
|
||||
if (!isPlayingRef.current && !video.paused) {
|
||||
video.pause();
|
||||
}
|
||||
@@ -79,7 +77,6 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
||||
const handleSeeking = () => {
|
||||
isSeekingRef.current = true;
|
||||
|
||||
// Prevent autoplay during seek if video was paused
|
||||
if (!isPlayingRef.current && !video.paused) {
|
||||
video.pause();
|
||||
}
|
||||
|
||||
@@ -24,10 +24,8 @@ interface AnimationState {
|
||||
focusY: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders video frames with all effects (background, zoom, crop, blur, shadow)
|
||||
* to an offscreen canvas for export.
|
||||
*/
|
||||
// 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;
|
||||
@@ -65,7 +63,6 @@ export class FrameRenderer {
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -73,7 +70,7 @@ export class FrameRenderer {
|
||||
height: this.config.height,
|
||||
backgroundAlpha: 0,
|
||||
antialias: true,
|
||||
resolution: 2, // Match typical Retina/high-DPI displays for blur quality
|
||||
resolution: 2,
|
||||
autoDensity: true,
|
||||
});
|
||||
|
||||
@@ -150,20 +147,18 @@ export class FrameRenderer {
|
||||
img.src = imageUrl;
|
||||
});
|
||||
|
||||
// Draw the image using cover and center positioning (like CSS bg-cover bg-center)
|
||||
// Draw the image using cover and center positioning
|
||||
const imgAspect = img.width / img.height;
|
||||
const canvasAspect = this.config.width / this.config.height;
|
||||
|
||||
let drawWidth, drawHeight, drawX, drawY;
|
||||
|
||||
if (imgAspect > canvasAspect) {
|
||||
// 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;
|
||||
@@ -172,13 +167,10 @@ export class FrameRenderer {
|
||||
|
||||
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;
|
||||
@@ -187,15 +179,10 @@ export class FrameRenderer {
|
||||
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];
|
||||
@@ -204,7 +191,6 @@ export class FrameRenderer {
|
||||
}
|
||||
});
|
||||
} 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;
|
||||
@@ -228,19 +214,17 @@ export class FrameRenderer {
|
||||
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"}
|
||||
this.backgroundSprite = bgCanvas as any;
|
||||
}
|
||||
|
||||
async renderFrame(videoFrame: VideoFrame, timestamp: number): Promise<void> {
|
||||
@@ -248,7 +232,7 @@ export class FrameRenderer {
|
||||
throw new Error('Renderer not initialized');
|
||||
}
|
||||
|
||||
this.currentVideoTime = timestamp / 1000000; // convert microseconds to seconds
|
||||
this.currentVideoTime = timestamp / 1000000;
|
||||
|
||||
// Create or update video sprite from VideoFrame
|
||||
if (!this.videoSprite) {
|
||||
@@ -264,10 +248,8 @@ export class FrameRenderer {
|
||||
// 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
|
||||
const TICKS_PER_FRAME = 1;
|
||||
|
||||
let maxMotionIntensity = 0;
|
||||
for (let i = 0; i < TICKS_PER_FRAME; i++) {
|
||||
@@ -285,7 +267,7 @@ export class FrameRenderer {
|
||||
focusX: this.animationState.focusX,
|
||||
focusY: this.animationState.focusY,
|
||||
motionIntensity: maxMotionIntensity,
|
||||
isPlaying: true, // Enable motion blur
|
||||
isPlaying: true,
|
||||
});
|
||||
|
||||
// Render the PixiJS stage to its canvas (video only, transparent background)
|
||||
@@ -355,10 +337,6 @@ export class FrameRenderer {
|
||||
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;
|
||||
|
||||
@@ -368,12 +346,10 @@ export class FrameRenderer {
|
||||
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,
|
||||
@@ -395,7 +371,6 @@ export class FrameRenderer {
|
||||
let nextFocusX = prevFocusX;
|
||||
let nextFocusY = prevFocusY;
|
||||
|
||||
// Apply smooth exponential easing
|
||||
if (Math.abs(scaleDelta) > MIN_DELTA) {
|
||||
nextScale = prevScale + scaleDelta * SMOOTHING_FACTOR;
|
||||
} else {
|
||||
@@ -418,7 +393,6 @@ export class FrameRenderer {
|
||||
state.focusX = nextFocusX;
|
||||
state.focusY = nextFocusY;
|
||||
|
||||
// Calculate and return motion intensity for blur
|
||||
return Math.max(
|
||||
Math.abs(nextScale - prevScale),
|
||||
Math.abs(nextFocusX - prevFocusX),
|
||||
@@ -442,7 +416,6 @@ export class FrameRenderer {
|
||||
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);
|
||||
@@ -456,22 +429,14 @@ export class FrameRenderer {
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -480,7 +445,6 @@ export class FrameRenderer {
|
||||
if (!this.compositeCanvas) {
|
||||
throw new Error('Renderer not initialized');
|
||||
}
|
||||
// Return the composite canvas which includes shadows
|
||||
return this.compositeCanvas;
|
||||
}
|
||||
|
||||
@@ -496,7 +460,6 @@ export class FrameRenderer {
|
||||
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 });
|
||||
|
||||
@@ -3,3 +3,6 @@ export { VideoFileDecoder } from './videoDecoder';
|
||||
export { FrameRenderer } from './frameRenderer';
|
||||
export { VideoMuxer } from './muxer';
|
||||
export type { ExportConfig, ExportProgress, ExportResult, VideoFrameData } from './types';
|
||||
|
||||
|
||||
// Ref: https://pietrasiak.com/fast-video-rendering-and-encoding-using-web-apis
|
||||
@@ -22,10 +22,6 @@ interface Muxer {
|
||||
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;
|
||||
@@ -37,7 +33,6 @@ export class VideoMuxer {
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -47,7 +42,7 @@ export class VideoMuxer {
|
||||
const options: MP4MuxerOptions = {
|
||||
target,
|
||||
video: {
|
||||
codec: 'avc', // mp4-muxer only accepts 'avc', not full codec string
|
||||
codec: 'avc',
|
||||
width: this.config.width,
|
||||
height: this.config.height,
|
||||
},
|
||||
|
||||
@@ -6,17 +6,12 @@ export interface DecodedVideoInfo {
|
||||
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';
|
||||
@@ -29,8 +24,8 @@ export class VideoFileDecoder {
|
||||
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
|
||||
frameRate: 60,
|
||||
codec: 'avc1.640033',
|
||||
};
|
||||
|
||||
resolve(this.info);
|
||||
|
||||
@@ -14,12 +14,6 @@ interface VideoExporterConfig extends ExportConfig {
|
||||
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;
|
||||
@@ -29,7 +23,7 @@ export class VideoExporter {
|
||||
private cancelled = false;
|
||||
private encodedChunks: EncodedVideoChunk[] = [];
|
||||
private encodeQueue = 0;
|
||||
private readonly MAX_ENCODE_QUEUE = 60; // Increased for better throughput
|
||||
private readonly MAX_ENCODE_QUEUE = 60;
|
||||
private videoDescription: Uint8Array | undefined;
|
||||
|
||||
constructor(config: VideoExporterConfig) {
|
||||
@@ -38,15 +32,14 @@ export class VideoExporter {
|
||||
|
||||
async export(): Promise<ExportResult> {
|
||||
try {
|
||||
// Clean up any previous export state
|
||||
this.cleanup();
|
||||
this.cancelled = false;
|
||||
|
||||
// Step 1: Initialize decoder and load video
|
||||
// Initialize decoder and load video
|
||||
this.decoder = new VideoFileDecoder();
|
||||
const videoInfo = await this.decoder.loadVideo(this.config.videoUrl);
|
||||
|
||||
// Step 2: Initialize frame renderer
|
||||
// Initialize frame renderer
|
||||
this.renderer = new FrameRenderer({
|
||||
width: this.config.width,
|
||||
height: this.config.height,
|
||||
@@ -60,26 +53,26 @@ export class VideoExporter {
|
||||
});
|
||||
await this.renderer.initialize();
|
||||
|
||||
// Step 3: Initialize video encoder
|
||||
// Initialize video encoder
|
||||
const totalFrames = Math.ceil(videoInfo.duration * this.config.frameRate);
|
||||
await this.initializeEncoder();
|
||||
|
||||
// Step 4: Initialize muxer
|
||||
// Initialize muxer
|
||||
this.muxer = new VideoMuxer(this.config, false);
|
||||
await this.muxer.initialize();
|
||||
|
||||
// Step 5: Get the video element for frame extraction
|
||||
// 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 with optimized seeking
|
||||
// Process frames with optimized seeking
|
||||
const frameDuration = 1_000_000 / this.config.frameRate; // in microseconds
|
||||
let frameIndex = 0;
|
||||
const timeStep = 1 / this.config.frameRate;
|
||||
|
||||
// Optimize: Pre-load first frame
|
||||
// Pre-load first frame
|
||||
videoElement.currentTime = 0;
|
||||
await new Promise(resolve => {
|
||||
const onSeeked = () => {
|
||||
@@ -92,7 +85,7 @@ export class VideoExporter {
|
||||
while (frameIndex < totalFrames && !this.cancelled) {
|
||||
const timestamp = frameIndex * frameDuration;
|
||||
const videoTime = frameIndex * timeStep;
|
||||
// Seek to frame (optimized: only seek if not already there)
|
||||
// Seek to frame (only seek if not already there)
|
||||
if (Math.abs(videoElement.currentTime - videoTime) > 0.001) {
|
||||
videoElement.currentTime = videoTime;
|
||||
await Promise.race([
|
||||
@@ -104,7 +97,7 @@ export class VideoExporter {
|
||||
};
|
||||
videoElement.addEventListener('seeked', onSeeked, { once: true });
|
||||
}),
|
||||
new Promise(resolve => setTimeout(resolve, 200)) // higher is slower but better capture
|
||||
new Promise(resolve => setTimeout(resolve, 200)) // higher this number, slower the export, but better capture/ no frame drops
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -116,31 +109,26 @@ export class VideoExporter {
|
||||
// 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 (backpressure)
|
||||
while (this.encodeQueue >= this.MAX_ENCODE_QUEUE && !this.cancelled) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1));
|
||||
}
|
||||
|
||||
if (this.cancelled) break;
|
||||
|
||||
// Create VideoFrame from rendered canvas (on GPU, no pixel read!)
|
||||
const canvas = this.renderer!.getCanvas();
|
||||
const 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++;
|
||||
|
||||
if (this.config.onProgress) {
|
||||
@@ -157,12 +145,12 @@ export class VideoExporter {
|
||||
return { success: false, error: 'Export cancelled' };
|
||||
}
|
||||
|
||||
// Step 7: Finalize encoding
|
||||
// Finalize encoding
|
||||
if (this.encoder && this.encoder.state === 'configured') {
|
||||
await this.encoder.flush();
|
||||
}
|
||||
|
||||
// Step 8: Add all chunks to muxer with metadata
|
||||
// Add all chunks to muxer with metadata
|
||||
for (let i = 0; i < this.encodedChunks.length; i++) {
|
||||
const chunk = this.encodedChunks[i];
|
||||
const meta: EncodedVideoChunkMetadata = {};
|
||||
@@ -180,7 +168,7 @@ export class VideoExporter {
|
||||
this.muxer!.addVideoChunk(chunk, meta);
|
||||
}
|
||||
|
||||
// Step 9: Finalize muxer and get output blob
|
||||
// Finalize muxer and get output blob
|
||||
const blob = this.muxer!.finalize();
|
||||
|
||||
return { success: true, blob };
|
||||
@@ -202,7 +190,6 @@ export class VideoExporter {
|
||||
|
||||
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));
|
||||
@@ -216,7 +203,6 @@ export class VideoExporter {
|
||||
},
|
||||
});
|
||||
|
||||
// Configure encoder for H.264 (AVC) with level 5.1 for high resolution support
|
||||
const codec = this.config.codec || 'avc1.640033';
|
||||
|
||||
this.encoder.configure({
|
||||
@@ -225,20 +211,18 @@ export class VideoExporter {
|
||||
height: this.config.height,
|
||||
bitrate: this.config.bitrate,
|
||||
framerate: this.config.frameRate,
|
||||
latencyMode: 'realtime', // Changed from 'quality' for faster encoding
|
||||
latencyMode: 'realtime',
|
||||
bitrateMode: 'variable',
|
||||
hardwareAcceleration: 'prefer-hardware', // Use GPU encoding
|
||||
hardwareAcceleration: 'prefer-hardware',
|
||||
} as VideoEncoderConfig);
|
||||
}
|
||||
|
||||
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') {
|
||||
@@ -250,7 +234,6 @@ export class VideoExporter {
|
||||
this.encoder = null;
|
||||
}
|
||||
|
||||
// Destroy decoder
|
||||
if (this.decoder) {
|
||||
try {
|
||||
this.decoder.destroy();
|
||||
@@ -260,7 +243,6 @@ export class VideoExporter {
|
||||
this.decoder = null;
|
||||
}
|
||||
|
||||
// Destroy renderer
|
||||
if (this.renderer) {
|
||||
try {
|
||||
this.renderer.destroy();
|
||||
|
||||
Reference in New Issue
Block a user