@@ -6,12 +6,19 @@
|
||||
"productName": "Openscreen",
|
||||
"npmRebuild": true,
|
||||
"buildDependenciesFromSource": true,
|
||||
"compression": "maximum",
|
||||
"directories": {
|
||||
"output": "release/${version}"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"dist-electron"
|
||||
"dist-electron",
|
||||
"!*.png",
|
||||
"!preview*.png",
|
||||
"!*.md",
|
||||
"!README.md",
|
||||
"!CONTRIBUTING.md",
|
||||
"!LICENSE"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
@@ -19,6 +26,9 @@
|
||||
"to": "assets/wallpapers"
|
||||
}
|
||||
],
|
||||
"asarUnpack": [
|
||||
"**/node_modules/uiohook-napi/**/*"
|
||||
],
|
||||
"mac": {
|
||||
"target": [
|
||||
"dmg"
|
||||
|
||||
|
Before Width: | Height: | Size: 798 KiB After Width: | Height: | Size: 813 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 660 B After Width: | Height: | Size: 630 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 222 KiB After Width: | Height: | Size: 224 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 353 KiB After Width: | Height: | Size: 353 KiB |
|
Before Width: | Height: | Size: 713 KiB After Width: | Height: | Size: 853 KiB |
@@ -24,7 +24,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"dnd-timeline": "^2.2.0",
|
||||
"lucide-react": "^0.545.0",
|
||||
"mp4-muxer": "^5.2.2",
|
||||
"mediabunny": "^1.25.1",
|
||||
"mp4box": "^2.2.0",
|
||||
"pixi.js": "^8.14.0",
|
||||
"react": "^18.2.0",
|
||||
@@ -44,16 +44,18 @@
|
||||
"autoprefixer": "^10.4.21",
|
||||
"electron": "^30.0.1",
|
||||
"electron-builder": "^24.13.3",
|
||||
"electron-icon-builder": "^2.0.1",
|
||||
"electron-rebuild": "^3.2.9",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"terser": "^5.44.1",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.1.6",
|
||||
"vite-plugin-electron": "^0.28.6",
|
||||
"vite-plugin-electron-renderer": "^0.14.5"
|
||||
},
|
||||
"main": "dist-electron/main.js"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 20 MiB |
|
Before Width: | Height: | Size: 11 MiB After Width: | Height: | Size: 682 KiB |
|
Before Width: | Height: | Size: 12 MiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 15 MiB After Width: | Height: | Size: 827 KiB |
|
Before Width: | Height: | Size: 18 MiB After Width: | Height: | Size: 524 KiB |
|
Before Width: | Height: | Size: 20 MiB After Width: | Height: | Size: 538 KiB |
|
Before Width: | Height: | Size: 8.5 MiB After Width: | Height: | Size: 897 KiB |
@@ -1,7 +1,7 @@
|
||||
import type React from "react";
|
||||
import { useEffect, useRef, useImperativeHandle, forwardRef, useState, useMemo, useCallback } from "react";
|
||||
import { getAssetPath } from "@/lib/assetPath";
|
||||
import * as PIXI from 'pixi.js';
|
||||
import { Application, Container, Sprite, Graphics, BlurFilter, Texture, VideoSource } from 'pixi.js';
|
||||
import { ZOOM_DEPTH_SCALES, type ZoomRegion, type ZoomFocus, type ZoomDepth } from "./types";
|
||||
import { DEFAULT_FOCUS, SMOOTHING_FACTOR, MIN_DELTA } from "./videoPlayback/constants";
|
||||
import { clamp01 } from "./videoPlayback/mathUtils";
|
||||
@@ -31,9 +31,9 @@ interface VideoPlaybackProps {
|
||||
|
||||
export interface VideoPlaybackRef {
|
||||
video: HTMLVideoElement | null;
|
||||
app: PIXI.Application | null;
|
||||
videoSprite: PIXI.Sprite | null;
|
||||
videoContainer: PIXI.Container | null;
|
||||
app: Application | null;
|
||||
videoSprite: Sprite | null;
|
||||
videoContainer: Container | null;
|
||||
play: () => Promise<void>;
|
||||
pause: () => void;
|
||||
}
|
||||
@@ -56,10 +56,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
}, ref) => {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const appRef = useRef<PIXI.Application | null>(null);
|
||||
const videoSpriteRef = useRef<PIXI.Sprite | null>(null);
|
||||
const videoContainerRef = useRef<PIXI.Container | null>(null);
|
||||
const cameraContainerRef = useRef<PIXI.Container | null>(null);
|
||||
const appRef = useRef<Application | null>(null);
|
||||
const videoSpriteRef = useRef<Sprite | null>(null);
|
||||
const videoContainerRef = useRef<Container | null>(null);
|
||||
const cameraContainerRef = useRef<Container | null>(null);
|
||||
const timeUpdateAnimationRef = useRef<number | null>(null);
|
||||
const [pixiReady, setPixiReady] = useState(false);
|
||||
const [videoReady, setVideoReady] = useState(false);
|
||||
@@ -69,7 +69,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
const zoomRegionsRef = useRef<ZoomRegion[]>([]);
|
||||
const selectedZoomIdRef = useRef<string | null>(null);
|
||||
const animationStateRef = useRef({ scale: 1, focusX: DEFAULT_FOCUS.cx, focusY: DEFAULT_FOCUS.cy });
|
||||
const blurFilterRef = useRef<PIXI.BlurFilter | null>(null);
|
||||
const blurFilterRef = useRef<BlurFilter | null>(null);
|
||||
const isDraggingFocusRef = useRef(false);
|
||||
const stageSizeRef = useRef({ width: 0, height: 0 });
|
||||
const videoSizeRef = useRef({ width: 0, height: 0 });
|
||||
@@ -77,7 +77,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
const baseOffsetRef = useRef({ x: 0, y: 0 });
|
||||
const baseMaskRef = useRef({ x: 0, y: 0, width: 0, height: 0 });
|
||||
const cropBoundsRef = useRef({ startX: 0, endX: 0, startY: 0, endY: 0 });
|
||||
const maskGraphicsRef = useRef<PIXI.Graphics | null>(null);
|
||||
const maskGraphicsRef = useRef<Graphics | null>(null);
|
||||
const isPlayingRef = useRef(isPlaying);
|
||||
const isSeekingRef = useRef(false);
|
||||
const allowPlaybackRef = useRef(false);
|
||||
@@ -398,10 +398,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
if (!container) return;
|
||||
|
||||
let mounted = true;
|
||||
let app: PIXI.Application | null = null;
|
||||
let app: Application | null = null;
|
||||
|
||||
(async () => {
|
||||
app = new PIXI.Application();
|
||||
app = new Application();
|
||||
|
||||
await app.init({
|
||||
width: container.clientWidth,
|
||||
@@ -423,12 +423,12 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
container.appendChild(app.canvas);
|
||||
|
||||
// Camera container - this will be scaled/positioned for zoom
|
||||
const cameraContainer = new PIXI.Container();
|
||||
const cameraContainer = new Container();
|
||||
cameraContainerRef.current = cameraContainer;
|
||||
app.stage.addChild(cameraContainer);
|
||||
|
||||
// Video container - holds the masked video sprite
|
||||
const videoContainer = new PIXI.Container();
|
||||
const videoContainer = new Container();
|
||||
videoContainerRef.current = videoContainer;
|
||||
cameraContainer.addChild(videoContainer);
|
||||
|
||||
@@ -468,19 +468,19 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
if (!video || !app || !videoContainer) return;
|
||||
if (video.videoWidth === 0 || video.videoHeight === 0) return;
|
||||
|
||||
const source = PIXI.VideoSource.from(video);
|
||||
const source = VideoSource.from(video);
|
||||
if ('autoPlay' in source) {
|
||||
(source as { autoPlay?: boolean }).autoPlay = false;
|
||||
}
|
||||
if ('autoUpdate' in source) {
|
||||
(source as { autoUpdate?: boolean }).autoUpdate = true;
|
||||
}
|
||||
const videoTexture = PIXI.Texture.from(source);
|
||||
const videoTexture = Texture.from(source);
|
||||
|
||||
const videoSprite = new PIXI.Sprite(videoTexture);
|
||||
const videoSprite = new Sprite(videoTexture);
|
||||
videoSpriteRef.current = videoSprite;
|
||||
|
||||
const maskGraphics = new PIXI.Graphics();
|
||||
const maskGraphics = new Graphics();
|
||||
videoContainer.addChild(videoSprite);
|
||||
videoContainer.addChild(maskGraphics);
|
||||
videoContainer.mask = maskGraphics;
|
||||
@@ -492,7 +492,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
focusY: DEFAULT_FOCUS.cy,
|
||||
};
|
||||
|
||||
const blurFilter = new PIXI.BlurFilter();
|
||||
const blurFilter = new BlurFilter();
|
||||
blurFilter.quality = 3;
|
||||
blurFilter.resolution = app.renderer.resolution;
|
||||
blurFilter.blur = 0;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import * as PIXI from 'pixi.js';
|
||||
import { Application, Sprite, Graphics } from 'pixi.js';
|
||||
import { VIEWPORT_SCALE } from "./constants";
|
||||
import type { CropRegion } from '../types';
|
||||
|
||||
interface LayoutParams {
|
||||
container: HTMLDivElement;
|
||||
app: PIXI.Application;
|
||||
videoSprite: PIXI.Sprite;
|
||||
maskGraphics: PIXI.Graphics;
|
||||
app: Application;
|
||||
videoSprite: Sprite;
|
||||
maskGraphics: Graphics;
|
||||
videoElement: HTMLVideoElement;
|
||||
cropRegion?: CropRegion;
|
||||
lockedVideoDimensions?: { width: number; height: number } | null;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as PIXI from 'pixi.js';
|
||||
import { Container, BlurFilter } from 'pixi.js';
|
||||
|
||||
interface TransformParams {
|
||||
cameraContainer: PIXI.Container;
|
||||
blurFilter: PIXI.BlurFilter | null;
|
||||
cameraContainer: Container;
|
||||
blurFilter: BlurFilter | null;
|
||||
stageSize: { width: number; height: number };
|
||||
baseMask: { x: number; y: number; width: number; height: number };
|
||||
zoomScale: number;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as PIXI from 'pixi.js';
|
||||
import { Application, Container, Sprite, Graphics, BlurFilter, Texture } 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';
|
||||
@@ -27,13 +27,13 @@ interface AnimationState {
|
||||
// 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 app: Application | null = null;
|
||||
private cameraContainer: Container | null = null;
|
||||
private videoContainer: Container | null = null;
|
||||
private videoSprite: Sprite | null = null;
|
||||
private backgroundSprite: Sprite | null = null;
|
||||
private maskGraphics: Graphics | null = null;
|
||||
private blurFilter: BlurFilter | null = null;
|
||||
private shadowCanvas: HTMLCanvasElement | null = null;
|
||||
private shadowCtx: CanvasRenderingContext2D | null = null;
|
||||
private compositeCanvas: HTMLCanvasElement | null = null;
|
||||
@@ -70,7 +70,7 @@ export class FrameRenderer {
|
||||
}
|
||||
|
||||
// Initialize PixiJS with optimized settings for export performance
|
||||
this.app = new PIXI.Application();
|
||||
this.app = new Application();
|
||||
await this.app.init({
|
||||
canvas,
|
||||
width: this.config.width,
|
||||
@@ -82,8 +82,8 @@ export class FrameRenderer {
|
||||
});
|
||||
|
||||
// Setup containers
|
||||
this.cameraContainer = new PIXI.Container();
|
||||
this.videoContainer = new PIXI.Container();
|
||||
this.cameraContainer = new Container();
|
||||
this.videoContainer = new Container();
|
||||
this.app.stage.addChild(this.cameraContainer);
|
||||
this.cameraContainer.addChild(this.videoContainer);
|
||||
|
||||
@@ -91,7 +91,7 @@ export class FrameRenderer {
|
||||
await this.setupBackground();
|
||||
|
||||
// Setup blur filter for video container
|
||||
this.blurFilter = new PIXI.BlurFilter();
|
||||
this.blurFilter = new BlurFilter();
|
||||
this.blurFilter.quality = 3;
|
||||
this.blurFilter.resolution = this.app.renderer.resolution;
|
||||
this.blurFilter.blur = 0;
|
||||
@@ -120,7 +120,7 @@ export class FrameRenderer {
|
||||
}
|
||||
|
||||
// Setup mask
|
||||
this.maskGraphics = new PIXI.Graphics();
|
||||
this.maskGraphics = new Graphics();
|
||||
this.videoContainer.addChild(this.maskGraphics);
|
||||
this.videoContainer.mask = this.maskGraphics;
|
||||
}
|
||||
@@ -251,13 +251,13 @@ export class FrameRenderer {
|
||||
|
||||
// Create or update video sprite from VideoFrame
|
||||
if (!this.videoSprite) {
|
||||
const texture = PIXI.Texture.from(videoFrame as any);
|
||||
this.videoSprite = new PIXI.Sprite(texture);
|
||||
const texture = Texture.from(videoFrame as any);
|
||||
this.videoSprite = new Sprite(texture);
|
||||
this.videoContainer.addChild(this.videoSprite);
|
||||
} else {
|
||||
// Destroy old texture to avoid memory leaks, then create new one
|
||||
const oldTexture = this.videoSprite.texture;
|
||||
const newTexture = PIXI.Texture.from(videoFrame as any);
|
||||
const newTexture = Texture.from(videoFrame as any);
|
||||
this.videoSprite.texture = newTexture;
|
||||
oldTexture.destroy(true);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,20 @@
|
||||
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;
|
||||
}
|
||||
import {
|
||||
Output,
|
||||
Mp4OutputFormat,
|
||||
BufferTarget,
|
||||
EncodedVideoPacketSource,
|
||||
EncodedAudioPacketSource,
|
||||
EncodedPacket
|
||||
} from 'mediabunny';
|
||||
|
||||
export class VideoMuxer {
|
||||
private muxer: Muxer | null = null;
|
||||
private config: ExportConfig;
|
||||
private output: Output | null = null;
|
||||
private videoSource: EncodedVideoPacketSource | null = null;
|
||||
private audioSource: EncodedAudioPacketSource | null = null;
|
||||
private hasAudio: boolean;
|
||||
private target: BufferTarget | null = null;
|
||||
private config: ExportConfig;
|
||||
|
||||
constructor(config: ExportConfig, hasAudio = false) {
|
||||
this.config = config;
|
||||
@@ -33,57 +22,68 @@ export class VideoMuxer {
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
const MP4MuxerModule = await import('mp4-muxer');
|
||||
const MP4MuxerClass = (MP4MuxerModule as any).Muxer || MP4MuxerModule.default;
|
||||
const ArrayBufferTarget = (MP4MuxerModule as any).ArrayBufferTarget;
|
||||
// Create the buffer target
|
||||
this.target = new BufferTarget();
|
||||
|
||||
const target = new ArrayBufferTarget();
|
||||
|
||||
const options: MP4MuxerOptions = {
|
||||
target,
|
||||
video: {
|
||||
codec: 'avc',
|
||||
width: this.config.width,
|
||||
height: this.config.height,
|
||||
},
|
||||
fastStart: 'in-memory',
|
||||
};
|
||||
this.output = new Output({
|
||||
format: new Mp4OutputFormat({
|
||||
fastStart: 'in-memory',
|
||||
}),
|
||||
target: this.target,
|
||||
});
|
||||
|
||||
// Create video source - codec will be deduced from metadata
|
||||
this.videoSource = new EncodedVideoPacketSource('avc');
|
||||
this.output.addVideoTrack(this.videoSource, {
|
||||
frameRate: this.config.frameRate,
|
||||
});
|
||||
|
||||
// Create audio source if needed
|
||||
if (this.hasAudio) {
|
||||
options.audio = {
|
||||
codec: 'opus',
|
||||
numberOfChannels: 2,
|
||||
sampleRate: 48000,
|
||||
};
|
||||
this.audioSource = new EncodedAudioPacketSource('opus');
|
||||
this.output.addAudioTrack(this.audioSource);
|
||||
}
|
||||
|
||||
this.muxer = new MP4MuxerClass(options) as Muxer;
|
||||
// Start the output to begin accepting media data
|
||||
await this.output.start();
|
||||
}
|
||||
|
||||
addVideoChunk(chunk: EncodedVideoChunk, meta?: EncodedVideoChunkMetadata): void {
|
||||
if (!this.muxer) {
|
||||
async addVideoChunk(chunk: EncodedVideoChunk, meta?: EncodedVideoChunkMetadata): Promise<void> {
|
||||
if (!this.videoSource) {
|
||||
throw new Error('Muxer not initialized');
|
||||
}
|
||||
this.muxer.addVideoChunk(chunk, meta);
|
||||
|
||||
// Convert WebCodecs chunk to Mediabunny packet
|
||||
const packet = EncodedPacket.fromEncodedChunk(chunk);
|
||||
|
||||
// Add metadata with the first chunk
|
||||
await this.videoSource.add(packet, meta);
|
||||
}
|
||||
|
||||
addAudioChunk(chunk: EncodedAudioChunk, meta?: EncodedAudioChunkMetadata): void {
|
||||
if (!this.muxer) {
|
||||
throw new Error('Muxer not initialized');
|
||||
}
|
||||
if (!this.hasAudio) {
|
||||
async addAudioChunk(chunk: EncodedAudioChunk, meta?: EncodedAudioChunkMetadata): Promise<void> {
|
||||
if (!this.audioSource) {
|
||||
throw new Error('Audio not configured for this muxer');
|
||||
}
|
||||
this.muxer.addAudioChunk(chunk, meta);
|
||||
|
||||
// Convert WebCodecs chunk to Mediabunny packet
|
||||
const packet = EncodedPacket.fromEncodedChunk(chunk);
|
||||
|
||||
// Add metadata with the first chunk
|
||||
await this.audioSource.add(packet, meta);
|
||||
}
|
||||
|
||||
finalize(): Blob {
|
||||
if (!this.muxer) {
|
||||
async finalize(): Promise<Blob> {
|
||||
if (!this.output || !this.target) {
|
||||
throw new Error('Muxer not initialized');
|
||||
}
|
||||
|
||||
this.muxer.finalize();
|
||||
const buffer = this.muxer.target.buffer;
|
||||
await this.output.finalize();
|
||||
const buffer = this.target.buffer;
|
||||
|
||||
if (!buffer) {
|
||||
throw new Error('Failed to finalize output');
|
||||
}
|
||||
|
||||
return new Blob([buffer], { type: 'video/mp4' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,12 +21,14 @@ export class VideoExporter {
|
||||
private encoder: VideoEncoder | null = null;
|
||||
private muxer: VideoMuxer | null = null;
|
||||
private cancelled = false;
|
||||
private encodedChunks: EncodedVideoChunk[] = [];
|
||||
private encodeQueue = 0;
|
||||
// Increased queue size for better throughput with hardware encoding
|
||||
private readonly MAX_ENCODE_QUEUE = 120;
|
||||
private videoDescription: Uint8Array | undefined;
|
||||
private videoColorSpace: VideoColorSpaceInit | undefined;
|
||||
// Track muxing promises for parallel processing
|
||||
private muxingPromises: Promise<void>[] = [];
|
||||
private chunkCount = 0;
|
||||
|
||||
constructor(config: VideoExporterConfig) {
|
||||
this.config = config;
|
||||
@@ -69,72 +71,67 @@ export class VideoExporter {
|
||||
throw new Error('Video element not available');
|
||||
}
|
||||
|
||||
// Process frames with optimized seeking
|
||||
// Process frames continuously without batching delays
|
||||
const frameDuration = 1_000_000 / this.config.frameRate; // in microseconds
|
||||
let frameIndex = 0;
|
||||
const timeStep = 1 / this.config.frameRate;
|
||||
const BATCH_SIZE = 5; // Process frames in batches for better throughput
|
||||
|
||||
while (frameIndex < totalFrames && !this.cancelled) {
|
||||
// Process a batch of frames
|
||||
const batchEnd = Math.min(frameIndex + BATCH_SIZE, totalFrames);
|
||||
|
||||
for (let i = frameIndex; i < batchEnd && !this.cancelled; i++) {
|
||||
const timestamp = i * frameDuration;
|
||||
const videoTime = i * timeStep;
|
||||
const i = frameIndex;
|
||||
const timestamp = i * frameDuration;
|
||||
const videoTime = i * timeStep;
|
||||
|
||||
// Seek if needed or wait for first frame to be ready
|
||||
const needsSeek = Math.abs(videoElement.currentTime - videoTime) > 0.001;
|
||||
if (needsSeek || i === 0) {
|
||||
if (needsSeek) {
|
||||
videoElement.currentTime = videoTime;
|
||||
}
|
||||
// Wait for video frame to be ready
|
||||
await new Promise<void>(resolve => {
|
||||
videoElement.requestVideoFrameCallback(() => resolve());
|
||||
});
|
||||
// Seek if needed or wait for first frame to be ready
|
||||
const needsSeek = Math.abs(videoElement.currentTime - videoTime) > 0.001;
|
||||
if (needsSeek || i === 0) {
|
||||
if (needsSeek) {
|
||||
videoElement.currentTime = videoTime;
|
||||
}
|
||||
|
||||
// Create a VideoFrame from the video element (on GPU!)
|
||||
const videoFrame = new VideoFrame(videoElement, {
|
||||
timestamp,
|
||||
// Wait for video frame to be ready
|
||||
await new Promise<void>(resolve => {
|
||||
videoElement.requestVideoFrameCallback(() => resolve());
|
||||
});
|
||||
|
||||
// Render the frame with all effects
|
||||
await this.renderer!.renderFrame(videoFrame, timestamp);
|
||||
|
||||
videoFrame.close();
|
||||
|
||||
const canvas = this.renderer!.getCanvas();
|
||||
|
||||
// Create VideoFrame from canvas on GPU without reading pixels
|
||||
// @ts-ignore - colorSpace not in TypeScript definitions but works at runtime
|
||||
const exportFrame = new VideoFrame(canvas, {
|
||||
timestamp,
|
||||
duration: frameDuration,
|
||||
colorSpace: {
|
||||
primaries: 'bt709',
|
||||
transfer: 'iec61966-2-1',
|
||||
matrix: 'rgb',
|
||||
fullRange: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (this.encoder && this.encoder.state === 'configured') {
|
||||
this.encodeQueue++;
|
||||
this.encoder.encode(exportFrame, { keyFrame: i % 150 === 0 });
|
||||
}
|
||||
exportFrame.close();
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
// Wait for encoder queue once per batch
|
||||
videoFrame.close();
|
||||
|
||||
const canvas = this.renderer!.getCanvas();
|
||||
|
||||
// Create VideoFrame from canvas on GPU without reading pixels
|
||||
// @ts-ignore - colorSpace not in TypeScript definitions but works at runtime
|
||||
const exportFrame = new VideoFrame(canvas, {
|
||||
timestamp,
|
||||
duration: frameDuration,
|
||||
colorSpace: {
|
||||
primaries: 'bt709',
|
||||
transfer: 'iec61966-2-1',
|
||||
matrix: 'rgb',
|
||||
fullRange: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Check encoder queue before encoding to keep it full
|
||||
while (this.encodeQueue >= this.MAX_ENCODE_QUEUE && !this.cancelled) {
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
frameIndex = batchEnd;
|
||||
if (this.encoder && this.encoder.state === 'configured') {
|
||||
this.encodeQueue++;
|
||||
this.encoder.encode(exportFrame, { keyFrame: i % 150 === 0 });
|
||||
}
|
||||
exportFrame.close();
|
||||
|
||||
// Batch progress updates to reduce callback overhead
|
||||
frameIndex++;
|
||||
|
||||
// Update progress
|
||||
if (this.config.onProgress) {
|
||||
this.config.onProgress({
|
||||
currentFrame: frameIndex,
|
||||
@@ -154,35 +151,11 @@ export class VideoExporter {
|
||||
await this.encoder.flush();
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Use captured colorSpace from encoder or fallback to default sRGB colorspace
|
||||
const colorSpace = this.videoColorSpace || {
|
||||
primaries: 'bt709',
|
||||
transfer: 'iec61966-2-1',
|
||||
matrix: 'rgb',
|
||||
fullRange: true,
|
||||
};
|
||||
|
||||
meta.decoderConfig = {
|
||||
codec: this.config.codec || 'avc1.640033',
|
||||
codedWidth: this.config.width,
|
||||
codedHeight: this.config.height,
|
||||
description: this.videoDescription,
|
||||
colorSpace,
|
||||
};
|
||||
}
|
||||
|
||||
this.muxer!.addVideoChunk(chunk, meta);
|
||||
}
|
||||
// Wait for all muxing operations to complete
|
||||
await Promise.all(this.muxingPromises);
|
||||
|
||||
// Finalize muxer and get output blob
|
||||
const blob = this.muxer!.finalize();
|
||||
const blob = await this.muxer!.finalize();
|
||||
|
||||
return { success: true, blob };
|
||||
} catch (error) {
|
||||
@@ -197,8 +170,9 @@ export class VideoExporter {
|
||||
}
|
||||
|
||||
private async initializeEncoder(): Promise<void> {
|
||||
this.encodedChunks = [];
|
||||
this.encodeQueue = 0;
|
||||
this.muxingPromises = [];
|
||||
this.chunkCount = 0;
|
||||
let videoDescription: Uint8Array | undefined;
|
||||
|
||||
this.encoder = new VideoEncoder({
|
||||
@@ -213,7 +187,42 @@ export class VideoExporter {
|
||||
if (meta?.decoderConfig?.colorSpace && !this.videoColorSpace) {
|
||||
this.videoColorSpace = meta.decoderConfig.colorSpace;
|
||||
}
|
||||
this.encodedChunks.push(chunk);
|
||||
|
||||
// Stream chunk to muxer immediately (parallel processing)
|
||||
const isFirstChunk = this.chunkCount === 0;
|
||||
this.chunkCount++;
|
||||
|
||||
const muxingPromise = (async () => {
|
||||
try {
|
||||
if (isFirstChunk && this.videoDescription) {
|
||||
// Add decoder config for the first chunk
|
||||
const colorSpace = this.videoColorSpace || {
|
||||
primaries: 'bt709',
|
||||
transfer: 'iec61966-2-1',
|
||||
matrix: 'rgb',
|
||||
fullRange: true,
|
||||
};
|
||||
|
||||
const metadata: EncodedVideoChunkMetadata = {
|
||||
decoderConfig: {
|
||||
codec: this.config.codec || 'avc1.640033',
|
||||
codedWidth: this.config.width,
|
||||
codedHeight: this.config.height,
|
||||
description: this.videoDescription,
|
||||
colorSpace,
|
||||
},
|
||||
};
|
||||
|
||||
await this.muxer!.addVideoChunk(chunk, metadata);
|
||||
} else {
|
||||
await this.muxer!.addVideoChunk(chunk, meta);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Muxing error:', error);
|
||||
}
|
||||
})();
|
||||
|
||||
this.muxingPromises.push(muxingPromise);
|
||||
this.encodeQueue--;
|
||||
},
|
||||
error: (error) => {
|
||||
@@ -271,8 +280,9 @@ export class VideoExporter {
|
||||
}
|
||||
|
||||
this.muxer = null;
|
||||
this.encodedChunks = [];
|
||||
this.encodeQueue = 0;
|
||||
this.muxingPromises = [];
|
||||
this.chunkCount = 0;
|
||||
this.videoDescription = undefined;
|
||||
this.videoColorSpace = undefined;
|
||||
}
|
||||
|
||||
@@ -39,4 +39,25 @@ export default defineConfig({
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
target: 'esnext',
|
||||
minify: 'terser',
|
||||
terserOptions: {
|
||||
compress: {
|
||||
drop_console: true,
|
||||
drop_debugger: true,
|
||||
pure_funcs: ['console.log', 'console.debug']
|
||||
}
|
||||
},
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
'pixi': ['pixi.js'],
|
||||
'react-vendor': ['react', 'react-dom'],
|
||||
'video-processing': ['mediabunny', 'mp4box', '@fix-webm-duration/fix']
|
||||
}
|
||||
}
|
||||
},
|
||||
chunkSizeWarningLimit: 1000
|
||||
}
|
||||
})
|
||||
|
||||