feat(editor): add cursor sway effect

Add a cursor sway control that carries through preview, export, and saved projects, and scale the effect so the editor slider has more usable range.
This commit is contained in:
KBCats
2026-03-15 17:01:44 -07:00
parent 08a93e2187
commit 4b3f057389
14 changed files with 3818 additions and 2459 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+199 -57
View File
@@ -1,11 +1,21 @@
import { ASPECT_RATIOS, type AspectRatio, isCustomAspectRatio } from "@/utils/aspectRatioUtils";
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
import {
ASPECT_RATIOS,
type AspectRatio,
isCustomAspectRatio,
} from "@/utils/aspectRatioUtils";
import type {
ExportFormat,
ExportQuality,
GifFrameRate,
GifSizePreset,
} from "@/lib/exporter";
import { WALLPAPER_PATHS } from "@/lib/wallpapers";
import {
DEFAULT_CURSOR_CLICK_BOUNCE,
DEFAULT_CURSOR_MOTION_BLUR,
DEFAULT_CURSOR_SIZE,
DEFAULT_CURSOR_SMOOTHING,
DEFAULT_CURSOR_SWAY,
DEFAULT_ANNOTATION_POSITION,
DEFAULT_ANNOTATION_SIZE,
DEFAULT_ANNOTATION_STYLE,
@@ -35,6 +45,7 @@ export interface ProjectEditorState {
cursorSmoothing: number;
cursorMotionBlur: number;
cursorClickBounce: number;
cursorSway: number;
borderRadius: number;
padding: number;
cropRegion: CropRegion;
@@ -68,7 +79,10 @@ function isFileUrl(value: string): boolean {
return /^file:\/\//i.test(value);
}
function encodePathSegments(pathname: string, keepWindowsDrive = false): string {
function encodePathSegments(
pathname: string,
keepWindowsDrive = false,
): string {
return pathname
.split("/")
.map((segment, index) => {
@@ -92,11 +106,15 @@ export function toFileUrl(filePath: string): string {
// UNC path: //server/share/...
if (normalized.startsWith("//")) {
const [host, ...pathParts] = normalized.replace(/^\/+/, "").split("/");
const encodedPath = pathParts.map((part) => encodeURIComponent(part)).join("/");
const encodedPath = pathParts
.map((part) => encodeURIComponent(part))
.join("/");
return encodedPath ? `file://${host}/${encodedPath}` : `file://${host}/`;
}
const absolutePath = normalized.startsWith("/") ? normalized : `/${normalized}`;
const absolutePath = normalized.startsWith("/")
? normalized
: `/${normalized}`;
return `file://${encodePathSegments(absolutePath)}`;
}
@@ -142,7 +160,9 @@ export function deriveNextId(prefix: string, ids: string[]): number {
return max + 1;
}
export function validateProjectData(candidate: unknown): candidate is EditorProjectData {
export function validateProjectData(
candidate: unknown,
): candidate is EditorProjectData {
if (!candidate || typeof candidate !== "object") return false;
const project = candidate as Partial<EditorProjectData>;
if (typeof project.version !== "number") return false;
@@ -151,27 +171,49 @@ export function validateProjectData(candidate: unknown): candidate is EditorProj
return true;
}
export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): ProjectEditorState {
export function normalizeProjectEditor(
editor: Partial<ProjectEditorState>,
): ProjectEditorState {
const validAspectRatios = new Set<AspectRatio>(ASPECT_RATIOS);
const legacyMotionBlurEnabled = (editor as Partial<{ motionBlurEnabled: boolean }>).motionBlurEnabled;
const legacyMotionBlurEnabled = (
editor as Partial<{ motionBlurEnabled: boolean }>
).motionBlurEnabled;
const legacyShowBlur = (editor as Partial<{ showBlur: boolean }>).showBlur;
const normalizedZoomMotionBlur = isFiniteNumber((editor as Partial<ProjectEditorState>).zoomMotionBlur)
? clamp((editor as Partial<ProjectEditorState>).zoomMotionBlur as number, 0, 2)
const normalizedZoomMotionBlur = isFiniteNumber(
(editor as Partial<ProjectEditorState>).zoomMotionBlur,
)
? clamp(
(editor as Partial<ProjectEditorState>).zoomMotionBlur as number,
0,
2,
)
: legacyMotionBlurEnabled
? 0.35
: DEFAULT_ZOOM_MOTION_BLUR;
const normalizedBackgroundBlur = isFiniteNumber((editor as Partial<ProjectEditorState>).backgroundBlur)
? clamp((editor as Partial<ProjectEditorState>).backgroundBlur as number, 0, 8)
const normalizedBackgroundBlur = isFiniteNumber(
(editor as Partial<ProjectEditorState>).backgroundBlur,
)
? clamp(
(editor as Partial<ProjectEditorState>).backgroundBlur as number,
0,
8,
)
: legacyShowBlur
? 2
: 0;
const normalizedZoomRegions: ZoomRegion[] = Array.isArray(editor.zoomRegions)
? editor.zoomRegions
.filter((region): region is ZoomRegion => Boolean(region && typeof region.id === "string"))
.filter((region): region is ZoomRegion =>
Boolean(region && typeof region.id === "string"),
)
.map((region) => {
const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0;
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
const rawStart = isFiniteNumber(region.startMs)
? Math.round(region.startMs)
: 0;
const rawEnd = isFiniteNumber(region.endMs)
? Math.round(region.endMs)
: rawStart + 1000;
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
const endMs = Math.max(startMs + 1, rawEnd);
@@ -179,10 +221,20 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
id: region.id,
startMs,
endMs,
depth: [1, 2, 3, 4, 5, 6].includes(region.depth) ? region.depth : DEFAULT_ZOOM_DEPTH,
depth: [1, 2, 3, 4, 5, 6].includes(region.depth)
? region.depth
: DEFAULT_ZOOM_DEPTH,
focus: {
cx: clamp(isFiniteNumber(region.focus?.cx) ? region.focus.cx : 0.5, 0, 1),
cy: clamp(isFiniteNumber(region.focus?.cy) ? region.focus.cy : 0.5, 0, 1),
cx: clamp(
isFiniteNumber(region.focus?.cx) ? region.focus.cx : 0.5,
0,
1,
),
cy: clamp(
isFiniteNumber(region.focus?.cy) ? region.focus.cy : 0.5,
0,
1,
),
},
};
})
@@ -190,10 +242,16 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
const normalizedTrimRegions: TrimRegion[] = Array.isArray(editor.trimRegions)
? editor.trimRegions
.filter((region): region is TrimRegion => Boolean(region && typeof region.id === "string"))
.filter((region): region is TrimRegion =>
Boolean(region && typeof region.id === "string"),
)
.map((region) => {
const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0;
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
const rawStart = isFiniteNumber(region.startMs)
? Math.round(region.startMs)
: 0;
const rawEnd = isFiniteNumber(region.endMs)
? Math.round(region.endMs)
: rawStart + 1000;
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
const endMs = Math.max(startMs + 1, rawEnd);
return {
@@ -204,12 +262,20 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
})
: [];
const normalizedSpeedRegions: SpeedRegion[] = Array.isArray(editor.speedRegions)
const normalizedSpeedRegions: SpeedRegion[] = Array.isArray(
editor.speedRegions,
)
? editor.speedRegions
.filter((region): region is SpeedRegion => Boolean(region && typeof region.id === "string"))
.filter((region): region is SpeedRegion =>
Boolean(region && typeof region.id === "string"),
)
.map((region) => {
const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0;
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
const rawStart = isFiniteNumber(region.startMs)
? Math.round(region.startMs)
: 0;
const rawEnd = isFiniteNumber(region.endMs)
? Math.round(region.endMs)
: rawStart + 1000;
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
const endMs = Math.max(startMs + 1, rawEnd);
@@ -233,12 +299,20 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
})
: [];
const normalizedAnnotationRegions: AnnotationRegion[] = Array.isArray(editor.annotationRegions)
const normalizedAnnotationRegions: AnnotationRegion[] = Array.isArray(
editor.annotationRegions,
)
? editor.annotationRegions
.filter((region): region is AnnotationRegion => Boolean(region && typeof region.id === "string"))
.filter((region): region is AnnotationRegion =>
Boolean(region && typeof region.id === "string"),
)
.map((region, index) => {
const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0;
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
const rawStart = isFiniteNumber(region.startMs)
? Math.round(region.startMs)
: 0;
const rawEnd = isFiniteNumber(region.endMs)
? Math.round(region.endMs)
: rawStart + 1000;
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
const endMs = Math.max(startMs + 1, rawEnd);
@@ -246,37 +320,56 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
id: region.id,
startMs,
endMs,
type: region.type === "image" || region.type === "figure" ? region.type : "text",
type:
region.type === "image" || region.type === "figure"
? region.type
: "text",
content: typeof region.content === "string" ? region.content : "",
textContent: typeof region.textContent === "string" ? region.textContent : undefined,
imageContent: typeof region.imageContent === "string" ? region.imageContent : undefined,
textContent:
typeof region.textContent === "string"
? region.textContent
: undefined,
imageContent:
typeof region.imageContent === "string"
? region.imageContent
: undefined,
position: {
x: clamp(
isFiniteNumber(region.position?.x) ? region.position.x : DEFAULT_ANNOTATION_POSITION.x,
isFiniteNumber(region.position?.x)
? region.position.x
: DEFAULT_ANNOTATION_POSITION.x,
0,
100,
),
y: clamp(
isFiniteNumber(region.position?.y) ? region.position.y : DEFAULT_ANNOTATION_POSITION.y,
isFiniteNumber(region.position?.y)
? region.position.y
: DEFAULT_ANNOTATION_POSITION.y,
0,
100,
),
},
size: {
width: clamp(
isFiniteNumber(region.size?.width) ? region.size.width : DEFAULT_ANNOTATION_SIZE.width,
isFiniteNumber(region.size?.width)
? region.size.width
: DEFAULT_ANNOTATION_SIZE.width,
1,
200,
),
height: clamp(
isFiniteNumber(region.size?.height) ? region.size.height : DEFAULT_ANNOTATION_SIZE.height,
isFiniteNumber(region.size?.height)
? region.size.height
: DEFAULT_ANNOTATION_SIZE.height,
1,
200,
),
},
style: {
...DEFAULT_ANNOTATION_STYLE,
...(region.style && typeof region.style === "object" ? region.style : {}),
...(region.style && typeof region.style === "object"
? region.style
: {}),
},
zIndex: isFiniteNumber(region.zIndex) ? region.zIndex : index + 1,
figureData: region.figureData
@@ -289,9 +382,15 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
})
: [];
const rawCropX = isFiniteNumber(editor.cropRegion?.x) ? editor.cropRegion.x : DEFAULT_CROP_REGION.x;
const rawCropY = isFiniteNumber(editor.cropRegion?.y) ? editor.cropRegion.y : DEFAULT_CROP_REGION.y;
const rawCropWidth = isFiniteNumber(editor.cropRegion?.width) ? editor.cropRegion.width : DEFAULT_CROP_REGION.width;
const rawCropX = isFiniteNumber(editor.cropRegion?.x)
? editor.cropRegion.x
: DEFAULT_CROP_REGION.x;
const rawCropY = isFiniteNumber(editor.cropRegion?.y)
? editor.cropRegion.y
: DEFAULT_CROP_REGION.y;
const rawCropWidth = isFiniteNumber(editor.cropRegion?.width)
? editor.cropRegion.width
: DEFAULT_CROP_REGION.width;
const rawCropHeight = isFiniteNumber(editor.cropRegion?.height)
? editor.cropRegion.height
: DEFAULT_CROP_REGION.height;
@@ -302,25 +401,60 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
const cropHeight = clamp(rawCropHeight, 0.01, 1 - cropY);
return {
wallpaper: typeof editor.wallpaper === "string" ? editor.wallpaper : WALLPAPER_PATHS[0],
shadowIntensity: typeof editor.shadowIntensity === "number" ? editor.shadowIntensity : 0.67,
wallpaper:
typeof editor.wallpaper === "string"
? editor.wallpaper
: WALLPAPER_PATHS[0],
shadowIntensity:
typeof editor.shadowIntensity === "number"
? editor.shadowIntensity
: 0.67,
backgroundBlur: normalizedBackgroundBlur,
zoomMotionBlur: normalizedZoomMotionBlur,
connectZooms: typeof editor.connectZooms === "boolean" ? editor.connectZooms : true,
showCursor: typeof editor.showCursor === "boolean" ? editor.showCursor : true,
loopCursor: typeof editor.loopCursor === "boolean" ? editor.loopCursor : false,
cursorSize: isFiniteNumber(editor.cursorSize) ? clamp(editor.cursorSize, 0.5, 10) : DEFAULT_CURSOR_SIZE,
connectZooms:
typeof editor.connectZooms === "boolean" ? editor.connectZooms : true,
showCursor:
typeof editor.showCursor === "boolean" ? editor.showCursor : true,
loopCursor:
typeof editor.loopCursor === "boolean" ? editor.loopCursor : false,
cursorSize: isFiniteNumber(editor.cursorSize)
? clamp(editor.cursorSize, 0.5, 10)
: DEFAULT_CURSOR_SIZE,
cursorSmoothing: isFiniteNumber(editor.cursorSmoothing)
? clamp(editor.cursorSmoothing, 0, 2)
: DEFAULT_CURSOR_SMOOTHING,
cursorMotionBlur: isFiniteNumber((editor as Partial<ProjectEditorState>).cursorMotionBlur)
? clamp((editor as Partial<ProjectEditorState>).cursorMotionBlur as number, 0, 2)
cursorMotionBlur: isFiniteNumber(
(editor as Partial<ProjectEditorState>).cursorMotionBlur,
)
? clamp(
(editor as Partial<ProjectEditorState>).cursorMotionBlur as number,
0,
2,
)
: DEFAULT_CURSOR_MOTION_BLUR,
cursorClickBounce: isFiniteNumber((editor as Partial<ProjectEditorState>).cursorClickBounce)
? clamp((editor as Partial<ProjectEditorState>).cursorClickBounce as number, 0, 5)
cursorClickBounce: isFiniteNumber(
(editor as Partial<ProjectEditorState>).cursorClickBounce,
)
? clamp(
(editor as Partial<ProjectEditorState>).cursorClickBounce as number,
0,
5,
)
: DEFAULT_CURSOR_CLICK_BOUNCE,
borderRadius: typeof editor.borderRadius === "number" ? editor.borderRadius : 12.5,
padding: isFiniteNumber(editor.padding) ? clamp(editor.padding, 0, 100) : 50,
cursorSway: isFiniteNumber(
(editor as Partial<ProjectEditorState>).cursorSway,
)
? clamp(
(editor as Partial<ProjectEditorState>).cursorSway as number,
0,
2,
)
: DEFAULT_CURSOR_SWAY,
borderRadius:
typeof editor.borderRadius === "number" ? editor.borderRadius : 12.5,
padding: isFiniteNumber(editor.padding)
? clamp(editor.padding, 0, 100)
: 50,
cropRegion: {
x: cropX,
y: cropY,
@@ -333,10 +467,14 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
annotationRegions: normalizedAnnotationRegions,
aspectRatio:
typeof editor.aspectRatio === "string" &&
(validAspectRatios.has(editor.aspectRatio as AspectRatio) || isCustomAspectRatio(editor.aspectRatio))
(validAspectRatios.has(editor.aspectRatio as AspectRatio) ||
isCustomAspectRatio(editor.aspectRatio))
? (editor.aspectRatio as AspectRatio)
: "16:9",
exportQuality: editor.exportQuality === "medium" || editor.exportQuality === "source" ? editor.exportQuality : "good",
exportQuality:
editor.exportQuality === "medium" || editor.exportQuality === "source"
? editor.exportQuality
: "good",
exportFormat: editor.exportFormat === "gif" ? "gif" : "mp4",
gifFrameRate:
editor.gifFrameRate === 15 ||
@@ -347,17 +485,21 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
: 15,
gifLoop: typeof editor.gifLoop === "boolean" ? editor.gifLoop : true,
gifSizePreset:
editor.gifSizePreset === "medium" || editor.gifSizePreset === "large" || editor.gifSizePreset === "original"
editor.gifSizePreset === "medium" ||
editor.gifSizePreset === "large" ||
editor.gifSizePreset === "original"
? editor.gifSizePreset
: "medium",
};
}
export function createProjectData(videoPath: string, editor: ProjectEditorState): EditorProjectData {
export function createProjectData(
videoPath: string,
editor: ProjectEditorState,
): EditorProjectData {
return {
version: PROJECT_VERSION,
videoPath,
editor,
};
}
+51 -26
View File
@@ -17,8 +17,23 @@ export interface CursorTelemetryPoint {
timeMs: number;
cx: number;
cy: number;
interactionType?: 'move' | 'click' | 'double-click' | 'right-click' | 'middle-click' | 'mouseup';
cursorType?: 'arrow' | 'text' | 'pointer' | 'crosshair' | 'open-hand' | 'closed-hand' | 'resize-ew' | 'resize-ns' | 'not-allowed';
interactionType?:
| "move"
| "click"
| "double-click"
| "right-click"
| "middle-click"
| "mouseup";
cursorType?:
| "arrow"
| "text"
| "pointer"
| "crosshair"
| "open-hand"
| "closed-hand"
| "resize-ew"
| "resize-ns"
| "not-allowed";
}
export interface CursorVisualSettings {
@@ -26,12 +41,14 @@ export interface CursorVisualSettings {
smoothing: number;
motionBlur: number;
clickBounce: number;
sway: number;
}
export const DEFAULT_CURSOR_SIZE = 3.0;
export const DEFAULT_CURSOR_SMOOTHING = 0.67;
export const DEFAULT_CURSOR_MOTION_BLUR = 0.35;
export const DEFAULT_CURSOR_CLICK_BOUNCE = 2.5;
export const DEFAULT_CURSOR_SWAY = 0;
export const DEFAULT_ZOOM_MOTION_BLUR = 0.35;
export interface TrimRegion {
@@ -40,9 +57,17 @@ export interface TrimRegion {
endMs: number;
}
export type AnnotationType = 'text' | 'image' | 'figure';
export type AnnotationType = "text" | "image" | "figure";
export type ArrowDirection = 'up' | 'down' | 'left' | 'right' | 'up-right' | 'up-left' | 'down-right' | 'down-left';
export type ArrowDirection =
| "up"
| "down"
| "left"
| "right"
| "up-right"
| "up-left"
| "down-right"
| "down-left";
export interface FigureData {
arrowDirection: ArrowDirection;
@@ -65,18 +90,18 @@ export interface AnnotationTextStyle {
backgroundColor: string;
fontSize: number; // pixels
fontFamily: string;
fontWeight: 'normal' | 'bold';
fontStyle: 'normal' | 'italic';
textDecoration: 'none' | 'underline';
textAlign: 'left' | 'center' | 'right';
fontWeight: "normal" | "bold";
fontStyle: "normal" | "italic";
textDecoration: "none" | "underline";
textAlign: "left" | "center" | "right";
}
function getDefaultAnnotationFontFamily() {
if (typeof navigator !== 'undefined' && /mac/i.test(navigator.platform)) {
if (typeof navigator !== "undefined" && /mac/i.test(navigator.platform)) {
return '"SF Pro Display", "SF Pro Text", -apple-system, BlinkMacSystemFont, sans-serif';
}
return 'Inter, system-ui, sans-serif';
return "Inter, system-ui, sans-serif";
}
export interface AnnotationRegion {
@@ -105,29 +130,27 @@ export const DEFAULT_ANNOTATION_SIZE: AnnotationSize = {
};
export const DEFAULT_ANNOTATION_STYLE: AnnotationTextStyle = {
color: '#ffffff',
backgroundColor: 'transparent',
color: "#ffffff",
backgroundColor: "transparent",
fontSize: 32,
fontFamily: getDefaultAnnotationFontFamily(),
fontWeight: 'bold',
fontStyle: 'normal',
textDecoration: 'none',
textAlign: 'center',
fontWeight: "bold",
fontStyle: "normal",
textDecoration: "none",
textAlign: "center",
};
export const DEFAULT_FIGURE_DATA: FigureData = {
arrowDirection: 'right',
color: '#2563EB',
arrowDirection: "right",
color: "#2563EB",
strokeWidth: 4,
};
export interface CropRegion {
x: number;
y: number;
width: number;
height: number;
x: number;
y: number;
width: number;
height: number;
}
export const DEFAULT_CROP_REGION: CropRegion = {
@@ -169,7 +192,10 @@ export const ZOOM_DEPTH_SCALES: Record<ZoomDepth, number> = {
export const DEFAULT_ZOOM_DEPTH: ZoomDepth = 3;
export function clampFocusToDepth(focus: ZoomFocus, _depth: ZoomDepth): ZoomFocus {
export function clampFocusToDepth(
focus: ZoomFocus,
_depth: ZoomDepth,
): ZoomFocus {
return {
cx: clamp(focus.cx, 0, 1),
cy: clamp(focus.cy, 0, 1),
@@ -180,4 +206,3 @@ function clamp(value: number, min: number, max: number) {
if (Number.isNaN(value)) return (min + max) / 2;
return Math.min(max, Math.max(min, value));
}
@@ -1,10 +1,26 @@
import { Assets, BlurFilter, Container, Graphics, Sprite, Texture } from 'pixi.js';
import { MotionBlurFilter } from 'pixi-filters/motion-blur';
import type { CursorTelemetryPoint } from '../types';
import { createSpringState, getCursorSpringConfig, resetSpringState, stepSpringValue } from './motionSmoothing';
import { uploadedCursorAssets, UPLOADED_CURSOR_SAMPLE_SIZE } from './uploadedCursorAssets';
import {
Assets,
BlurFilter,
Container,
Graphics,
Sprite,
Texture,
} from "pixi.js";
import { MotionBlurFilter } from "pixi-filters/motion-blur";
import type { CursorTelemetryPoint } from "../types";
import {
createSpringState,
getCursorSpringConfig,
resetSpringState,
stepSpringValue,
} from "./motionSmoothing";
import { computeCursorSwayRotation } from "./cursorSway";
import {
uploadedCursorAssets,
UPLOADED_CURSOR_SAMPLE_SIZE,
} from "./uploadedCursorAssets";
type CursorAssetKey = NonNullable<CursorTelemetryPoint['cursorType']>;
type CursorAssetKey = NonNullable<CursorTelemetryPoint["cursorType"]>;
type LoadedCursorAsset = {
texture: Texture;
@@ -39,6 +55,8 @@ export interface CursorRenderConfig {
motionBlur: number;
/** Click bounce multiplier. */
clickBounce: number;
/** Cursor sway multiplier. */
sway: number;
}
export const DEFAULT_CURSOR_CONFIG: CursorRenderConfig = {
@@ -49,6 +67,7 @@ export const DEFAULT_CURSOR_CONFIG: CursorRenderConfig = {
smoothingFactor: 0.18,
motionBlur: 0,
clickBounce: 1,
sway: 0,
};
const REFERENCE_WIDTH = 1920;
@@ -57,7 +76,10 @@ const CLICK_ANIMATION_MS = 140;
const CLICK_RING_FADE_MS = 240;
const CURSOR_MOTION_BLUR_BASE_MULTIPLIER = 0.08;
const CURSOR_TIME_DISCONTINUITY_MS = 100;
const CURSOR_SVG_DROP_SHADOW_FILTER = 'drop-shadow(0px 2px 3px rgba(0, 0, 0, 0.35))';
const CURSOR_SWAY_SMOOTHING_MULTIPLIER = 0.7;
const CURSOR_SWAY_SMOOTHING_OFFSET = 0.18;
const CURSOR_SVG_DROP_SHADOW_FILTER =
"drop-shadow(0px 2px 3px rgba(0, 0, 0, 0.35))";
const CURSOR_SHADOW_COLOR = 0x000000;
const CURSOR_SHADOW_ALPHA = 0.35;
const CURSOR_SHADOW_OFFSET_X = 0;
@@ -68,22 +90,25 @@ const CURSOR_SHADOW_PADDING = 12;
let cursorAssetsPromise: Promise<void> | null = null;
let loadedCursorAssets: Partial<Record<CursorAssetKey, LoadedCursorAsset>> = {};
const SUPPORTED_CURSOR_KEYS: CursorAssetKey[] = [
'arrow',
'text',
'pointer',
'crosshair',
'open-hand',
'closed-hand',
'resize-ew',
'resize-ns',
'not-allowed',
"arrow",
"text",
"pointer",
"crosshair",
"open-hand",
"closed-hand",
"resize-ew",
"resize-ns",
"not-allowed",
];
function loadImage(dataUrl: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error(`Failed to load cursor image: ${dataUrl.slice(0, 128)}`));
image.onerror = () =>
reject(
new Error(`Failed to load cursor image: ${dataUrl.slice(0, 128)}`),
);
image.src = dataUrl;
});
}
@@ -92,7 +117,10 @@ function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function getNormalizedAnchor(systemAsset: SystemCursorAsset | undefined, fallbackAnchor: { x: number; y: number }) {
function getNormalizedAnchor(
systemAsset: SystemCursorAsset | undefined,
fallbackAnchor: { x: number; y: number },
) {
if (!systemAsset || systemAsset.width <= 0 || systemAsset.height <= 0) {
return fallbackAnchor;
}
@@ -120,17 +148,17 @@ async function rasterizeAndCropSvg(
const img = await loadImage(url);
// Draw at full sample size
const srcCanvas = document.createElement('canvas');
const srcCanvas = document.createElement("canvas");
srcCanvas.width = sampleSize;
srcCanvas.height = sampleSize;
const srcCtx = srcCanvas.getContext('2d')!;
const srcCtx = srcCanvas.getContext("2d")!;
srcCtx.drawImage(img, 0, 0, sampleSize, sampleSize);
// Crop to trim bounds
const dstCanvas = document.createElement('canvas');
const dstCanvas = document.createElement("canvas");
dstCanvas.width = trimWidth;
dstCanvas.height = trimHeight;
const dstCtx = dstCanvas.getContext('2d')!;
const dstCtx = dstCanvas.getContext("2d")!;
dstCtx.drawImage(
srcCanvas,
trimX,
@@ -144,7 +172,7 @@ async function rasterizeAndCropSvg(
);
return {
dataUrl: dstCanvas.toDataURL('image/png'),
dataUrl: dstCanvas.toDataURL("image/png"),
width: dstCanvas.width,
height: dstCanvas.height,
};
@@ -161,7 +189,7 @@ function getCursorAsset(key: CursorAssetKey): LoadedCursorAsset {
function getAvailableCursorKeys(): CursorAssetKey[] {
const loadedKeys = Object.keys(loadedCursorAssets) as CursorAssetKey[];
return loadedKeys.length > 0 ? loadedKeys : ['arrow'];
return loadedKeys.length > 0 ? loadedKeys : ["arrow"];
}
export async function preloadCursorAssets() {
@@ -175,7 +203,10 @@ export async function preloadCursorAssets() {
systemCursors = result.cursors;
}
} catch (error) {
console.warn('[CursorRenderer] Failed to fetch system cursor assets:', error);
console.warn(
"[CursorRenderer] Failed to fetch system cursor assets:",
error,
);
}
const entries = await Promise.all(
@@ -217,31 +248,42 @@ export async function preloadCursorAssets() {
const img = await loadImage(finalUrl);
width = img.naturalWidth;
height = img.naturalHeight;
normalizedAnchor = getNormalizedAnchor(systemAsset, { x: 0, y: 0 });
normalizedAnchor = getNormalizedAnchor(systemAsset, {
x: 0,
y: 0,
});
}
await Assets.load(finalUrl);
const image = await loadImage(finalUrl);
const texture = Texture.from(finalUrl);
return [key, {
texture,
image,
aspectRatio: height > 0 ? width / height : 1,
anchorX: normalizedAnchor.x,
anchorY: normalizedAnchor.y,
} satisfies LoadedCursorAsset] as const;
return [
key,
{
texture,
image,
aspectRatio: height > 0 ? width / height : 1,
anchorX: normalizedAnchor.x,
anchorY: normalizedAnchor.y,
} satisfies LoadedCursorAsset,
] as const;
} catch (error) {
console.warn(`[CursorRenderer] Failed to load cursor image for: ${key}`, error);
console.warn(
`[CursorRenderer] Failed to load cursor image for: ${key}`,
error,
);
return null;
}
})
}),
);
loadedCursorAssets = Object.fromEntries(entries.filter(Boolean).map((entry) => entry!)) as Partial<Record<CursorAssetKey, LoadedCursorAsset>>;
loadedCursorAssets = Object.fromEntries(
entries.filter(Boolean).map((entry) => entry!),
) as Partial<Record<CursorAssetKey, LoadedCursorAsset>>;
if (!loadedCursorAssets.arrow) {
throw new Error('Failed to initialize the fallback arrow cursor asset');
throw new Error("Failed to initialize the fallback arrow cursor asset");
}
})();
}
@@ -264,7 +306,10 @@ export function interpolateCursorPosition(
}
if (timeMs >= samples[samples.length - 1].timeMs) {
return { cx: samples[samples.length - 1].cx, cy: samples[samples.length - 1].cy };
return {
cx: samples[samples.length - 1].cx,
cy: samples[samples.length - 1].cy,
};
}
let lo = 0;
@@ -307,17 +352,22 @@ function findLatestSample(samples: CursorTelemetryPoint[], timeMs: number) {
return samples[lo]?.timeMs <= timeMs ? samples[lo] : null;
}
function findLatestInteractionSample(samples: CursorTelemetryPoint[], timeMs: number) {
function findLatestInteractionSample(
samples: CursorTelemetryPoint[],
timeMs: number,
) {
for (let index = samples.length - 1; index >= 0; index -= 1) {
const sample = samples[index];
if (sample.timeMs > timeMs) {
continue;
}
if (sample.interactionType === 'click'
|| sample.interactionType === 'double-click'
|| sample.interactionType === 'right-click'
|| sample.interactionType === 'middle-click') {
if (
sample.interactionType === "click" ||
sample.interactionType === "double-click" ||
sample.interactionType === "right-click" ||
sample.interactionType === "middle-click"
) {
return sample;
}
}
@@ -325,7 +375,10 @@ function findLatestInteractionSample(samples: CursorTelemetryPoint[], timeMs: nu
return null;
}
function findLatestStableCursorType(samples: CursorTelemetryPoint[], timeMs: number) {
function findLatestStableCursorType(
samples: CursorTelemetryPoint[],
timeMs: number,
) {
// Binary search to find position at timeMs, then scan backwards
let lo = 0;
let hi = samples.length - 1;
@@ -350,41 +403,69 @@ function findLatestStableCursorType(samples: CursorTelemetryPoint[], timeMs: num
continue;
}
if (sample.interactionType === 'click'
|| sample.interactionType === 'double-click'
|| sample.interactionType === 'right-click'
|| sample.interactionType === 'middle-click') {
if (
sample.interactionType === "click" ||
sample.interactionType === "double-click" ||
sample.interactionType === "right-click" ||
sample.interactionType === "middle-click"
) {
continue;
}
return sample.cursorType;
}
return findLatestSample(samples, timeMs)?.cursorType ?? 'arrow';
return findLatestSample(samples, timeMs)?.cursorType ?? "arrow";
}
function getCursorViewportScale(viewport: CursorViewportRect) {
return Math.max(MIN_CURSOR_VIEWPORT_SCALE, viewport.width / REFERENCE_WIDTH);
}
function getCursorSwaySpringConfig(smoothingFactor: number) {
const baseConfig = getCursorSpringConfig(
Math.min(
2,
Math.max(
0.15,
smoothingFactor * CURSOR_SWAY_SMOOTHING_MULTIPLIER +
CURSOR_SWAY_SMOOTHING_OFFSET,
),
),
);
return {
...baseConfig,
damping: baseConfig.damping * 0.9,
mass: Math.max(0.55, baseConfig.mass * 0.8),
restDelta: 0.0005,
restSpeed: 0.02,
};
}
function getCursorVisualState(samples: CursorTelemetryPoint[], timeMs: number) {
const latestClick = findLatestInteractionSample(samples, timeMs);
const interactionType = latestClick?.interactionType;
const ageMs = latestClick ? Math.max(0, timeMs - latestClick.timeMs) : Number.POSITIVE_INFINITY;
const isClickEvent = interactionType === 'click'
|| interactionType === 'double-click'
|| interactionType === 'right-click'
|| interactionType === 'middle-click';
const clickBounceProgress = latestClick && isClickEvent && ageMs <= CLICK_ANIMATION_MS
? 1 - ageMs / CLICK_ANIMATION_MS
: 0;
const ageMs = latestClick
? Math.max(0, timeMs - latestClick.timeMs)
: Number.POSITIVE_INFINITY;
const isClickEvent =
interactionType === "click" ||
interactionType === "double-click" ||
interactionType === "right-click" ||
interactionType === "middle-click";
const clickBounceProgress =
latestClick && isClickEvent && ageMs <= CLICK_ANIMATION_MS
? 1 - ageMs / CLICK_ANIMATION_MS
: 0;
return {
cursorType: findLatestStableCursorType(samples, timeMs),
clickBounceProgress,
clickProgress: latestClick && isClickEvent && ageMs <= CLICK_RING_FADE_MS
? 1 - ageMs / CLICK_RING_FADE_MS
: 0,
clickProgress:
latestClick && isClickEvent && ageMs <= CLICK_RING_FADE_MS
? 1 - ageMs / CLICK_RING_FADE_MS
: 0,
};
}
@@ -402,7 +483,9 @@ export class SmoothedCursorState {
private xSpring = createSpringState(0.5);
private ySpring = createSpringState(0.5);
constructor(config: Pick<CursorRenderConfig, 'smoothingFactor' | 'trailLength'>) {
constructor(
config: Pick<CursorRenderConfig, "smoothingFactor" | "trailLength">,
) {
this.smoothingFactor = config.smoothingFactor;
this.trailLength = config.trailLength;
}
@@ -423,7 +506,10 @@ export class SmoothedCursorState {
return;
}
if (this.smoothingFactor <= 0 || (this.lastTimeMs !== null && timeMs < this.lastTimeMs)) {
if (
this.smoothingFactor <= 0 ||
(this.lastTimeMs !== null && timeMs < this.lastTimeMs)
) {
this.snapTo(targetX, targetY, timeMs);
return;
}
@@ -433,7 +519,10 @@ export class SmoothedCursorState {
this.trail.length = this.trailLength;
}
const deltaMs = this.lastTimeMs === null ? 1000 / 60 : Math.max(1, timeMs - this.lastTimeMs);
const deltaMs =
this.lastTimeMs === null
? 1000 / 60
: Math.max(1, timeMs - this.lastTimeMs);
this.lastTimeMs = timeMs;
const springConfig = getCursorSpringConfig(this.smoothingFactor);
@@ -468,7 +557,13 @@ export class SmoothedCursorState {
}
}
function drawClickRing(graphics: Graphics, px: number, py: number, h: number, progress: number) {
function drawClickRing(
graphics: Graphics,
px: number,
py: number,
h: number,
progress: number,
) {
void graphics;
void px;
void py;
@@ -487,13 +582,15 @@ export class PixiCursorOverlay {
private config: CursorRenderConfig;
private lastRenderedPoint: { px: number; py: number } | null = null;
private lastRenderedTimeMs: number | null = null;
private swayRotation = 0;
private swaySpring = createSpringState(0);
constructor(config: Partial<CursorRenderConfig> = {}) {
this.config = { ...DEFAULT_CURSOR_CONFIG, ...config };
this.state = new SmoothedCursorState(this.config);
this.container = new Container();
this.container.label = 'cursor-overlay';
this.container.label = "cursor-overlay";
this.clickRingGraphics = new Graphics();
this.cursorShadowSprites = {};
@@ -542,7 +639,8 @@ export class PixiCursorOverlay {
setMotionBlur(motionBlur: number) {
this.config.motionBlur = Math.max(0, motionBlur);
this.container.filters = this.config.motionBlur > 0 ? [this.cursorMotionBlurFilter] : null;
this.container.filters =
this.config.motionBlur > 0 ? [this.cursorMotionBlurFilter] : null;
if (this.config.motionBlur <= 0) {
this.cursorMotionBlurFilter.velocity = { x: 0, y: 0 };
this.cursorMotionBlurFilter.kernelSize = 5;
@@ -554,6 +652,10 @@ export class PixiCursorOverlay {
this.config.clickBounce = Math.max(0, clickBounce);
}
setSway(sway: number) {
this.config.sway = clamp(sway, 0, 2);
}
update(
samples: CursorTelemetryPoint[],
timeMs: number,
@@ -561,10 +663,17 @@ export class PixiCursorOverlay {
visible: boolean,
freeze = false,
): void {
if (!visible || samples.length === 0 || viewport.width <= 0 || viewport.height <= 0) {
if (
!visible ||
samples.length === 0 ||
viewport.width <= 0 ||
viewport.height <= 0
) {
this.container.visible = false;
this.lastRenderedPoint = null;
this.lastRenderedTimeMs = null;
this.swayRotation = 0;
resetSpringState(this.swaySpring, 0);
this.cursorMotionBlurFilter.velocity = { x: 0, y: 0 };
return;
}
@@ -575,11 +684,15 @@ export class PixiCursorOverlay {
return;
}
const sameFrameTime = this.lastRenderedTimeMs !== null && Math.abs(this.lastRenderedTimeMs - timeMs) < 0.0001;
const hasTimeDiscontinuity = this.lastRenderedTimeMs !== null
&& Math.abs(timeMs - this.lastRenderedTimeMs) > CURSOR_TIME_DISCONTINUITY_MS;
const sameFrameTime =
this.lastRenderedTimeMs !== null &&
Math.abs(this.lastRenderedTimeMs - timeMs) < 0.0001;
const hasTimeDiscontinuity =
this.lastRenderedTimeMs !== null &&
Math.abs(timeMs - this.lastRenderedTimeMs) > CURSOR_TIME_DISCONTINUITY_MS;
const shouldFreezeCursorMotion = freeze || hasTimeDiscontinuity;
if (freeze || hasTimeDiscontinuity) {
if (shouldFreezeCursorMotion) {
if (!sameFrameTime || !this.lastRenderedPoint) {
this.state.snapTo(target.cx, target.cy, timeMs);
}
@@ -591,29 +704,52 @@ export class PixiCursorOverlay {
const px = viewport.x + this.state.x * viewport.width;
const py = viewport.y + this.state.y * viewport.height;
const h = this.config.dotRadius * getCursorViewportScale(viewport);
const { cursorType, clickBounceProgress, clickProgress } = getCursorVisualState(samples, timeMs);
const spriteKey = (cursorType in this.cursorSprites ? cursorType : 'arrow') as CursorAssetKey;
const { cursorType, clickBounceProgress, clickProgress } =
getCursorVisualState(samples, timeMs);
const spriteKey = (
cursorType in this.cursorSprites ? cursorType : "arrow"
) as CursorAssetKey;
const asset = getCursorAsset(spriteKey);
const shadowSprite = this.cursorShadowSprites[spriteKey] ?? this.cursorShadowSprites.arrow!;
const shadowSprite =
this.cursorShadowSprites[spriteKey] ?? this.cursorShadowSprites.arrow!;
const sprite = this.cursorSprites[spriteKey] ?? this.cursorSprites.arrow!;
const bounceScale = Math.max(0.72, 1 - Math.sin(clickBounceProgress * Math.PI) * (0.08 * this.config.clickBounce));
const bounceScale = Math.max(
0.72,
1 -
Math.sin(clickBounceProgress * Math.PI) *
(0.08 * this.config.clickBounce),
);
const scaledH = h;
const swayRotation = this.updateCursorSway(
px,
py,
timeMs,
shouldFreezeCursorMotion,
);
this.clickRingGraphics.clear();
drawClickRing(this.clickRingGraphics, px, py, h, clickProgress);
for (const [key, currentShadowSprite] of Object.entries(this.cursorShadowSprites) as Array<[CursorAssetKey, Sprite]>) {
for (const [key, currentShadowSprite] of Object.entries(
this.cursorShadowSprites,
) as Array<[CursorAssetKey, Sprite]>) {
currentShadowSprite.visible = key === spriteKey;
}
for (const [key, currentSprite] of Object.entries(this.cursorSprites) as Array<[CursorAssetKey, Sprite]>) {
for (const [key, currentSprite] of Object.entries(
this.cursorSprites,
) as Array<[CursorAssetKey, Sprite]>) {
currentSprite.visible = key === spriteKey;
}
if (shadowSprite) {
shadowSprite.height = scaledH * bounceScale;
shadowSprite.width = scaledH * bounceScale * asset.aspectRatio;
shadowSprite.position.set(px + CURSOR_SHADOW_OFFSET_X, py + CURSOR_SHADOW_OFFSET_Y);
shadowSprite.position.set(
px + CURSOR_SHADOW_OFFSET_X,
py + CURSOR_SHADOW_OFFSET_Y,
);
shadowSprite.rotation = swayRotation;
}
if (sprite) {
@@ -621,15 +757,60 @@ export class PixiCursorOverlay {
sprite.height = scaledH * bounceScale;
sprite.width = scaledH * bounceScale * asset.aspectRatio;
sprite.position.set(px, py);
sprite.rotation = swayRotation;
}
this.applyCursorMotionBlur(px, py, timeMs, freeze);
this.applyCursorMotionBlur(px, py, timeMs, shouldFreezeCursorMotion);
this.lastRenderedPoint = { px, py };
this.lastRenderedTimeMs = timeMs;
}
private applyCursorMotionBlur(px: number, py: number, timeMs: number, freeze: boolean) {
if (freeze || this.config.motionBlur <= 0 || !this.lastRenderedPoint || this.lastRenderedTimeMs === null) {
private updateCursorSway(
px: number,
py: number,
timeMs: number,
freeze: boolean,
) {
const deltaMs =
this.lastRenderedTimeMs === null || freeze
? 1000 / 60
: Math.max(1, timeMs - this.lastRenderedTimeMs);
const targetRotation =
!freeze && this.lastRenderedPoint && this.lastRenderedTimeMs !== null
? computeCursorSwayRotation(
px - this.lastRenderedPoint.px,
py - this.lastRenderedPoint.py,
timeMs - this.lastRenderedTimeMs,
this.config.sway,
)
: 0;
this.swayRotation = stepSpringValue(
this.swaySpring,
targetRotation,
deltaMs,
getCursorSwaySpringConfig(this.config.smoothingFactor),
);
if (Math.abs(this.swayRotation) < 0.0001 && targetRotation === 0) {
this.swayRotation = 0;
}
return this.swayRotation;
}
private applyCursorMotionBlur(
px: number,
py: number,
timeMs: number,
freeze: boolean,
) {
if (
freeze ||
this.config.motionBlur <= 0 ||
!this.lastRenderedPoint ||
this.lastRenderedTimeMs === null
) {
this.cursorMotionBlurFilter.velocity = { x: 0, y: 0 };
this.cursorMotionBlurFilter.kernelSize = 5;
this.cursorMotionBlurFilter.offset = 0;
@@ -639,15 +820,20 @@ export class PixiCursorOverlay {
const deltaMs = Math.max(1, timeMs - this.lastRenderedTimeMs);
const dx = px - this.lastRenderedPoint.px;
const dy = py - this.lastRenderedPoint.py;
const velocityScale = (1000 / deltaMs) * this.config.motionBlur * CURSOR_MOTION_BLUR_BASE_MULTIPLIER;
const velocityScale =
(1000 / deltaMs) *
this.config.motionBlur *
CURSOR_MOTION_BLUR_BASE_MULTIPLIER;
const velocity = {
x: dx * velocityScale,
y: dy * velocityScale,
};
const magnitude = Math.hypot(velocity.x, velocity.y);
this.cursorMotionBlurFilter.velocity = magnitude > 0.05 ? velocity : { x: 0, y: 0 };
this.cursorMotionBlurFilter.kernelSize = magnitude > 3 ? 9 : magnitude > 1 ? 7 : 5;
this.cursorMotionBlurFilter.velocity =
magnitude > 0.05 ? velocity : { x: 0, y: 0 };
this.cursorMotionBlurFilter.kernelSize =
magnitude > 3 ? 9 : magnitude > 1 ? 7 : 5;
this.cursorMotionBlurFilter.offset = magnitude > 0.5 ? -0.25 : 0;
}
@@ -665,6 +851,8 @@ export class PixiCursorOverlay {
this.container.visible = false;
this.lastRenderedPoint = null;
this.lastRenderedTimeMs = null;
this.swayRotation = 0;
resetSpringState(this.swaySpring, 0);
this.cursorMotionBlurFilter.velocity = { x: 0, y: 0 };
this.cursorMotionBlurFilter.kernelSize = 5;
this.cursorMotionBlurFilter.offset = 0;
@@ -688,7 +876,8 @@ export function drawCursorOnCanvas(
smoothedState: SmoothedCursorState,
config: CursorRenderConfig = DEFAULT_CURSOR_CONFIG,
): void {
if (samples.length === 0 || viewport.width <= 0 || viewport.height <= 0) return;
if (samples.length === 0 || viewport.width <= 0 || viewport.height <= 0)
return;
const target = interpolateCursorPosition(samples, timeMs);
if (!target) return;
@@ -698,10 +887,18 @@ export function drawCursorOnCanvas(
const px = viewport.x + smoothedState.x * viewport.width;
const py = viewport.y + smoothedState.y * viewport.height;
const h = config.dotRadius * getCursorViewportScale(viewport);
const { cursorType, clickBounceProgress } = getCursorVisualState(samples, timeMs);
const spriteKey = (cursorType && loadedCursorAssets[cursorType] ? cursorType : 'arrow') as CursorAssetKey;
const { cursorType, clickBounceProgress } = getCursorVisualState(
samples,
timeMs,
);
const spriteKey = (
cursorType && loadedCursorAssets[cursorType] ? cursorType : "arrow"
) as CursorAssetKey;
const asset = getCursorAsset(spriteKey);
const bounceScale = Math.max(0.72, 1 - Math.sin(clickBounceProgress * Math.PI) * (0.08 * config.clickBounce));
const bounceScale = Math.max(
0.72,
1 - Math.sin(clickBounceProgress * Math.PI) * (0.08 * config.clickBounce),
);
ctx.save();
ctx.filter = CURSOR_SVG_DROP_SHADOW_FILTER;
@@ -711,8 +908,13 @@ export function drawCursorOnCanvas(
const hotspotX = asset.anchorX * drawWidth;
const hotspotY = asset.anchorY * drawHeight;
ctx.globalAlpha = config.dotAlpha;
ctx.drawImage(asset.image, px - hotspotX, py - hotspotY, drawWidth, drawHeight);
ctx.drawImage(
asset.image,
px - hotspotX,
py - hotspotY,
drawWidth,
drawHeight,
);
ctx.restore();
}
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { computeCursorSwayRotation } from "./cursorSway";
describe("computeCursorSwayRotation", () => {
it("returns zero when sway is disabled or there is no movement", () => {
expect(computeCursorSwayRotation(120, 0, 16, 0)).toBe(0);
expect(computeCursorSwayRotation(0, 0, 16, 1)).toBe(0);
});
it("leans opposite the motion direction", () => {
expect(computeCursorSwayRotation(120, 0, 16, 1)).toBeLessThan(0);
expect(computeCursorSwayRotation(-120, 0, 16, 1)).toBeGreaterThan(0);
expect(computeCursorSwayRotation(0, 120, 16, 1)).toBeLessThan(0);
expect(computeCursorSwayRotation(0, -120, 16, 1)).toBeGreaterThan(0);
});
it("increases with faster movement for the same direction", () => {
const slow = Math.abs(computeCursorSwayRotation(24, 0, 48, 1));
const fast = Math.abs(computeCursorSwayRotation(120, 0, 16, 1));
expect(fast).toBeGreaterThan(slow);
});
it("maps a 2x slider value to a 6x sway intensity", () => {
expect(computeCursorSwayRotation(-140, 0, 100, 2)).toBeCloseTo(
Math.PI / 3,
6,
);
});
});
@@ -0,0 +1,49 @@
import { clampDeltaMs } from "./motionSmoothing";
const CURSOR_SWAY_MAX_ROTATION = Math.PI / 18;
const CURSOR_SWAY_SPEED_REFERENCE = 1400;
const CURSOR_SWAY_VERTICAL_WEIGHT = 0.65;
const CURSOR_SWAY_INTENSITY_SCALE = 3;
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
export function computeCursorSwayRotation(
dx: number,
dy: number,
deltaMs: number,
sway: number,
) {
if (sway <= 0) {
return 0;
}
const distance = Math.hypot(dx, dy);
if (!Number.isFinite(distance) || distance < 0.01) {
return 0;
}
const speedPxPerSecond = distance / (clampDeltaMs(deltaMs) / 1000);
const speedFactor = clamp(
speedPxPerSecond / CURSOR_SWAY_SPEED_REFERENCE,
0,
1,
);
if (speedFactor <= 0) {
return 0;
}
const directionalBias = clamp(
(-dx - dy * CURSOR_SWAY_VERTICAL_WEIGHT) / distance,
-1,
1,
);
return (
directionalBias *
speedFactor *
CURSOR_SWAY_MAX_ROTATION *
sway *
CURSOR_SWAY_INTENSITY_SCALE
);
}
+59 -58
View File
@@ -1,60 +1,61 @@
{
"zoom": {
"level": "Zoom Level",
"selectRegion": "Select a zoom region to adjust",
"deleteZoom": "Delete Zoom"
},
"trim": {
"deleteRegion": "Delete Trim Region"
},
"speed": {
"playbackSpeed": "Playback Speed",
"selectRegion": "Select a speed region to adjust",
"deleteRegion": "Delete Speed Region"
},
"effects": {
"title": "Video Effects",
"showCursor": "Show Cursor",
"loopCursor": "Loop cursor",
"backgroundBlur": "Background Blur",
"zoomMotionBlur": "Zoom Motion Blur",
"connectZooms": "Connect Zooms",
"cursorSize": "Cursor Size",
"cursorSmoothing": "Cursor Smoothing",
"off": "Off",
"cursorMotionBlur": "Cursor Motion Blur",
"cursorClickBounce": "Cursor Click Bounce",
"shadow": "Shadow",
"roundness": "Roundness",
"padding": "Padding"
},
"crop": {
"title": "Crop Video",
"instruction": "Drag on each side to adjust the crop area"
},
"background": {
"title": "Background",
"image": "Image",
"color": "Color",
"gradient": "Gradient",
"uploadCustom": "Upload Custom",
"uploadSuccess": "Custom image uploaded successfully!",
"uploadError": "Please upload a JPG or JPEG image file."
},
"export": {
"mp4": "MP4",
"gif": "GIF",
"quality": {
"low": "Low",
"medium": "Medium",
"high": "High"
},
"loop": "Loop",
"outputDimensions": "Output: {{dimensions}}px",
"loadProject": "Load Project",
"saveProject": "Save Project",
"exportVideo": "Export {{format}}",
"reportBug": "Report Bug",
"starOnGithub": "Star on GitHub"
}
"zoom": {
"level": "Zoom Level",
"selectRegion": "Select a zoom region to adjust",
"deleteZoom": "Delete Zoom"
},
"trim": {
"deleteRegion": "Delete Trim Region"
},
"speed": {
"playbackSpeed": "Playback Speed",
"selectRegion": "Select a speed region to adjust",
"deleteRegion": "Delete Speed Region"
},
"effects": {
"title": "Video Effects",
"showCursor": "Show Cursor",
"loopCursor": "Loop cursor",
"backgroundBlur": "Background Blur",
"zoomMotionBlur": "Zoom Motion Blur",
"connectZooms": "Connect Zooms",
"cursorSize": "Cursor Size",
"cursorSmoothing": "Cursor Smoothing",
"off": "Off",
"cursorMotionBlur": "Cursor Motion Blur",
"cursorClickBounce": "Cursor Click Bounce",
"cursorSway": "Cursor Sway",
"shadow": "Shadow",
"roundness": "Roundness",
"padding": "Padding"
},
"crop": {
"title": "Crop Video",
"instruction": "Drag on each side to adjust the crop area"
},
"background": {
"title": "Background",
"image": "Image",
"color": "Color",
"gradient": "Gradient",
"uploadCustom": "Upload Custom",
"uploadSuccess": "Custom image uploaded successfully!",
"uploadError": "Please upload a JPG or JPEG image file."
},
"export": {
"mp4": "MP4",
"gif": "GIF",
"quality": {
"low": "Low",
"medium": "Medium",
"high": "High"
},
"loop": "Loop",
"outputDimensions": "Output: {{dimensions}}px",
"loadProject": "Load Project",
"saveProject": "Save Project",
"exportVideo": "Export {{format}}",
"reportBug": "Report Bug",
"starOnGithub": "Star on GitHub"
}
}
+59 -58
View File
@@ -1,60 +1,61 @@
{
"zoom": {
"level": "Nivel de zoom",
"selectRegion": "Selecciona una región de zoom para ajustar",
"deleteZoom": "Eliminar zoom"
},
"trim": {
"deleteRegion": "Eliminar región de recorte"
},
"speed": {
"playbackSpeed": "Velocidad de reproducción",
"selectRegion": "Selecciona una región de velocidad para ajustar",
"deleteRegion": "Eliminar región de velocidad"
},
"effects": {
"title": "Efectos de video",
"showCursor": "Mostrar cursor",
"loopCursor": "Cursor en bucle",
"backgroundBlur": "Desenfoque de fondo",
"zoomMotionBlur": "Desenfoque de movimiento del zoom",
"connectZooms": "Conectar zooms",
"cursorSize": "Tamaño del cursor",
"cursorSmoothing": "Suavizado del cursor",
"off": "Desactivado",
"cursorMotionBlur": "Desenfoque de movimiento del cursor",
"cursorClickBounce": "Rebote de clic del cursor",
"shadow": "Sombra",
"roundness": "Redondez",
"padding": "Relleno"
},
"crop": {
"title": "Recortar video",
"instruction": "Arrastra cada lado para ajustar el área de recorte"
},
"background": {
"title": "Fondo",
"image": "Imagen",
"color": "Color",
"gradient": "Degradado",
"uploadCustom": "Subir personalizado",
"uploadSuccess": "¡Imagen personalizada subida exitosamente!",
"uploadError": "Por favor sube un archivo de imagen JPG o JPEG."
},
"export": {
"mp4": "MP4",
"gif": "GIF",
"quality": {
"low": "Baja",
"medium": "Media",
"high": "Alta"
},
"loop": "Bucle",
"outputDimensions": "Salida: {{dimensions}}px",
"loadProject": "Cargar proyecto",
"saveProject": "Guardar proyecto",
"exportVideo": "Exportar {{format}}",
"reportBug": "Reportar error",
"starOnGithub": "Estrella en GitHub"
}
"zoom": {
"level": "Nivel de zoom",
"selectRegion": "Selecciona una región de zoom para ajustar",
"deleteZoom": "Eliminar zoom"
},
"trim": {
"deleteRegion": "Eliminar región de recorte"
},
"speed": {
"playbackSpeed": "Velocidad de reproducción",
"selectRegion": "Selecciona una región de velocidad para ajustar",
"deleteRegion": "Eliminar región de velocidad"
},
"effects": {
"title": "Efectos de video",
"showCursor": "Mostrar cursor",
"loopCursor": "Cursor en bucle",
"backgroundBlur": "Desenfoque de fondo",
"zoomMotionBlur": "Desenfoque de movimiento del zoom",
"connectZooms": "Conectar zooms",
"cursorSize": "Tamaño del cursor",
"cursorSmoothing": "Suavizado del cursor",
"off": "Desactivado",
"cursorMotionBlur": "Desenfoque de movimiento del cursor",
"cursorClickBounce": "Rebote de clic del cursor",
"cursorSway": "Balanceo del cursor",
"shadow": "Sombra",
"roundness": "Redondez",
"padding": "Relleno"
},
"crop": {
"title": "Recortar video",
"instruction": "Arrastra cada lado para ajustar el área de recorte"
},
"background": {
"title": "Fondo",
"image": "Imagen",
"color": "Color",
"gradient": "Degradado",
"uploadCustom": "Subir personalizado",
"uploadSuccess": "¡Imagen personalizada subida exitosamente!",
"uploadError": "Por favor sube un archivo de imagen JPG o JPEG."
},
"export": {
"mp4": "MP4",
"gif": "GIF",
"quality": {
"low": "Baja",
"medium": "Media",
"high": "Alta"
},
"loop": "Bucle",
"outputDimensions": "Salida: {{dimensions}}px",
"loadProject": "Cargar proyecto",
"saveProject": "Guardar proyecto",
"exportVideo": "Exportar {{format}}",
"reportBug": "Reportar error",
"starOnGithub": "Estrella en GitHub"
}
}
+59 -58
View File
@@ -1,60 +1,61 @@
{
"zoom": {
"level": "缩放级别",
"selectRegion": "选择缩放区域以调整",
"deleteZoom": "删除缩放"
},
"trim": {
"deleteRegion": "删除修剪区域"
},
"speed": {
"playbackSpeed": "播放速度",
"selectRegion": "选择变速区域以调整",
"deleteRegion": "删除变速区域"
},
"effects": {
"title": "视频效果",
"showCursor": "显示光标",
"loopCursor": "循环光标",
"backgroundBlur": "背景模糊",
"zoomMotionBlur": "缩放运动模糊",
"connectZooms": "连接缩放",
"cursorSize": "光标大小",
"cursorSmoothing": "光标平滑",
"off": "关",
"cursorMotionBlur": "光标运动模糊",
"cursorClickBounce": "光标点击弹跳",
"shadow": "阴影",
"roundness": "圆角",
"padding": "内边距"
},
"crop": {
"title": "裁剪视频",
"instruction": "拖动各边以调整裁剪区域"
},
"background": {
"title": "背景",
"image": "图片",
"color": "颜色",
"gradient": "渐变",
"uploadCustom": "上传自定义",
"uploadSuccess": "自定义图片上传成功!",
"uploadError": "请上传 JPG 或 JPEG 图片文件。"
},
"export": {
"mp4": "MP4",
"gif": "GIF",
"quality": {
"low": "低",
"medium": "中",
"high": "高"
},
"loop": "循环",
"outputDimensions": "输出:{{dimensions}}px",
"loadProject": "加载项目",
"saveProject": "保存项目",
"exportVideo": "导出{{format}}",
"reportBug": "报告问题",
"starOnGithub": "在 GitHub 上加星"
}
"zoom": {
"level": "缩放级别",
"selectRegion": "选择缩放区域以调整",
"deleteZoom": "删除缩放"
},
"trim": {
"deleteRegion": "删除修剪区域"
},
"speed": {
"playbackSpeed": "播放速度",
"selectRegion": "选择变速区域以调整",
"deleteRegion": "删除变速区域"
},
"effects": {
"title": "视频效果",
"showCursor": "显示光标",
"loopCursor": "循环光标",
"backgroundBlur": "背景模糊",
"zoomMotionBlur": "缩放运动模糊",
"connectZooms": "连接缩放",
"cursorSize": "光标大小",
"cursorSmoothing": "光标平滑",
"off": "关",
"cursorMotionBlur": "光标运动模糊",
"cursorClickBounce": "光标点击弹跳",
"cursorSway": "光标摆动",
"shadow": "阴影",
"roundness": "圆角",
"padding": "内边距"
},
"crop": {
"title": "裁剪视频",
"instruction": "拖动各边以调整裁剪区域"
},
"background": {
"title": "背景",
"image": "图片",
"color": "颜色",
"gradient": "渐变",
"uploadCustom": "上传自定义",
"uploadSuccess": "自定义图片上传成功!",
"uploadError": "请上传 JPG 或 JPEG 图片文件。"
},
"export": {
"mp4": "MP4",
"gif": "GIF",
"quality": {
"low": "低",
"medium": "中",
"high": "高"
},
"loop": "循环",
"outputDimensions": "输出:{{dimensions}}px",
"loadProject": "加载项目",
"saveProject": "保存项目",
"exportVideo": "导出{{format}}",
"reportBug": "报告问题",
"starOnGithub": "在 GitHub 上加星"
}
}
+235 -112
View File
@@ -1,13 +1,40 @@
import { Application, Container, Sprite, Graphics, BlurFilter, Texture } from 'pixi.js';
import { MotionBlurFilter } from 'pixi-filters/motion-blur';
import type { ZoomRegion, CropRegion, AnnotationRegion, SpeedRegion, CursorTelemetryPoint } from '@/components/video-editor/types';
import { ZOOM_DEPTH_SCALES } from '@/components/video-editor/types';
import { getAssetPath, getRenderableAssetUrl } from '@/lib/assetPath';
import { findDominantRegion } from '@/components/video-editor/videoPlayback/zoomRegionUtils';
import { applyZoomTransform, computeFocusFromTransform, computeZoomTransform, createMotionBlurState, type MotionBlurState } from '@/components/video-editor/videoPlayback/zoomTransform';
import { DEFAULT_FOCUS, ZOOM_SCALE_DEADZONE, ZOOM_TRANSLATION_DEADZONE_PX } from '@/components/video-editor/videoPlayback/constants';
import { renderAnnotations } from './annotationRenderer';
import { PixiCursorOverlay, DEFAULT_CURSOR_CONFIG, preloadCursorAssets } from '@/components/video-editor/videoPlayback/cursorRenderer';
import {
Application,
Container,
Sprite,
Graphics,
BlurFilter,
Texture,
} from "pixi.js";
import { MotionBlurFilter } from "pixi-filters/motion-blur";
import type {
ZoomRegion,
CropRegion,
AnnotationRegion,
SpeedRegion,
CursorTelemetryPoint,
} from "@/components/video-editor/types";
import { ZOOM_DEPTH_SCALES } from "@/components/video-editor/types";
import { getAssetPath, getRenderableAssetUrl } from "@/lib/assetPath";
import { findDominantRegion } from "@/components/video-editor/videoPlayback/zoomRegionUtils";
import {
applyZoomTransform,
computeFocusFromTransform,
computeZoomTransform,
createMotionBlurState,
type MotionBlurState,
} from "@/components/video-editor/videoPlayback/zoomTransform";
import {
DEFAULT_FOCUS,
ZOOM_SCALE_DEADZONE,
ZOOM_TRANSLATION_DEADZONE_PX,
} from "@/components/video-editor/videoPlayback/constants";
import { renderAnnotations } from "./annotationRenderer";
import {
PixiCursorOverlay,
DEFAULT_CURSOR_CONFIG,
preloadCursorAssets,
} from "@/components/video-editor/videoPlayback/cursorRenderer";
interface FrameRenderConfig {
width: number;
@@ -34,6 +61,7 @@ interface FrameRenderConfig {
cursorSmoothing?: number;
cursorMotionBlur?: number;
cursorClickBounce?: number;
cursorSway?: number;
}
interface AnimationState {
@@ -94,23 +122,29 @@ export class FrameRenderer {
await preloadCursorAssets();
} catch (error) {
cursorOverlayEnabled = false;
console.warn('[FrameRenderer] Native cursor assets are unavailable; continuing export without cursor overlay.', error);
console.warn(
"[FrameRenderer] Native cursor assets are unavailable; continuing export without cursor overlay.",
error,
);
}
// Create canvas for rendering
const canvas = document.createElement('canvas');
const canvas = document.createElement("canvas");
canvas.width = this.config.width;
canvas.height = this.config.height;
// Try to set colorSpace if supported (may not be available on all platforms)
try {
if (canvas && 'colorSpace' in canvas) {
if (canvas && "colorSpace" in canvas) {
// @ts-ignore
canvas.colorSpace = 'srgb';
canvas.colorSpace = "srgb";
}
} catch (error) {
// Silently ignore colorSpace errors on platforms that don't support it
console.warn('[FrameRenderer] colorSpace not supported on this platform:', error);
console.warn(
"[FrameRenderer] colorSpace not supported on this platform:",
error,
);
}
// Initialize PixiJS with optimized settings for export performance
@@ -135,10 +169,14 @@ export class FrameRenderer {
if (cursorOverlayEnabled) {
this.cursorOverlay = new PixiCursorOverlay({
dotRadius: DEFAULT_CURSOR_CONFIG.dotRadius * (this.config.cursorSize ?? 1.4),
smoothingFactor: this.config.cursorSmoothing ?? DEFAULT_CURSOR_CONFIG.smoothingFactor,
dotRadius:
DEFAULT_CURSOR_CONFIG.dotRadius * (this.config.cursorSize ?? 1.4),
smoothingFactor:
this.config.cursorSmoothing ?? DEFAULT_CURSOR_CONFIG.smoothingFactor,
motionBlur: this.config.cursorMotionBlur ?? 0,
clickBounce: this.config.cursorClickBounce ?? DEFAULT_CURSOR_CONFIG.clickBounce,
clickBounce:
this.config.cursorClickBounce ?? DEFAULT_CURSOR_CONFIG.clickBounce,
sway: this.config.cursorSway ?? DEFAULT_CURSOR_CONFIG.sway,
});
}
@@ -154,24 +192,28 @@ export class FrameRenderer {
this.videoContainer.filters = [this.blurFilter, this.motionBlurFilter];
// Setup composite canvas for final output with shadows
this.compositeCanvas = document.createElement('canvas');
this.compositeCanvas = document.createElement("canvas");
this.compositeCanvas.width = this.config.width;
this.compositeCanvas.height = this.config.height;
this.compositeCtx = this.compositeCanvas.getContext('2d', { willReadFrequently: false });
this.compositeCtx = this.compositeCanvas.getContext("2d", {
willReadFrequently: false,
});
if (!this.compositeCtx) {
throw new Error('Failed to get 2D context for composite canvas');
throw new Error("Failed to get 2D context for composite canvas");
}
// Setup shadow canvas if needed
if (this.config.showShadow) {
this.shadowCanvas = document.createElement('canvas');
this.shadowCanvas = document.createElement("canvas");
this.shadowCanvas.width = this.config.width;
this.shadowCanvas.height = this.config.height;
this.shadowCtx = this.shadowCanvas.getContext('2d', { willReadFrequently: false });
this.shadowCtx = this.shadowCanvas.getContext("2d", {
willReadFrequently: false,
});
if (!this.shadowCtx) {
throw new Error('Failed to get 2D context for shadow canvas');
throw new Error("Failed to get 2D context for shadow canvas");
}
}
@@ -185,44 +227,55 @@ export class FrameRenderer {
}
private async setupBackground(): Promise<void> {
const wallpaper = await this.resolveWallpaperForExport(this.config.wallpaper);
const wallpaper = await this.resolveWallpaperForExport(
this.config.wallpaper,
);
// Create background canvas for separate rendering (not affected by zoom)
const bgCanvas = document.createElement('canvas');
const bgCanvas = document.createElement("canvas");
bgCanvas.width = this.config.width;
bgCanvas.height = this.config.height;
const bgCtx = bgCanvas.getContext('2d')!;
const bgCtx = bgCanvas.getContext("2d")!;
try {
// Render background based on type
if (wallpaper.startsWith('file://') || wallpaper.startsWith('data:') || wallpaper.startsWith('/') || wallpaper.startsWith('http')) {
if (
wallpaper.startsWith("file://") ||
wallpaper.startsWith("data:") ||
wallpaper.startsWith("/") ||
wallpaper.startsWith("http")
) {
// Image background
const img = new Image();
const imageUrl = await this.resolveWallpaperImageUrl(wallpaper);
// Don't set crossOrigin for same-origin images to avoid CORS taint.
if (
imageUrl.startsWith('http')
&& window.location.origin
&& !imageUrl.startsWith(window.location.origin)
imageUrl.startsWith("http") &&
window.location.origin &&
!imageUrl.startsWith(window.location.origin)
) {
img.crossOrigin = 'anonymous';
img.crossOrigin = "anonymous";
}
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = (err) => {
console.error('[FrameRenderer] Failed to load background image:', imageUrl, err);
console.error(
"[FrameRenderer] Failed to load background image:",
imageUrl,
err,
);
reject(new Error(`Failed to load background image: ${imageUrl}`));
};
img.src = imageUrl;
});
// Draw the image using cover and center positioning
const imgAspect = img.width / img.height;
const canvasAspect = this.config.width / this.config.height;
let drawWidth, drawHeight, drawX, drawY;
if (imgAspect > canvasAspect) {
drawHeight = this.config.height;
drawWidth = drawHeight * imgAspect;
@@ -234,26 +287,32 @@ export class FrameRenderer {
drawX = 0;
drawY = (this.config.height - drawHeight) / 2;
}
bgCtx.drawImage(img, drawX, drawY, drawWidth, drawHeight);
} else if (wallpaper.startsWith('#')) {
} else if (wallpaper.startsWith("#")) {
bgCtx.fillStyle = wallpaper;
bgCtx.fillRect(0, 0, this.config.width, this.config.height);
} else if (wallpaper.startsWith('linear-gradient') || wallpaper.startsWith('radial-gradient')) {
const gradientMatch = wallpaper.match(/(linear|radial)-gradient\((.+)\)/);
} else if (
wallpaper.startsWith("linear-gradient") ||
wallpaper.startsWith("radial-gradient")
) {
const gradientMatch = wallpaper.match(
/(linear|radial)-gradient\((.+)\)/,
);
if (gradientMatch) {
const [, type, params] = gradientMatch;
const parts = params.split(',').map(s => s.trim());
const parts = params.split(",").map((s) => s.trim());
let gradient: CanvasGradient;
if (type === 'linear') {
if (type === "linear") {
gradient = bgCtx.createLinearGradient(0, 0, 0, this.config.height);
parts.forEach((part, index) => {
if (part.startsWith('to ') || part.includes('deg')) return;
const colorMatch = part.match(/^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|[a-z]+)/);
if (part.startsWith("to ") || part.includes("deg")) return;
const colorMatch = part.match(
/^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|[a-z]+)/,
);
if (colorMatch) {
const color = colorMatch[1];
const position = index / (parts.length - 1);
@@ -265,9 +324,11 @@ export class FrameRenderer {
const cy = this.config.height / 2;
const radius = Math.max(this.config.width, this.config.height) / 2;
gradient = bgCtx.createRadialGradient(cx, cy, 0, cx, cy, radius);
parts.forEach((part, index) => {
const colorMatch = part.match(/^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|[a-z]+)/);
const colorMatch = part.match(
/^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|[a-z]+)/,
);
if (colorMatch) {
const color = colorMatch[1];
const position = index / (parts.length - 1);
@@ -275,12 +336,14 @@ export class FrameRenderer {
}
});
}
bgCtx.fillStyle = gradient;
bgCtx.fillRect(0, 0, this.config.width, this.config.height);
} else {
console.warn('[FrameRenderer] Could not parse gradient, using black fallback');
bgCtx.fillStyle = '#000000';
console.warn(
"[FrameRenderer] Could not parse gradient, using black fallback",
);
bgCtx.fillStyle = "#000000";
bgCtx.fillRect(0, 0, this.config.width, this.config.height);
}
} else {
@@ -288,8 +351,11 @@ export class FrameRenderer {
bgCtx.fillRect(0, 0, this.config.width, this.config.height);
}
} catch (error) {
console.error('[FrameRenderer] Error setting up background, using fallback:', error);
bgCtx.fillStyle = '#000000';
console.error(
"[FrameRenderer] Error setting up background, using fallback:",
error,
);
bgCtx.fillStyle = "#000000";
bgCtx.fillRect(0, 0, this.config.width, this.config.height);
}
@@ -299,15 +365,18 @@ export class FrameRenderer {
private async resolveWallpaperImageUrl(wallpaper: string): Promise<string> {
if (
wallpaper.startsWith('file://')
|| wallpaper.startsWith('data:')
|| wallpaper.startsWith('http')
wallpaper.startsWith("file://") ||
wallpaper.startsWith("data:") ||
wallpaper.startsWith("http")
) {
return wallpaper;
}
const resolved = await getAssetPath(wallpaper.replace(/^\/+/, ''));
if (resolved.startsWith('/') && window.location.protocol.startsWith('http')) {
const resolved = await getAssetPath(wallpaper.replace(/^\/+/, ""));
if (
resolved.startsWith("/") &&
window.location.protocol.startsWith("http")
) {
return `${window.location.origin}${resolved}`;
}
@@ -319,14 +388,19 @@ export class FrameRenderer {
return wallpaper;
}
if (wallpaper.startsWith('#') || wallpaper.startsWith('linear-gradient') || wallpaper.startsWith('radial-gradient')) {
if (
wallpaper.startsWith("#") ||
wallpaper.startsWith("linear-gradient") ||
wallpaper.startsWith("radial-gradient")
) {
return wallpaper;
}
const looksLikeAbsoluteFilePath = wallpaper.startsWith('/')
&& !wallpaper.startsWith('//')
&& !wallpaper.startsWith('/wallpapers/')
&& !wallpaper.startsWith('/app-icons/');
const looksLikeAbsoluteFilePath =
wallpaper.startsWith("/") &&
!wallpaper.startsWith("//") &&
!wallpaper.startsWith("/wallpapers/") &&
!wallpaper.startsWith("/app-icons/");
const wallpaperAsset = looksLikeAbsoluteFilePath
? `file://${encodeURI(wallpaper)}`
@@ -337,7 +411,7 @@ export class FrameRenderer {
async renderFrame(videoFrame: VideoFrame, timestamp: number): Promise<void> {
if (!this.app || !this.videoContainer || !this.cameraContainer) {
throw new Error('Renderer not initialized');
throw new Error("Renderer not initialized");
}
this.currentVideoTime = timestamp / 1000000;
@@ -377,13 +451,13 @@ export class FrameRenderer {
}
const TICKS_PER_FRAME = 1;
let maxMotionIntensity = 0;
for (let i = 0; i < TICKS_PER_FRAME; i++) {
const motionIntensity = this.updateAnimationState(timeMs);
maxMotionIntensity = Math.max(maxMotionIntensity, motionIntensity);
}
// Apply transform once with maximum motion intensity from all ticks
applyZoomTransform({
cameraContainer: this.cameraContainer,
@@ -415,7 +489,11 @@ export class FrameRenderer {
this.compositeWithShadows();
// Render annotations on top if present
if (this.config.annotationRegions && this.config.annotationRegions.length > 0 && this.compositeCtx) {
if (
this.config.annotationRegions &&
this.config.annotationRegions.length > 0 &&
this.compositeCtx
) {
// Calculate scale factor based on export vs preview dimensions
const previewWidth = this.config.previewWidth || 1920;
const previewHeight = this.config.previewHeight || 1080;
@@ -429,14 +507,19 @@ export class FrameRenderer {
this.config.width,
this.config.height,
timeMs,
scaleFactor
scaleFactor,
);
}
}
private updateLayout(): void {
if (!this.app || !this.videoSprite || !this.maskGraphics || !this.videoContainer) return;
if (
!this.app ||
!this.videoSprite ||
!this.maskGraphics ||
!this.videoContainer
)
return;
const { width, height } = this.config;
const { cropRegion, borderRadius = 0, padding = 0 } = this.config;
@@ -451,13 +534,16 @@ export class FrameRenderer {
const croppedVideoWidth = videoWidth * (cropEndX - cropStartX);
const croppedVideoHeight = videoHeight * (cropEndY - cropStartY);
// Calculate scale to fit in viewport
// Padding is a percentage (0-100), where 50% ~ 0.8 scale
const paddingScale = 1.0 - (padding / 100) * 0.4;
const viewportWidth = width * paddingScale;
const viewportHeight = height * paddingScale;
const scale = Math.min(viewportWidth / croppedVideoWidth, viewportHeight / croppedVideoHeight);
const scale = Math.min(
viewportWidth / croppedVideoWidth,
viewportHeight / croppedVideoHeight,
);
this.videoSprite.scale.set(scale);
@@ -468,8 +554,8 @@ export class FrameRenderer {
const centerOffsetX = (width - croppedDisplayWidth) / 2;
const centerOffsetY = (height - croppedDisplayHeight) / 2;
const spriteX = centerOffsetX - (cropRegion.x * fullVideoDisplayWidth);
const spriteY = centerOffsetY - (cropRegion.y * fullVideoDisplayHeight);
const spriteX = centerOffsetX - cropRegion.x * fullVideoDisplayWidth;
const spriteY = centerOffsetY - cropRegion.y * fullVideoDisplayHeight;
this.videoSprite.position.set(spriteX, spriteY);
this.videoContainer.position.set(0, 0);
@@ -477,11 +563,20 @@ export class FrameRenderer {
// scale border radius by export/preview canvas ratio
const previewWidth = this.config.previewWidth || 1920;
const previewHeight = this.config.previewHeight || 1080;
const canvasScaleFactor = Math.min(width / previewWidth, height / previewHeight);
const canvasScaleFactor = Math.min(
width / previewWidth,
height / previewHeight,
);
const scaledBorderRadius = borderRadius * canvasScaleFactor;
this.maskGraphics.clear();
this.maskGraphics.roundRect(centerOffsetX, centerOffsetY, croppedDisplayWidth, croppedDisplayHeight, scaledBorderRadius);
this.maskGraphics.roundRect(
centerOffsetX,
centerOffsetY,
croppedDisplayWidth,
croppedDisplayHeight,
scaledBorderRadius,
);
this.maskGraphics.fill({ color: 0xffffff });
// Cache layout info
@@ -490,17 +585,26 @@ export class FrameRenderer {
videoSize: { width: croppedVideoWidth, height: croppedVideoHeight },
baseScale: scale,
baseOffset: { x: spriteX, y: spriteY },
maskRect: { x: centerOffsetX, y: centerOffsetY, width: croppedDisplayWidth, height: croppedDisplayHeight },
maskRect: {
x: centerOffsetX,
y: centerOffsetY,
width: croppedDisplayWidth,
height: croppedDisplayHeight,
},
};
}
private updateAnimationState(timeMs: number): number {
if (!this.cameraContainer || !this.layoutCache) return 0;
const { region, strength, blendedScale, transition } = findDominantRegion(this.config.zoomRegions, timeMs, {
connectZooms: this.config.connectZooms,
});
const { region, strength, blendedScale, transition } = findDominantRegion(
this.config.zoomRegions,
timeMs,
{
connectZooms: this.config.connectZooms,
},
);
const defaultFocus = DEFAULT_FOCUS;
let targetScaleFactor = 1;
let targetFocus = { ...defaultFocus };
@@ -509,7 +613,7 @@ export class FrameRenderer {
if (region && strength > 0) {
const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth];
const regionFocus = region.focus;
targetScaleFactor = zoomScale;
targetFocus = regionFocus;
targetProgress = strength;
@@ -533,9 +637,15 @@ export class FrameRenderer {
});
const interpolatedTransform = {
scale: startTransform.scale + (endTransform.scale - startTransform.scale) * transition.progress,
x: startTransform.x + (endTransform.x - startTransform.x) * transition.progress,
y: startTransform.y + (endTransform.y - startTransform.y) * transition.progress,
scale:
startTransform.scale +
(endTransform.scale - startTransform.scale) * transition.progress,
x:
startTransform.x +
(endTransform.x - startTransform.x) * transition.progress,
y:
startTransform.y +
(endTransform.y - startTransform.y) * transition.progress,
};
targetScaleFactor = interpolatedTransform.scale;
@@ -570,15 +680,18 @@ export class FrameRenderer {
focusY: state.focusY,
});
state.appliedScale = Math.abs(projectedTransform.scale - prevScale) < ZOOM_SCALE_DEADZONE
? projectedTransform.scale
: projectedTransform.scale;
state.x = Math.abs(projectedTransform.x - prevX) < ZOOM_TRANSLATION_DEADZONE_PX
? projectedTransform.x
: projectedTransform.x;
state.y = Math.abs(projectedTransform.y - prevY) < ZOOM_TRANSLATION_DEADZONE_PX
? projectedTransform.y
: projectedTransform.y;
state.appliedScale =
Math.abs(projectedTransform.scale - prevScale) < ZOOM_SCALE_DEADZONE
? projectedTransform.scale
: projectedTransform.scale;
state.x =
Math.abs(projectedTransform.x - prevX) < ZOOM_TRANSLATION_DEADZONE_PX
? projectedTransform.x
: projectedTransform.x;
state.y =
Math.abs(projectedTransform.y - prevY) < ZOOM_TRANSLATION_DEADZONE_PX
? projectedTransform.y
: projectedTransform.y;
this.lastMotionVector = {
x: state.x - prevX,
@@ -588,7 +701,8 @@ export class FrameRenderer {
return Math.max(
Math.abs(state.appliedScale - prevScale),
Math.abs(state.x - prevX) / Math.max(1, this.layoutCache.stageSize.width),
Math.abs(state.y - prevY) / Math.max(1, this.layoutCache.stageSize.height)
Math.abs(state.y - prevY) /
Math.max(1, this.layoutCache.stageSize.height),
);
}
@@ -606,7 +720,7 @@ export class FrameRenderer {
// Step 1: Draw background layer (with optional blur, not affected by zoom)
if (this.backgroundSprite) {
const bgCanvas = this.backgroundSprite as any as HTMLCanvasElement;
if (this.config.backgroundBlur > 0) {
ctx.save();
ctx.filter = `blur(${this.config.backgroundBlur * 3}px)`;
@@ -616,15 +730,22 @@ export class FrameRenderer {
ctx.drawImage(bgCanvas, 0, 0, w, h);
}
} else {
console.warn('[FrameRenderer] No background sprite found during compositing!');
console.warn(
"[FrameRenderer] No background sprite found during compositing!",
);
}
// Draw video layer with shadows on top of background
if (this.config.showShadow && this.config.shadowIntensity > 0 && this.shadowCanvas && this.shadowCtx) {
if (
this.config.showShadow &&
this.config.shadowIntensity > 0 &&
this.shadowCanvas &&
this.shadowCtx
) {
const shadowCtx = this.shadowCtx;
shadowCtx.clearRect(0, 0, w, h);
shadowCtx.save();
// Calculate shadow parameters based on intensity (0-1)
const intensity = this.config.shadowIntensity;
const baseBlur1 = 48 * intensity;
@@ -634,8 +755,8 @@ export class FrameRenderer {
const baseAlpha2 = 0.5 * intensity;
const baseAlpha3 = 0.3 * intensity;
const baseOffset = 12 * intensity;
shadowCtx.filter = `drop-shadow(0 ${baseOffset}px ${baseBlur1}px rgba(0,0,0,${baseAlpha1})) drop-shadow(0 ${baseOffset/3}px ${baseBlur2}px rgba(0,0,0,${baseAlpha2})) drop-shadow(0 ${baseOffset/6}px ${baseBlur3}px rgba(0,0,0,${baseAlpha3}))`;
shadowCtx.filter = `drop-shadow(0 ${baseOffset}px ${baseBlur1}px rgba(0,0,0,${baseAlpha1})) drop-shadow(0 ${baseOffset / 3}px ${baseBlur2}px rgba(0,0,0,${baseAlpha2})) drop-shadow(0 ${baseOffset / 6}px ${baseBlur3}px rgba(0,0,0,${baseAlpha3}))`;
shadowCtx.drawImage(videoCanvas, 0, 0, w, h);
shadowCtx.restore();
ctx.drawImage(this.shadowCanvas, 0, 0, w, h);
@@ -646,12 +767,11 @@ export class FrameRenderer {
getCanvas(): HTMLCanvasElement {
if (!this.compositeCanvas) {
throw new Error('Renderer not initialized');
throw new Error("Renderer not initialized");
}
return this.compositeCanvas;
}
destroy(): void {
if (this.videoSprite) {
const videoTexture = this.videoSprite.texture;
@@ -661,7 +781,11 @@ export class FrameRenderer {
}
this.backgroundSprite = null;
if (this.app) {
this.app.destroy(true, { children: true, texture: false, textureSource: false });
this.app.destroy(true, {
children: true,
texture: false,
textureSource: false,
});
this.app = null;
}
this.cameraContainer = null;
@@ -679,4 +803,3 @@ export class FrameRenderer {
this.compositeCtx = null;
}
}
+56 -29
View File
@@ -1,10 +1,26 @@
import GIF from 'gif.js';
import type { ExportProgress, ExportResult, GifFrameRate, GifSizePreset, GIF_SIZE_PRESETS } from './types';
import { StreamingVideoDecoder } from './streamingDecoder';
import { FrameRenderer } from './frameRenderer';
import type { ZoomRegion, CropRegion, TrimRegion, AnnotationRegion, SpeedRegion, CursorTelemetryPoint } from '@/components/video-editor/types';
import GIF from "gif.js";
import type {
ExportProgress,
ExportResult,
GifFrameRate,
GifSizePreset,
GIF_SIZE_PRESETS,
} from "./types";
import { StreamingVideoDecoder } from "./streamingDecoder";
import { FrameRenderer } from "./frameRenderer";
import type {
ZoomRegion,
CropRegion,
TrimRegion,
AnnotationRegion,
SpeedRegion,
CursorTelemetryPoint,
} from "@/components/video-editor/types";
const GIF_WORKER_URL = new URL('gif.js/dist/gif.worker.js', import.meta.url).toString();
const GIF_WORKER_URL = new URL(
"gif.js/dist/gif.worker.js",
import.meta.url,
).toString();
interface GifExporterConfig {
videoUrl: string;
@@ -33,6 +49,7 @@ interface GifExporterConfig {
cursorSmoothing?: number;
cursorMotionBlur?: number;
cursorClickBounce?: number;
cursorSway?: number;
previewWidth?: number;
previewHeight?: number;
onProgress?: (progress: ExportProgress) => void;
@@ -50,13 +67,13 @@ export function calculateOutputDimensions(
sourceWidth: number,
sourceHeight: number,
sizePreset: GifSizePreset,
sizePresets: typeof GIF_SIZE_PRESETS
sizePresets: typeof GIF_SIZE_PRESETS,
): { width: number; height: number } {
const preset = sizePresets[sizePreset];
const maxHeight = preset.maxHeight;
// If original is smaller than max height or preset is 'original', use source dimensions
if (sourceHeight <= maxHeight || sizePreset === 'original') {
if (sourceHeight <= maxHeight || sizePreset === "original") {
return { width: sourceWidth, height: sourceHeight };
}
@@ -90,7 +107,9 @@ export class GifExporter {
// Initialize streaming decoder and load video metadata
this.streamingDecoder = new StreamingVideoDecoder();
const videoInfo = await this.streamingDecoder.loadMetadata(this.config.videoUrl);
const videoInfo = await this.streamingDecoder.loadMetadata(
this.config.videoUrl,
);
// Initialize frame renderer
this.renderer = new FrameRenderer({
@@ -118,6 +137,7 @@ export class GifExporter {
cursorSmoothing: this.config.cursorSmoothing,
cursorMotionBlur: this.config.cursorMotionBlur,
cursorClickBounce: this.config.cursorClickBounce,
cursorSway: this.config.cursorSway,
});
await this.renderer.initialize();
@@ -134,25 +154,33 @@ export class GifExporter {
height: this.config.height,
workerScript: GIF_WORKER_URL,
repeat,
background: '#000000',
background: "#000000",
transparent: null,
dither: 'FloydSteinberg',
dither: "FloydSteinberg",
});
// Calculate effective duration and frame count (excluding trim regions)
const effectiveDuration = this.streamingDecoder.getEffectiveDuration(this.config.trimRegions, this.config.speedRegions);
const effectiveDuration = this.streamingDecoder.getEffectiveDuration(
this.config.trimRegions,
this.config.speedRegions,
);
const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate);
// Calculate frame delay in milliseconds (gif.js uses ms)
const frameDelay = Math.round(1000 / this.config.frameRate);
console.log('[GifExporter] Original duration:', videoInfo.duration, 's');
console.log('[GifExporter] Effective duration:', effectiveDuration, 's');
console.log('[GifExporter] Total frames to export:', totalFrames);
console.log('[GifExporter] Frame rate:', this.config.frameRate, 'FPS');
console.log('[GifExporter] Frame delay:', frameDelay, 'ms');
console.log('[GifExporter] Loop:', this.config.loop ? 'infinite' : 'once');
console.log('[GifExporter] Using streaming decode (web-demuxer + VideoDecoder)');
console.log("[GifExporter] Original duration:", videoInfo.duration, "s");
console.log("[GifExporter] Effective duration:", effectiveDuration, "s");
console.log("[GifExporter] Total frames to export:", totalFrames);
console.log("[GifExporter] Frame rate:", this.config.frameRate, "FPS");
console.log("[GifExporter] Frame delay:", frameDelay, "ms");
console.log(
"[GifExporter] Loop:",
this.config.loop ? "infinite" : "once",
);
console.log(
"[GifExporter] Using streaming decode (web-demuxer + VideoDecoder)",
);
let frameIndex = 0;
@@ -174,11 +202,11 @@ export class GifExporter {
this.addRenderedGifFrame(frameDelay);
frameIndex++;
this.reportProgress(frameIndex, totalFrames);
}
},
);
if (this.cancelled) {
return { success: false, error: 'Export cancelled' };
return { success: false, error: "Export cancelled" };
}
// Update progress to show we're now in the finalizing phase
@@ -188,25 +216,25 @@ export class GifExporter {
totalFrames,
percentage: 100,
estimatedTimeRemaining: 0,
phase: 'finalizing',
phase: "finalizing",
});
}
// Render the GIF
const blob = await new Promise<Blob>((resolve, _reject) => {
this.gif!.on('finished', (blob: Blob) => {
this.gif!.on("finished", (blob: Blob) => {
resolve(blob);
});
// Track rendering progress
this.gif!.on('progress', (progress: number) => {
this.gif!.on("progress", (progress: number) => {
if (this.config.onProgress) {
this.config.onProgress({
currentFrame: totalFrames,
totalFrames,
percentage: 100,
estimatedTimeRemaining: 0,
phase: 'finalizing',
phase: "finalizing",
renderProgress: Math.round(progress * 100),
});
}
@@ -218,7 +246,7 @@ export class GifExporter {
return { success: true, blob };
} catch (error) {
console.error('GIF Export error:', error);
console.error("GIF Export error:", error);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
@@ -260,7 +288,7 @@ export class GifExporter {
try {
this.streamingDecoder.destroy();
} catch (e) {
console.warn('Error destroying streaming decoder:', e);
console.warn("Error destroying streaming decoder:", e);
}
this.streamingDecoder = null;
}
@@ -269,7 +297,7 @@ export class GifExporter {
try {
this.renderer.destroy();
} catch (e) {
console.warn('Error destroying renderer:', e);
console.warn("Error destroying renderer:", e);
}
this.renderer = null;
}
@@ -277,4 +305,3 @@ export class GifExporter {
this.gif = null;
}
}
+102 -52
View File
@@ -1,9 +1,16 @@
import type { ExportConfig, ExportProgress, ExportResult } from './types';
import { AudioProcessor } from './audioEncoder';
import { StreamingVideoDecoder } from './streamingDecoder';
import { FrameRenderer } from './frameRenderer';
import { VideoMuxer } from './muxer';
import type { ZoomRegion, CropRegion, TrimRegion, AnnotationRegion, SpeedRegion, CursorTelemetryPoint } from '@/components/video-editor/types';
import type { ExportConfig, ExportProgress, ExportResult } from "./types";
import { AudioProcessor } from "./audioEncoder";
import { StreamingVideoDecoder } from "./streamingDecoder";
import { FrameRenderer } from "./frameRenderer";
import { VideoMuxer } from "./muxer";
import type {
ZoomRegion,
CropRegion,
TrimRegion,
AnnotationRegion,
SpeedRegion,
CursorTelemetryPoint,
} from "@/components/video-editor/types";
interface VideoExporterConfig extends ExportConfig {
videoUrl: string;
@@ -27,6 +34,7 @@ interface VideoExporterConfig extends ExportConfig {
cursorSmoothing?: number;
cursorMotionBlur?: number;
cursorClickBounce?: number;
cursorSway?: number;
previewWidth?: number;
previewHeight?: number;
onProgress?: (progress: ExportProgress) => void;
@@ -60,7 +68,9 @@ export class VideoExporter {
// Initialize streaming decoder and load video metadata
this.streamingDecoder = new StreamingVideoDecoder();
const videoInfo = await this.streamingDecoder.loadMetadata(this.config.videoUrl);
const videoInfo = await this.streamingDecoder.loadMetadata(
this.config.videoUrl,
);
// Initialize frame renderer
this.renderer = new FrameRenderer({
@@ -88,6 +98,7 @@ export class VideoExporter {
cursorSmoothing: this.config.cursorSmoothing,
cursorMotionBlur: this.config.cursorMotionBlur,
cursorClickBounce: this.config.cursorClickBounce,
cursorSway: this.config.cursorSway,
});
await this.renderer.initialize();
@@ -101,13 +112,26 @@ export class VideoExporter {
await this.muxer.initialize();
// Calculate effective duration and frame count (excluding trim regions)
const effectiveDuration = this.streamingDecoder.getEffectiveDuration(this.config.trimRegions, this.config.speedRegions);
const effectiveDuration = this.streamingDecoder.getEffectiveDuration(
this.config.trimRegions,
this.config.speedRegions,
);
const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate);
console.log('[VideoExporter] Original duration:', videoInfo.duration, 's');
console.log('[VideoExporter] Effective duration:', effectiveDuration, 's');
console.log('[VideoExporter] Total frames to export:', totalFrames);
console.log('[VideoExporter] Using streaming decode (web-demuxer + VideoDecoder)');
console.log(
"[VideoExporter] Original duration:",
videoInfo.duration,
"s",
);
console.log(
"[VideoExporter] Effective duration:",
effectiveDuration,
"s",
);
console.log("[VideoExporter] Total frames to export:", totalFrames);
console.log(
"[VideoExporter] Using streaming decode (web-demuxer + VideoDecoder)",
);
const frameDuration = 1_000_000 / this.config.frameRate; // in microseconds
let frameIndex = 0;
@@ -131,20 +155,26 @@ export class VideoExporter {
await this.encodeRenderedFrame(timestamp, frameDuration, frameIndex);
frameIndex++;
this.reportProgress(frameIndex, totalFrames);
}
},
);
if (this.cancelled) {
return { success: false, error: 'Export cancelled' };
return { success: false, error: "Export cancelled" };
}
// Finalize encoding
if (this.encoder && this.encoder.state === 'configured') {
await this.awaitWithWindowsTimeout(this.encoder.flush(), 'encoder flush');
if (this.encoder && this.encoder.state === "configured") {
await this.awaitWithWindowsTimeout(
this.encoder.flush(),
"encoder flush",
);
}
// Wait for queued muxing operations to complete
await this.awaitWithWindowsTimeout(this.pendingMuxing, 'muxing queued video chunks');
await this.awaitWithWindowsTimeout(
this.pendingMuxing,
"muxing queued video chunks",
);
if (hasAudio && !this.cancelled) {
const demuxer = this.streamingDecoder.getDemuxer();
@@ -158,17 +188,20 @@ export class VideoExporter {
this.config.trimRegions,
this.config.speedRegions,
),
'audio processing',
"audio processing",
);
}
}
// Finalize muxer and get output blob
const blob = await this.awaitWithWindowsTimeout(this.muxer!.finalize(), 'muxer finalization');
const blob = await this.awaitWithWindowsTimeout(
this.muxer!.finalize(),
"muxer finalization",
);
return { success: true, blob };
} catch (error) {
console.error('Export error:', error);
console.error("Export error:", error);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
@@ -179,13 +212,16 @@ export class VideoExporter {
}
private isWindowsPlatform(): boolean {
if (typeof navigator === 'undefined') {
if (typeof navigator === "undefined") {
return false;
}
return /Win/i.test(navigator.platform);
}
private async awaitWithWindowsTimeout<T>(promise: Promise<T>, stage: string): Promise<T> {
private async awaitWithWindowsTimeout<T>(
promise: Promise<T>,
stage: string,
): Promise<T> {
if (!this.isWindowsPlatform()) {
return promise;
}
@@ -208,7 +244,11 @@ export class VideoExporter {
}
}
private async encodeRenderedFrame(timestamp: number, frameDuration: number, frameIndex: number) {
private async encodeRenderedFrame(
timestamp: number,
frameDuration: number,
frameIndex: number,
) {
const canvas = this.renderer!.getCanvas();
// @ts-ignore - colorSpace not in TypeScript definitions but works at runtime
@@ -216,22 +256,28 @@ export class VideoExporter {
timestamp,
duration: frameDuration,
colorSpace: {
primaries: 'bt709',
transfer: 'iec61966-2-1',
matrix: 'rgb',
primaries: "bt709",
transfer: "iec61966-2-1",
matrix: "rgb",
fullRange: true,
},
});
while (this.encoder && this.encoder.encodeQueueSize >= this.MAX_ENCODE_QUEUE && !this.cancelled) {
await new Promise(resolve => setTimeout(resolve, 5));
while (
this.encoder &&
this.encoder.encodeQueueSize >= this.MAX_ENCODE_QUEUE &&
!this.cancelled
) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
if (this.encoder && this.encoder.state === 'configured') {
if (this.encoder && this.encoder.state === "configured") {
this.encodeQueue++;
this.encoder.encode(exportFrame, { keyFrame: frameIndex % 150 === 0 });
} else {
console.warn(`[Frame ${frameIndex}] Encoder not ready! State: ${this.encoder?.state}`);
console.warn(
`[Frame ${frameIndex}] Encoder not ready! State: ${this.encoder?.state}`,
);
}
exportFrame.close();
@@ -259,7 +305,9 @@ export class VideoExporter {
// Capture decoder config metadata from encoder output
if (meta?.decoderConfig?.description && !videoDescription) {
const desc = meta.decoderConfig.description;
videoDescription = new Uint8Array(desc instanceof ArrayBuffer ? desc : (desc as any));
videoDescription = new Uint8Array(
desc instanceof ArrayBuffer ? desc : (desc as any),
);
this.videoDescription = videoDescription;
}
// Capture colorSpace from encoder metadata if provided
@@ -276,15 +324,15 @@ export class VideoExporter {
if (isFirstChunk && this.videoDescription) {
// Add decoder config for the first chunk
const colorSpace = this.videoColorSpace || {
primaries: 'bt709',
transfer: 'iec61966-2-1',
matrix: 'rgb',
primaries: "bt709",
transfer: "iec61966-2-1",
matrix: "rgb",
fullRange: true,
};
const metadata: EncodedVideoChunkMetadata = {
decoderConfig: {
codec: this.config.codec || 'avc1.640033',
codec: this.config.codec || "avc1.640033",
codedWidth: this.config.width,
codedHeight: this.config.height,
description: this.videoDescription,
@@ -297,19 +345,19 @@ export class VideoExporter {
await this.muxer!.addVideoChunk(chunk, meta);
}
} catch (error) {
console.error('Muxing error:', error);
console.error("Muxing error:", error);
}
});
this.encodeQueue--;
},
error: (error) => {
console.error('[VideoExporter] Encoder error:', error);
console.error("[VideoExporter] Encoder error:", error);
// Stop export encoding failed
this.cancelled = true;
},
});
const codec = this.config.codec || 'avc1.640033';
const codec = this.config.codec || "avc1.640033";
const encoderConfig: VideoEncoderConfig = {
codec,
@@ -317,9 +365,9 @@ export class VideoExporter {
height: this.config.height,
bitrate: this.config.bitrate,
framerate: this.config.frameRate,
latencyMode: 'quality', // Changed from 'realtime' to 'quality' for better throughput
bitrateMode: 'variable',
hardwareAcceleration: 'prefer-hardware',
latencyMode: "quality", // Changed from 'realtime' to 'quality' for better throughput
bitrateMode: "variable",
hardwareAcceleration: "prefer-hardware",
};
// Check hardware support first
@@ -327,16 +375,19 @@ export class VideoExporter {
if (hardwareSupport.supported) {
// Use hardware encoding
console.log('[VideoExporter] Using hardware acceleration');
console.log("[VideoExporter] Using hardware acceleration");
this.encoder.configure(encoderConfig);
} else {
// Fall back to software encoding
console.log('[VideoExporter] Hardware not supported, using software encoding');
encoderConfig.hardwareAcceleration = 'prefer-software';
console.log(
"[VideoExporter] Hardware not supported, using software encoding",
);
encoderConfig.hardwareAcceleration = "prefer-software";
const softwareSupport = await VideoEncoder.isConfigSupported(encoderConfig);
const softwareSupport =
await VideoEncoder.isConfigSupported(encoderConfig);
if (!softwareSupport.supported) {
throw new Error('Video encoding not supported on this system');
throw new Error("Video encoding not supported on this system");
}
this.encoder.configure(encoderConfig);
@@ -357,11 +408,11 @@ export class VideoExporter {
private cleanup(): void {
if (this.encoder) {
try {
if (this.encoder.state === 'configured') {
if (this.encoder.state === "configured") {
this.encoder.close();
}
} catch (e) {
console.warn('Error closing encoder:', e);
console.warn("Error closing encoder:", e);
}
this.encoder = null;
}
@@ -370,7 +421,7 @@ export class VideoExporter {
try {
this.streamingDecoder.destroy();
} catch (e) {
console.warn('Error destroying streaming decoder:', e);
console.warn("Error destroying streaming decoder:", e);
}
this.streamingDecoder = null;
}
@@ -379,13 +430,13 @@ export class VideoExporter {
try {
this.renderer.destroy();
} catch (e) {
console.warn('Error destroying renderer:', e);
console.warn("Error destroying renderer:", e);
}
this.renderer = null;
}
this.muxer = null;
this.audioProcessor = null;
this.audioProcessor = null;
this.encodeQueue = 0;
this.pendingMuxing = Promise.resolve();
this.chunkCount = 0;
@@ -393,4 +444,3 @@ export class VideoExporter {
this.videoColorSpace = undefined;
}
}