mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 23:35:43 +00:00
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:
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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user