feat: smooth auto-follow zoom with export parity

This commit is contained in:
xKeCo
2026-04-01 01:41:20 -05:00
parent e35780bd85
commit 3be195cc15
15 changed files with 287 additions and 18 deletions
@@ -92,6 +92,9 @@ interface SettingsPanelProps {
onWallpaperChange: (path: string) => void;
selectedZoomDepth?: ZoomDepth | null;
onZoomDepthChange?: (depth: ZoomDepth) => void;
selectedZoomFocusMode?: import("./types").ZoomFocusMode | null;
onZoomFocusModeChange?: (mode: import("./types").ZoomFocusMode) => void;
hasCursorTelemetry?: boolean;
selectedZoomId?: string | null;
onZoomDelete?: (id: string) => void;
selectedTrimId?: string | null;
@@ -161,6 +164,9 @@ export function SettingsPanel({
onWallpaperChange,
selectedZoomDepth,
onZoomDepthChange,
selectedZoomFocusMode,
onZoomFocusModeChange,
hasCursorTelemetry = false,
selectedZoomId,
onZoomDelete,
selectedTrimId,
@@ -500,6 +506,41 @@ export function SettingsPanel({
{!zoomEnabled && (
<p className="text-[10px] text-slate-500 mt-2 text-center">{t("zoom.selectRegion")}</p>
)}
{zoomEnabled && hasCursorTelemetry && (
<div className="mt-3">
<span className="text-sm font-medium text-slate-200 mb-2 block">
{t("zoom.focusMode.title")}
</span>
<div className="grid grid-cols-2 gap-1.5">
{(["manual", "auto"] as const).map((mode) => {
const isActive = selectedZoomFocusMode === mode;
return (
<Button
key={mode}
type="button"
onClick={() => onZoomFocusModeChange?.(mode)}
className={cn(
"h-auto w-full rounded-lg border px-2 py-2 text-center shadow-sm transition-all",
"duration-200 ease-out cursor-pointer",
isActive
? "border-[#34B27B] bg-[#34B27B] text-white shadow-[#34B27B]/20"
: "border-white/5 bg-white/5 text-slate-400 hover:bg-white/10 hover:border-white/10 hover:text-slate-200",
)}
>
<span className="text-xs font-semibold capitalize">
{t(`zoom.focusMode.${mode}`)}
</span>
</Button>
);
})}
</div>
{selectedZoomFocusMode === "auto" && (
<p className="text-[10px] text-slate-500 mt-1.5">
{t("zoom.focusMode.autoDescription")}
</p>
)}
</div>
)}
{zoomEnabled && (
<Button
onClick={handleDeleteClick}
@@ -56,6 +56,7 @@ import {
type TrimRegion,
type ZoomDepth,
type ZoomFocus,
type ZoomFocusMode,
type ZoomRegion,
} from "./types";
import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback";
@@ -688,6 +689,18 @@ export default function VideoEditor() {
[selectedZoomId, pushState],
);
const handleZoomFocusModeChange = useCallback(
(focusMode: ZoomFocusMode) => {
if (!selectedZoomId) return;
pushState((prev) => ({
zoomRegions: prev.zoomRegions.map((region) =>
region.id === selectedZoomId ? { ...region, focusMode } : region,
),
}));
},
[selectedZoomId, pushState],
);
const handleZoomDelete = useCallback(
(id: string) => {
pushState((prev) => ({ zoomRegions: prev.zoomRegions.filter((r) => r.id !== id) }));
@@ -1093,6 +1106,7 @@ export default function VideoEditor() {
webcamPosition,
previewWidth,
previewHeight,
cursorTelemetry,
onProgress: (progress: ExportProgress) => {
setExportProgress(progress);
},
@@ -1224,6 +1238,7 @@ export default function VideoEditor() {
webcamPosition,
previewWidth,
previewHeight,
cursorTelemetry,
onProgress: (progress: ExportProgress) => {
setExportProgress(progress);
},
@@ -1292,6 +1307,7 @@ export default function VideoEditor() {
webcamPosition,
exportQuality,
handleExportSaved,
cursorTelemetry,
],
);
@@ -1502,6 +1518,7 @@ export default function VideoEditor() {
onSelectAnnotation={handleSelectAnnotation}
onAnnotationPositionChange={handleAnnotationPositionChange}
onAnnotationSizeChange={handleAnnotationSizeChange}
cursorTelemetry={cursorTelemetry}
/>
</div>
</div>
@@ -1584,6 +1601,13 @@ export default function VideoEditor() {
selectedZoomId ? zoomRegions.find((z) => z.id === selectedZoomId)?.depth : null
}
onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)}
selectedZoomFocusMode={
selectedZoomId
? (zoomRegions.find((z) => z.id === selectedZoomId)?.focusMode ?? "manual")
: null
}
onZoomFocusModeChange={(mode) => selectedZoomId && handleZoomFocusModeChange(mode)}
hasCursorTelemetry={cursorTelemetry.length > 0}
selectedZoomId={selectedZoomId}
onZoomDelete={handleZoomDelete}
selectedTrimId={selectedTrimId}
+48 -1
View File
@@ -41,10 +41,13 @@ import {
type ZoomRegion,
} from "./types";
import {
AUTO_FOLLOW_DEADZONE,
AUTO_FOLLOW_SMOOTHING_FACTOR,
DEFAULT_FOCUS,
ZOOM_SCALE_DEADZONE,
ZOOM_TRANSLATION_DEADZONE_PX,
} from "./videoPlayback/constants";
import { smoothCursorFocus } from "./videoPlayback/cursorFollowUtils";
import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils";
import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils";
import { clamp01 } from "./videoPlayback/mathUtils";
@@ -93,6 +96,7 @@ interface VideoPlaybackProps {
onSelectAnnotation?: (id: string | null) => void;
onAnnotationPositionChange?: (id: string, position: { x: number; y: number }) => void;
onAnnotationSizeChange?: (id: string, size: { width: number; height: number }) => void;
cursorTelemetry?: import("./types").CursorTelemetryPoint[];
}
export interface VideoPlaybackRef {
@@ -141,6 +145,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
onSelectAnnotation,
onAnnotationPositionChange,
onAnnotationSizeChange,
cursorTelemetry = [],
},
ref,
) => {
@@ -160,6 +165,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const [webcamDimensions, setWebcamDimensions] = useState<Size | null>(null);
const currentTimeRef = useRef(0);
const zoomRegionsRef = useRef<ZoomRegion[]>([]);
const cursorTelemetryRef = useRef<import("./types").CursorTelemetryPoint[]>([]);
const selectedZoomIdRef = useRef<string | null>(null);
const animationStateRef = useRef({
scale: 1,
@@ -194,6 +200,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const onTimeUpdateRef = useRef(onTimeUpdate);
const onPlayStateChangeRef = useRef(onPlayStateChange);
const videoReadyRafRef = useRef<number | null>(null);
const smoothedAutoFocusRef = useRef<ZoomFocus | null>(null);
const prevTargetProgressRef = useRef(0);
const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => {
return clampFocusToStageUtil(focus, depth, stageSizeRef.current);
@@ -379,6 +387,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
if (!regionId) return;
const region = zoomRegionsRef.current.find((r) => r.id === regionId);
if (!region) return;
if (region.focusMode === "auto") return;
onSelectZoom(region.id);
event.preventDefault();
isDraggingFocusRef.current = true;
@@ -462,6 +471,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
zoomRegionsRef.current = zoomRegions;
}, [zoomRegions]);
useEffect(() => {
cursorTelemetryRef.current = cursorTelemetry;
}, [cursorTelemetry]);
useEffect(() => {
selectedZoomIdRef.current = selectedZoomId;
}, [selectedZoomId]);
@@ -833,7 +846,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const { region, strength, blendedScale, transition } = findDominantRegion(
zoomRegionsRef.current,
currentTimeRef.current,
{ connectZooms: true },
{ connectZooms: true, cursorTelemetry: cursorTelemetryRef.current },
);
const defaultFocus = DEFAULT_FOCUS;
@@ -854,6 +867,40 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
targetFocus = regionFocus;
targetProgress = strength;
// Apply deadzone + smoothing for auto-follow mode
if (region.focusMode === "auto" && !transition) {
const raw = targetFocus;
const isZoomingIn =
targetProgress < 0.999 && targetProgress >= prevTargetProgressRef.current;
if (targetProgress >= 0.999) {
// Full zoom: apply deadzone + smoothing for stable follow
const prev = smoothedAutoFocusRef.current ?? raw;
const dx = Math.abs(raw.cx - prev.cx);
const dy = Math.abs(raw.cy - prev.cy);
if (dx > AUTO_FOLLOW_DEADZONE || dy > AUTO_FOLLOW_DEADZONE) {
const smoothed = smoothCursorFocus(raw, prev, AUTO_FOLLOW_SMOOTHING_FACTOR);
smoothedAutoFocusRef.current = smoothed;
targetFocus = smoothed;
} else {
smoothedAutoFocusRef.current = prev;
targetFocus = prev;
}
} else if (isZoomingIn) {
// Zoom-in: track cursor directly so zoom always aims at current cursor
// position; keep ref in sync to avoid snap when full-zoom begins
smoothedAutoFocusRef.current = raw;
} else {
// Zoom-out: keep smoothing for continuity — avoids snap at zoom-out start
const prev = smoothedAutoFocusRef.current ?? raw;
const smoothed = smoothCursorFocus(raw, prev, AUTO_FOLLOW_SMOOTHING_FACTOR);
smoothedAutoFocusRef.current = smoothed;
targetFocus = smoothed;
}
} else if (region.focusMode !== "auto") {
smoothedAutoFocusRef.current = null;
}
prevTargetProgressRef.current = targetProgress;
// Handle connected zoom transitions (pan between adjacent zoom regions)
if (transition) {
const startTransform = computeZoomTransform({
@@ -189,6 +189,7 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
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),
},
focusMode: region.focusMode === "auto" ? "auto" : "manual",
};
})
: [];
+2
View File
@@ -1,6 +1,7 @@
import type { WebcamLayoutPreset } from "@/lib/compositeLayout";
export type ZoomDepth = 1 | 2 | 3 | 4 | 5 | 6;
export type ZoomFocusMode = "manual" | "auto";
export type { WebcamLayoutPreset };
export const DEFAULT_WEBCAM_LAYOUT_PRESET: WebcamLayoutPreset = "picture-in-picture";
@@ -23,6 +24,7 @@ export interface ZoomRegion {
endMs: number;
depth: ZoomDepth;
focus: ZoomFocus;
focusMode?: ZoomFocusMode;
}
export interface CursorTelemetryPoint {
@@ -8,3 +8,5 @@ export const VIEWPORT_SCALE = 0.8;
export const SMOOTHING_FACTOR = 0.12;
export const ZOOM_TRANSLATION_DEADZONE_PX = 1.25;
export const ZOOM_SCALE_DEADZONE = 0.002;
export const AUTO_FOLLOW_SMOOTHING_FACTOR = 0.05;
export const AUTO_FOLLOW_DEADZONE = 0.06;
@@ -0,0 +1,54 @@
import type { CursorTelemetryPoint, ZoomFocus } from "../types";
/**
* Binary-search the sorted telemetry array and linearly interpolate
* the cursor position at the given playback time.
*/
export function interpolateCursorAt(
telemetry: CursorTelemetryPoint[],
timeMs: number,
): ZoomFocus | null {
if (telemetry.length === 0) return null;
if (timeMs <= telemetry[0].timeMs) {
return { cx: telemetry[0].cx, cy: telemetry[0].cy };
}
const last = telemetry[telemetry.length - 1];
if (timeMs >= last.timeMs) {
return { cx: last.cx, cy: last.cy };
}
let lo = 0;
let hi = telemetry.length - 1;
while (lo < hi - 1) {
const mid = (lo + hi) >>> 1;
if (telemetry[mid].timeMs <= timeMs) {
lo = mid;
} else {
hi = mid;
}
}
const before = telemetry[lo];
const after = telemetry[hi];
const span = after.timeMs - before.timeMs;
const t = span > 0 ? (timeMs - before.timeMs) / span : 0;
return {
cx: before.cx + (after.cx - before.cx) * t,
cy: before.cy + (after.cy - before.cy) * t,
};
}
/**
* Exponential smoothing to reduce jitter from high-frequency cursor data.
* Lower factor = smoother / more lag, higher = more responsive.
*/
export function smoothCursorFocus(raw: ZoomFocus, prev: ZoomFocus, factor: number): ZoomFocus {
return {
cx: prev.cx + (raw.cx - prev.cx) * factor,
cy: prev.cy + (raw.cy - prev.cy) * factor,
};
}
@@ -14,7 +14,7 @@ interface OverlayUpdateParams {
export function updateOverlayIndicator(params: OverlayUpdateParams) {
const { overlayEl, indicatorEl, region, focusOverride, videoSize, baseScale, isPlaying } = params;
if (!region) {
if (!region || region.focusMode === "auto") {
indicatorEl.style.display = "none";
overlayEl.style.pointerEvents = "none";
return;
@@ -1,6 +1,7 @@
import type { ZoomFocus, ZoomRegion } from "../types";
import type { CursorTelemetryPoint, ZoomFocus, ZoomRegion } from "../types";
import { ZOOM_DEPTH_SCALES } from "../types";
import { TRANSITION_WINDOW_MS, ZOOM_IN_TRANSITION_WINDOW_MS } from "./constants";
import { interpolateCursorAt } from "./cursorFollowUtils";
import { clampFocusToScale } from "./focusUtils";
import { clamp01, cubicBezier, easeOutScreenStudio } from "./mathUtils";
@@ -10,6 +11,7 @@ const ZOOM_IN_OVERLAP_MS = 500;
type DominantRegionOptions = {
connectZooms?: boolean;
cursorTelemetry?: CursorTelemetryPoint[];
};
type ConnectedRegionPair = {
@@ -64,8 +66,27 @@ function getLinearFocus(start: ZoomFocus, end: ZoomFocus, amount: number): ZoomF
};
}
function getResolvedFocus(region: ZoomRegion, zoomScale: number): ZoomFocus {
return clampFocusToScale(region.focus, zoomScale);
function getResolvedFocus(
region: ZoomRegion,
zoomScale: number,
timeMs?: number,
cursorTelemetry?: CursorTelemetryPoint[],
): ZoomFocus {
let focus = region.focus;
if (
region.focusMode === "auto" &&
cursorTelemetry &&
cursorTelemetry.length > 0 &&
timeMs !== undefined
) {
const cursorFocus = interpolateCursorAt(cursorTelemetry, timeMs);
if (cursorFocus) {
focus = cursorFocus;
}
}
return clampFocusToScale(focus, zoomScale);
}
function getConnectedRegionPairs(regions: ZoomRegion[]) {
@@ -96,6 +117,7 @@ function getActiveRegion(
regions: ZoomRegion[],
timeMs: number,
connectedPairs: ConnectedRegionPair[],
cursorTelemetry?: CursorTelemetryPoint[],
) {
const activeRegions = regions
.map((region) => {
@@ -130,21 +152,25 @@ function getActiveRegion(
return {
region: {
...activeRegion,
focus: getResolvedFocus(activeRegion, activeScale),
focus: getResolvedFocus(activeRegion, activeScale, timeMs, cursorTelemetry),
},
strength: activeRegions[0].strength,
blendedScale: null,
};
}
function getConnectedRegionHold(timeMs: number, connectedPairs: ConnectedRegionPair[]) {
function getConnectedRegionHold(
timeMs: number,
connectedPairs: ConnectedRegionPair[],
cursorTelemetry?: CursorTelemetryPoint[],
) {
for (const pair of connectedPairs) {
if (timeMs > pair.transitionEnd && timeMs < pair.nextRegion.startMs) {
const nextScale = ZOOM_DEPTH_SCALES[pair.nextRegion.depth];
return {
region: {
...pair.nextRegion,
focus: getResolvedFocus(pair.nextRegion, nextScale),
focus: getResolvedFocus(pair.nextRegion, nextScale, timeMs, cursorTelemetry),
},
strength: 1,
blendedScale: null,
@@ -155,7 +181,11 @@ function getConnectedRegionHold(timeMs: number, connectedPairs: ConnectedRegionP
return null;
}
function getConnectedRegionTransition(connectedPairs: ConnectedRegionPair[], timeMs: number) {
function getConnectedRegionTransition(
connectedPairs: ConnectedRegionPair[],
timeMs: number,
cursorTelemetry?: CursorTelemetryPoint[],
) {
for (const pair of connectedPairs) {
const { currentRegion, nextRegion, transitionStart, transitionEnd } = pair;
@@ -169,8 +199,8 @@ function getConnectedRegionTransition(connectedPairs: ConnectedRegionPair[], tim
const currentScale = ZOOM_DEPTH_SCALES[currentRegion.depth];
const nextScale = ZOOM_DEPTH_SCALES[nextRegion.depth];
const transitionScale = lerp(currentScale, nextScale, transitionProgress);
const currentFocus = getResolvedFocus(currentRegion, currentScale);
const nextFocus = getResolvedFocus(nextRegion, nextScale);
const currentFocus = getResolvedFocus(currentRegion, currentScale, timeMs, cursorTelemetry);
const nextFocus = getResolvedFocus(nextRegion, nextScale, timeMs, cursorTelemetry);
const transitionFocus = getLinearFocus(currentFocus, nextFocus, transitionProgress);
return {
@@ -204,20 +234,21 @@ export function findDominantRegion(
transition: ConnectedPanTransition | null;
} {
const connectedPairs = options.connectZooms ? getConnectedRegionPairs(regions) : [];
const telemetry = options.cursorTelemetry;
if (options.connectZooms) {
const connectedTransition = getConnectedRegionTransition(connectedPairs, timeMs);
const connectedTransition = getConnectedRegionTransition(connectedPairs, timeMs, telemetry);
if (connectedTransition) {
return connectedTransition;
}
const connectedHold = getConnectedRegionHold(timeMs, connectedPairs);
const connectedHold = getConnectedRegionHold(timeMs, connectedPairs, telemetry);
if (connectedHold) {
return { ...connectedHold, transition: null };
}
}
const activeRegion = getActiveRegion(regions, timeMs, connectedPairs);
const activeRegion = getActiveRegion(regions, timeMs, connectedPairs, telemetry);
return activeRegion
? { ...activeRegion, transition: null }
: { region: null, strength: 0, blendedScale: null, transition: null };
+7 -1
View File
@@ -2,7 +2,13 @@
"zoom": {
"level": "Zoom Level",
"selectRegion": "Select a zoom region to adjust",
"deleteZoom": "Delete Zoom"
"deleteZoom": "Delete Zoom",
"focusMode": {
"title": "Focus Mode",
"manual": "Manual",
"auto": "Auto",
"autoDescription": "Camera follows the recorded cursor position"
}
},
"speed": {
"playbackSpeed": "Playback Speed",
+7 -1
View File
@@ -2,7 +2,13 @@
"zoom": {
"level": "Nivel de zoom",
"selectRegion": "Selecciona una región de zoom para ajustar",
"deleteZoom": "Eliminar zoom"
"deleteZoom": "Eliminar zoom",
"focusMode": {
"title": "Modo de enfoque",
"manual": "Manual",
"auto": "Auto",
"autoDescription": "La cámara sigue la posición del cursor grabado"
}
},
"speed": {
"playbackSpeed": "Velocidad de reproducción",
+7 -1
View File
@@ -2,7 +2,13 @@
"zoom": {
"level": "缩放级别",
"selectRegion": "选择要调整的缩放区域",
"deleteZoom": "删除缩放"
"deleteZoom": "删除缩放",
"focusMode": {
"title": "对焦模式",
"manual": "手动",
"auto": "自动",
"autoDescription": "摄像头跟随录制时的光标位置"
}
},
"speed": {
"playbackSpeed": "播放速度",
+46 -1
View File
@@ -18,10 +18,13 @@ import type {
} from "@/components/video-editor/types";
import { ZOOM_DEPTH_SCALES } from "@/components/video-editor/types";
import {
AUTO_FOLLOW_DEADZONE,
AUTO_FOLLOW_SMOOTHING_FACTOR,
DEFAULT_FOCUS,
ZOOM_SCALE_DEADZONE,
ZOOM_TRANSLATION_DEADZONE_PX,
} from "@/components/video-editor/videoPlayback/constants";
import { smoothCursorFocus } from "@/components/video-editor/videoPlayback/cursorFollowUtils";
import { clampFocusToStage as clampFocusToStageUtil } from "@/components/video-editor/videoPlayback/focusUtils";
import { findDominantRegion } from "@/components/video-editor/videoPlayback/zoomRegionUtils";
import {
@@ -66,6 +69,7 @@ interface FrameRenderConfig {
speedRegions?: SpeedRegion[];
previewWidth?: number;
previewHeight?: number;
cursorTelemetry?: import("@/components/video-editor/types").CursorTelemetryPoint[];
}
interface AnimationState {
@@ -107,6 +111,9 @@ export class FrameRenderer {
private layoutCache: LayoutCache | null = null;
private currentVideoTime = 0;
private motionBlurState: MotionBlurState = createMotionBlurState();
private smoothedAutoFocus: { cx: number; cy: number } | null = null;
private prevAnimationTimeMs: number | null = null;
private prevTargetProgress = 0;
constructor(config: FrameRenderConfig) {
this.config = config;
@@ -511,7 +518,7 @@ export class FrameRenderer {
const { region, strength, blendedScale, transition } = findDominantRegion(
this.config.zoomRegions,
timeMs,
{ connectZooms: true },
{ connectZooms: true, cursorTelemetry: this.config.cursorTelemetry },
);
const defaultFocus = DEFAULT_FOCUS;
@@ -527,6 +534,42 @@ export class FrameRenderer {
targetFocus = regionFocus;
targetProgress = strength;
// Apply deadzone + time-based smoothing for auto-follow mode
if (region.focusMode === "auto" && !transition) {
const raw = targetFocus;
const dtMs = this.prevAnimationTimeMs != null ? timeMs - this.prevAnimationTimeMs : 0;
const framesElapsed = dtMs > 0 ? dtMs / (1000 / 60) : 1;
const factor = 1 - Math.pow(1 - AUTO_FOLLOW_SMOOTHING_FACTOR, Math.max(1, framesElapsed));
const isZoomingIn = targetProgress < 0.999 && targetProgress >= this.prevTargetProgress;
if (targetProgress >= 0.999) {
// Full zoom: apply deadzone + smoothing for stable follow
const prev = this.smoothedAutoFocus ?? raw;
const dx = Math.abs(raw.cx - prev.cx);
const dy = Math.abs(raw.cy - prev.cy);
if (dx > AUTO_FOLLOW_DEADZONE || dy > AUTO_FOLLOW_DEADZONE) {
const smoothed = smoothCursorFocus(raw, prev, factor);
this.smoothedAutoFocus = smoothed;
targetFocus = smoothed;
} else {
this.smoothedAutoFocus = prev;
targetFocus = prev;
}
} else if (isZoomingIn) {
// Zoom-in: track cursor directly so zoom always aims at current cursor
// position; keep ref in sync to avoid snap when full-zoom begins
this.smoothedAutoFocus = raw;
} else {
// Zoom-out: keep smoothing for continuity — avoids snap at zoom-out start
const prev = this.smoothedAutoFocus ?? raw;
const smoothed = smoothCursorFocus(raw, prev, factor);
this.smoothedAutoFocus = smoothed;
targetFocus = smoothed;
}
} else if (region.focusMode !== "auto") {
this.smoothedAutoFocus = null;
}
this.prevTargetProgress = targetProgress;
if (transition) {
const startTransform = computeZoomTransform({
stageSize: this.layoutCache.stageSize,
@@ -602,6 +645,8 @@ export class FrameRenderer {
state.y = appliedY;
state.appliedScale = appliedScale;
this.prevAnimationTimeMs = timeMs;
return Math.max(
Math.abs(appliedScale - prevScale),
Math.abs(appliedX - prevX) / Math.max(1, this.layoutCache.stageSize.width),
+2
View File
@@ -45,6 +45,7 @@ interface GifExporterConfig {
annotationRegions?: AnnotationRegion[];
previewWidth?: number;
previewHeight?: number;
cursorTelemetry?: import("@/components/video-editor/types").CursorTelemetryPoint[];
onProgress?: (progress: ExportProgress) => void;
}
@@ -146,6 +147,7 @@ export class GifExporter {
speedRegions: this.config.speedRegions,
previewWidth: this.config.previewWidth,
previewHeight: this.config.previewHeight,
cursorTelemetry: this.config.cursorTelemetry,
});
await this.renderer.initialize();
+2
View File
@@ -36,6 +36,7 @@ interface VideoExporterConfig extends ExportConfig {
annotationRegions?: AnnotationRegion[];
previewWidth?: number;
previewHeight?: number;
cursorTelemetry?: import("@/components/video-editor/types").CursorTelemetryPoint[];
onProgress?: (progress: ExportProgress) => void;
}
@@ -139,6 +140,7 @@ export class VideoExporter {
speedRegions: this.config.speedRegions,
previewWidth: this.config.previewWidth,
previewHeight: this.config.previewHeight,
cursorTelemetry: this.config.cursorTelemetry,
});
this.renderer = renderer;
await renderer.initialize();