From d9a9f48ab9b5fac313ffedb2b6be638af274f9ff Mon Sep 17 00:00:00 2001
From: Siddharth
Date: Tue, 18 Nov 2025 00:58:09 -0700
Subject: [PATCH] cleanup+ readme updates
---
README.md | 5 +-
electron/ipc/mouseTracking.ts | 3 ++
electron/main.ts | 1 -
src/components/launch/SourceSelector.tsx | 2 -
src/components/video-editor/CropControl.tsx | 16 ------
src/components/video-editor/SettingsPanel.tsx | 4 +-
src/components/video-editor/VideoEditor.tsx | 4 --
src/components/video-editor/VideoPlayback.tsx | 17 +-----
.../video-editor/timeline/TimelineEditor.tsx | 4 +-
.../video-editor/timeline/TimelineWrapper.tsx | 3 --
.../video-editor/videoPlayback/focusUtils.ts | 3 +-
.../video-editor/videoPlayback/layoutUtils.ts | 2 +-
.../videoPlayback/videoEventHandlers.ts | 3 --
src/lib/exporter/frameRenderer.ts | 53 +++----------------
src/lib/exporter/index.ts | 3 ++
src/lib/exporter/muxer.ts | 7 +--
src/lib/exporter/videoDecoder.ts | 9 +---
src/lib/exporter/videoExporter.ts | 48 ++++++-----------
18 files changed, 40 insertions(+), 147 deletions(-)
diff --git a/README.md b/README.md
index 08afd3c..d48e7a3 100644
--- a/README.md
+++ b/README.md
@@ -15,11 +15,11 @@ If you don't want to pay $29/month for Screen Studio but want a much simpler ver
Okay, let's be real: Screen Studio is an awesome product and this is definitely not a 1:1 clone. OpenScreen is a much simpler take, just the basics for folks who want control and don't want to pay. If you need all the fancy features, your best bet is to support Screen Studio (they really do a great job, haha). But if you just want something free (no gotchas) and open, this project does the job!
-OpenScreen is 100% free for personal and commercial use. Use it, remix it, build on it. (Just be cool and give a shoutout if you feel like it!)
+OpenScreen is 100% free for personal and commercial use. Use it, modify it, distribute it. (Just be cool 😁 and give a shoutout if you feel like it !)
-**⚠️ DISCLAIMER: This is very much in beta and might be buggy here and there (but hope you have a good experience!).**
+**⚠️ DISCLAIMER: This is very much in beta and might be buggy here and there (but hope you have a good experience!).**
@@ -53,6 +53,7 @@ Download the latest installer for your platform from the [GitHub Releases](https
- TypeScript
- Vite
- PixiJS
+- dnd-timeline
---
diff --git a/electron/ipc/mouseTracking.ts b/electron/ipc/mouseTracking.ts
index 63f8409..47d3171 100644
--- a/electron/ipc/mouseTracking.ts
+++ b/electron/ipc/mouseTracking.ts
@@ -1,3 +1,6 @@
+// uiohook-napi [WIP: scoping out mouse tracking with cross platform support for potential auto zoom/ post processing cursor effects]
+// not currently being used.
+
import { uIOhook } from 'uiohook-napi'
let isMouseTrackingActive = false
diff --git a/electron/main.ts b/electron/main.ts
index ed3fa2c..04d15b6 100644
--- a/electron/main.ts
+++ b/electron/main.ts
@@ -147,7 +147,6 @@ app.whenReady().then(async () => {
() => mainWindow,
() => sourceSelectorWindow,
(recording: boolean, sourceName: string) => {
- // removed unused assignment to _isRecording
selectedSourceName = sourceName
if (recording) {
if (!tray) createTray();
diff --git a/src/components/launch/SourceSelector.tsx b/src/components/launch/SourceSelector.tsx
index d319b64..61bc2eb 100644
--- a/src/components/launch/SourceSelector.tsx
+++ b/src/components/launch/SourceSelector.tsx
@@ -104,7 +104,6 @@ export function SourceSelector() {
))}
- {/* Removed scroll hint gradient for clean look */}
@@ -144,7 +143,6 @@ export function SourceSelector() {
))}
- {/* Removed scroll hint gradient for clean look */}
diff --git a/src/components/video-editor/CropControl.tsx b/src/components/video-editor/CropControl.tsx
index 76a53a3..2dfb235 100644
--- a/src/components/video-editor/CropControl.tsx
+++ b/src/components/video-editor/CropControl.tsx
@@ -23,7 +23,6 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [initialCrop, setInitialCrop] = useState(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 */}
- {/* Crop region - 4 straight lines */}
- {/* Top line */}
handlePointerDown(e, 'top')}
/>
- {/* Bottom line */}
handlePointerDown(e, 'bottom')}
/>
- {/* Left line */}
handlePointerDown(e, 'left')}
/>
- {/* Right line */}
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;
})();
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx
index 3ccfcfe..62d671f 100644
--- a/src/components/video-editor/VideoEditor.tsx
+++ b/src/components/video-editor/VideoEditor.tsx
@@ -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();
}
diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx
index ad7d253..106c6c6 100644
--- a/src/components/video-editor/VideoPlayback.tsx
+++ b/src/components/video-editor/VideoPlayback.tsx
@@ -165,7 +165,6 @@ const VideoPlayback = forwardRef(({
}
}, [updateOverlayForRegion, cropRegion]);
- // Keep layoutVideoContent ref updated
useEffect(() => {
layoutVideoContentRef.current = layoutVideoContent;
}, [layoutVideoContent]);
@@ -262,7 +261,7 @@ const VideoPlayback = forwardRef(({
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
- // ignore release errors when pointer capture is already cleared
+
}
};
@@ -286,7 +285,6 @@ const VideoPlayback = forwardRef(({
isPlayingRef.current = isPlaying;
}, [isPlaying]);
- // Reset animation state and transforms when crop changes
useEffect(() => {
if (!pixiReady || !videoReady) return;
@@ -306,7 +304,6 @@ const VideoPlayback = forwardRef(({
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(({
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(({
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(({
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(({
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(({
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(({
let mounted = true;
let app: PIXI.Application | null = null;
- // Initialize the app
(async () => {
app = new PIXI.Application();
@@ -423,7 +412,6 @@ const VideoPlayback = forwardRef(({
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(({
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(({
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) {
diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx
index 7bf63d9..150061d 100644
--- a/src/components/video-editor/timeline/TimelineEditor.tsx
+++ b/src/components/video-editor/timeline/TimelineEditor.tsx
@@ -151,7 +151,7 @@ function PlaybackCursor({
- {/* Subtle glow at top */}
@@ -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;
diff --git a/src/components/video-editor/timeline/TimelineWrapper.tsx b/src/components/video-editor/timeline/TimelineWrapper.tsx
index 085aadb..cc33936 100644
--- a/src/components/video-editor/timeline/TimelineWrapper.tsx
+++ b/src/components/video-editor/timeline/TimelineWrapper.tsx
@@ -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 (
{children}
diff --git a/src/components/video-editor/videoPlayback/focusUtils.ts b/src/components/video-editor/videoPlayback/focusUtils.ts
index 8228f3e..e8e9f41 100644
--- a/src/components/video-editor/videoPlayback/focusUtils.ts
+++ b/src/components/video-editor/videoPlayback/focusUtils.ts
@@ -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);
diff --git a/src/components/video-editor/videoPlayback/layoutUtils.ts b/src/components/video-editor/videoPlayback/layoutUtils.ts
index 6305d2d..a83c982 100644
--- a/src/components/video-editor/videoPlayback/layoutUtils.ts
+++ b/src/components/video-editor/videoPlayback/layoutUtils.ts
@@ -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;
diff --git a/src/components/video-editor/videoPlayback/videoEventHandlers.ts b/src/components/video-editor/videoPlayback/videoEventHandlers.ts
index 439e0d2..86b8201 100644
--- a/src/components/video-editor/videoPlayback/videoEventHandlers.ts
+++ b/src/components/video-editor/videoPlayback/videoEventHandlers.ts
@@ -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();
}
diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts
index a4b7b37..6fc9c15 100644
--- a/src/lib/exporter/frameRenderer.ts
+++ b/src/lib/exporter/frameRenderer.ts
@@ -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 {
@@ -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 });
diff --git a/src/lib/exporter/index.ts b/src/lib/exporter/index.ts
index b5b1934..bc2ac94 100644
--- a/src/lib/exporter/index.ts
+++ b/src/lib/exporter/index.ts
@@ -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
\ No newline at end of file
diff --git a/src/lib/exporter/muxer.ts b/src/lib/exporter/muxer.ts
index 2ae2d01..b787db3 100644
--- a/src/lib/exporter/muxer.ts
+++ b/src/lib/exporter/muxer.ts
@@ -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 {
- // 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,
},
diff --git a/src/lib/exporter/videoDecoder.ts b/src/lib/exporter/videoDecoder.ts
index b5bf3bc..4f3c590 100644
--- a/src/lib/exporter/videoDecoder.ts
+++ b/src/lib/exporter/videoDecoder.ts
@@ -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 {
- // 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);
diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts
index e8c913d..c692fc3 100644
--- a/src/lib/exporter/videoExporter.ts
+++ b/src/lib/exporter/videoExporter.ts
@@ -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 {
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();