feat: speed thing
This commit is contained in:
@@ -9,7 +9,8 @@ import { useState } from "react";
|
||||
import Block from '@uiw/react-color-block';
|
||||
import { Trash2, Download, Crop, X, Bug, Upload, Star, Film, Image, Sparkles, Palette } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { ZoomDepth, CropRegion, AnnotationRegion, AnnotationType } from "./types";
|
||||
import type { ZoomDepth, CropRegion, AnnotationRegion, AnnotationType, PlaybackSpeed } from "./types";
|
||||
import { SPEED_OPTIONS } from "./types";
|
||||
import { CropControl } from "./CropControl";
|
||||
import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp";
|
||||
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
|
||||
@@ -90,6 +91,10 @@ interface SettingsPanelProps {
|
||||
onAnnotationStyleChange?: (id: string, style: Partial<AnnotationRegion['style']>) => void;
|
||||
onAnnotationFigureDataChange?: (id: string, figureData: any) => void;
|
||||
onAnnotationDelete?: (id: string) => void;
|
||||
selectedSpeedId?: string | null;
|
||||
selectedSpeedValue?: PlaybackSpeed | null;
|
||||
onSpeedChange?: (speed: PlaybackSpeed) => void;
|
||||
onSpeedDelete?: (id: string) => void;
|
||||
}
|
||||
|
||||
export default SettingsPanel;
|
||||
@@ -145,6 +150,10 @@ export function SettingsPanel({
|
||||
onAnnotationStyleChange,
|
||||
onAnnotationFigureDataChange,
|
||||
onAnnotationDelete,
|
||||
selectedSpeedId,
|
||||
selectedSpeedValue,
|
||||
onSpeedChange,
|
||||
onSpeedDelete,
|
||||
}: SettingsPanelProps) {
|
||||
const [wallpaperPaths, setWallpaperPaths] = useState<string[]>([]);
|
||||
const [customImages, setCustomImages] = useState<string[]>([]);
|
||||
@@ -321,6 +330,54 @@ export function SettingsPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-medium text-slate-200">Playback Speed</span>
|
||||
{selectedSpeedId && selectedSpeedValue && (
|
||||
<span className="text-[10px] uppercase tracking-wider font-medium text-[#d97706] bg-[#d97706]/10 px-2 py-0.5 rounded-full">
|
||||
{SPEED_OPTIONS.find(o => o.speed === selectedSpeedValue)?.label ?? `${selectedSpeedValue}×`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1.5">
|
||||
{SPEED_OPTIONS.map((option) => {
|
||||
const isActive = selectedSpeedValue === option.speed;
|
||||
return (
|
||||
<Button
|
||||
key={option.speed}
|
||||
type="button"
|
||||
disabled={!selectedSpeedId}
|
||||
onClick={() => onSpeedChange?.(option.speed)}
|
||||
className={cn(
|
||||
"h-auto w-full rounded-lg border px-1 py-2 text-center shadow-sm transition-all",
|
||||
"duration-200 ease-out",
|
||||
selectedSpeedId ? "opacity-100 cursor-pointer" : "opacity-40 cursor-not-allowed",
|
||||
isActive
|
||||
? "border-[#d97706] bg-[#d97706] text-white shadow-[#d97706]/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">{option.label}</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{!selectedSpeedId && (
|
||||
<p className="text-[10px] text-slate-500 mt-2 text-center">Select a speed region to adjust</p>
|
||||
)}
|
||||
{selectedSpeedId && (
|
||||
<Button
|
||||
onClick={() => selectedSpeedId && onSpeedDelete?.(selectedSpeedId)}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="mt-2 w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all h-8 text-xs"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
Delete Speed Region
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Accordion type="multiple" defaultValue={["effects", "background"]} className="space-y-1">
|
||||
<AccordionItem value="effects" className="border-white/5 rounded-xl bg-white/[0.02] px-3">
|
||||
<AccordionTrigger className="py-2.5 hover:no-underline">
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
DEFAULT_ANNOTATION_SIZE,
|
||||
DEFAULT_ANNOTATION_STYLE,
|
||||
DEFAULT_FIGURE_DATA,
|
||||
DEFAULT_PLAYBACK_SPEED,
|
||||
type ZoomDepth,
|
||||
type ZoomFocus,
|
||||
type ZoomRegion,
|
||||
@@ -27,6 +28,8 @@ import {
|
||||
type AnnotationRegion,
|
||||
type CropRegion,
|
||||
type FigureData,
|
||||
type SpeedRegion,
|
||||
type PlaybackSpeed,
|
||||
} from "./types";
|
||||
import { VideoExporter, GifExporter, type ExportProgress, type ExportQuality, type ExportSettings, type ExportFormat, type GifFrameRate, type GifSizePreset, GIF_SIZE_PRESETS, calculateOutputDimensions } from "@/lib/exporter";
|
||||
import { type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils";
|
||||
@@ -53,6 +56,8 @@ export default function VideoEditor() {
|
||||
const [selectedZoomId, setSelectedZoomId] = useState<string | null>(null);
|
||||
const [trimRegions, setTrimRegions] = useState<TrimRegion[]>([]);
|
||||
const [selectedTrimId, setSelectedTrimId] = useState<string | null>(null);
|
||||
const [speedRegions, setSpeedRegions] = useState<SpeedRegion[]>([]);
|
||||
const [selectedSpeedId, setSelectedSpeedId] = useState<string | null>(null);
|
||||
const [annotationRegions, setAnnotationRegions] = useState<AnnotationRegion[]>([]);
|
||||
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
@@ -69,6 +74,7 @@ export default function VideoEditor() {
|
||||
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
|
||||
const nextZoomIdRef = useRef(1);
|
||||
const nextTrimIdRef = useRef(1);
|
||||
const nextSpeedIdRef = useRef(1);
|
||||
const nextAnnotationIdRef = useRef(1);
|
||||
const nextAnnotationZIndexRef = useRef(1); // Track z-index for stacking order
|
||||
const exporterRef = useRef<VideoExporter | null>(null);
|
||||
@@ -263,6 +269,60 @@ export default function VideoEditor() {
|
||||
}
|
||||
}, [selectedTrimId]);
|
||||
|
||||
const handleSelectSpeed = useCallback((id: string | null) => {
|
||||
setSelectedSpeedId(id);
|
||||
if (id) {
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSpeedAdded = useCallback((span: Span) => {
|
||||
const id = `speed-${nextSpeedIdRef.current++}`;
|
||||
const newRegion: SpeedRegion = {
|
||||
id,
|
||||
startMs: Math.round(span.start),
|
||||
endMs: Math.round(span.end),
|
||||
speed: DEFAULT_PLAYBACK_SPEED,
|
||||
};
|
||||
setSpeedRegions((prev) => [...prev, newRegion]);
|
||||
setSelectedSpeedId(id);
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
}, []);
|
||||
|
||||
const handleSpeedSpanChange = useCallback((id: string, span: Span) => {
|
||||
setSpeedRegions((prev) =>
|
||||
prev.map((region) =>
|
||||
region.id === id
|
||||
? {
|
||||
...region,
|
||||
startMs: Math.round(span.start),
|
||||
endMs: Math.round(span.end),
|
||||
}
|
||||
: region,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleSpeedDelete = useCallback((id: string) => {
|
||||
setSpeedRegions((prev) => prev.filter((region) => region.id !== id));
|
||||
if (selectedSpeedId === id) {
|
||||
setSelectedSpeedId(null);
|
||||
}
|
||||
}, [selectedSpeedId]);
|
||||
|
||||
const handleSpeedChange = useCallback((speed: PlaybackSpeed) => {
|
||||
if (!selectedSpeedId) return;
|
||||
setSpeedRegions((prev) =>
|
||||
prev.map((region) =>
|
||||
region.id === selectedSpeedId ? { ...region, speed } : region,
|
||||
),
|
||||
);
|
||||
}, [selectedSpeedId]);
|
||||
|
||||
const handleAnnotationAdded = useCallback((span: Span) => {
|
||||
const id = `annotation-${nextAnnotationIdRef.current++}`;
|
||||
const zIndex = nextAnnotationZIndexRef.current++; // Assign z-index based on creation order
|
||||
@@ -438,6 +498,12 @@ export default function VideoEditor() {
|
||||
}
|
||||
}, [selectedAnnotationId, annotationRegions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSpeedId && !speedRegions.some((region) => region.id === selectedSpeedId)) {
|
||||
setSelectedSpeedId(null);
|
||||
}
|
||||
}, [selectedSpeedId, speedRegions]);
|
||||
|
||||
const handleExport = useCallback(async (settings: ExportSettings) => {
|
||||
if (!videoPath) {
|
||||
toast.error('No video loaded');
|
||||
@@ -482,6 +548,7 @@ export default function VideoEditor() {
|
||||
wallpaper,
|
||||
zoomRegions,
|
||||
trimRegions,
|
||||
speedRegions,
|
||||
showShadow: shadowIntensity > 0,
|
||||
shadowIntensity,
|
||||
showBlur,
|
||||
@@ -608,6 +675,7 @@ export default function VideoEditor() {
|
||||
wallpaper,
|
||||
zoomRegions,
|
||||
trimRegions,
|
||||
speedRegions,
|
||||
showShadow: shadowIntensity > 0,
|
||||
shadowIntensity,
|
||||
showBlur,
|
||||
@@ -663,7 +731,7 @@ export default function VideoEditor() {
|
||||
setShowExportDialog(false);
|
||||
setExportProgress(null);
|
||||
}
|
||||
}, [videoPath, wallpaper, zoomRegions, trimRegions, shadowIntensity, showBlur, motionBlurEnabled, borderRadius, padding, cropRegion, annotationRegions, isPlaying, aspectRatio, exportQuality]);
|
||||
}, [videoPath, wallpaper, zoomRegions, trimRegions, speedRegions, shadowIntensity, showBlur, motionBlurEnabled, borderRadius, padding, cropRegion, annotationRegions, isPlaying, aspectRatio, exportQuality]);
|
||||
|
||||
const handleOpenExportDialog = useCallback(() => {
|
||||
if (!videoPath) {
|
||||
@@ -770,6 +838,7 @@ export default function VideoEditor() {
|
||||
padding={padding}
|
||||
cropRegion={cropRegion}
|
||||
trimRegions={trimRegions}
|
||||
speedRegions={speedRegions}
|
||||
annotationRegions={annotationRegions}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
onSelectAnnotation={handleSelectAnnotation}
|
||||
@@ -816,6 +885,12 @@ export default function VideoEditor() {
|
||||
onTrimDelete={handleTrimDelete}
|
||||
selectedTrimId={selectedTrimId}
|
||||
onSelectTrim={handleSelectTrim}
|
||||
speedRegions={speedRegions}
|
||||
onSpeedAdded={handleSpeedAdded}
|
||||
onSpeedSpanChange={handleSpeedSpanChange}
|
||||
onSpeedDelete={handleSpeedDelete}
|
||||
selectedSpeedId={selectedSpeedId}
|
||||
onSelectSpeed={handleSelectSpeed}
|
||||
annotationRegions={annotationRegions}
|
||||
onAnnotationAdded={handleAnnotationAdded}
|
||||
onAnnotationSpanChange={handleAnnotationSpanChange}
|
||||
@@ -878,6 +953,10 @@ export default function VideoEditor() {
|
||||
onAnnotationStyleChange={handleAnnotationStyleChange}
|
||||
onAnnotationFigureDataChange={handleAnnotationFigureDataChange}
|
||||
onAnnotationDelete={handleAnnotationDelete}
|
||||
selectedSpeedId={selectedSpeedId}
|
||||
selectedSpeedValue={selectedSpeedId ? speedRegions.find(r => r.id === selectedSpeedId)?.speed ?? null : null}
|
||||
onSpeedChange={handleSpeedChange}
|
||||
onSpeedDelete={handleSpeedDelete}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type React from "react";
|
||||
import { useEffect, useRef, useImperativeHandle, forwardRef, useState, useMemo, useCallback } from "react";
|
||||
import { getAssetPath } from "@/lib/assetPath";
|
||||
import { Application, Container, Sprite, Graphics, BlurFilter, Texture, VideoSource } from 'pixi.js';
|
||||
import { ZOOM_DEPTH_SCALES, type ZoomRegion, type ZoomFocus, type ZoomDepth, type TrimRegion, type AnnotationRegion } from "./types";
|
||||
import { ZOOM_DEPTH_SCALES, type ZoomRegion, type ZoomFocus, type ZoomDepth, type TrimRegion, type SpeedRegion, type AnnotationRegion } from "./types";
|
||||
import { DEFAULT_FOCUS, SMOOTHING_FACTOR, MIN_DELTA } from "./videoPlayback/constants";
|
||||
import { clamp01 } from "./videoPlayback/mathUtils";
|
||||
import { findDominantRegion } from "./videoPlayback/zoomRegionUtils";
|
||||
@@ -35,6 +35,7 @@ interface VideoPlaybackProps {
|
||||
padding?: number;
|
||||
cropRegion?: import('./types').CropRegion;
|
||||
trimRegions?: TrimRegion[];
|
||||
speedRegions?: SpeedRegion[];
|
||||
aspectRatio: AspectRatio;
|
||||
annotationRegions?: AnnotationRegion[];
|
||||
selectedAnnotationId?: string | null;
|
||||
@@ -74,6 +75,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
padding = 50,
|
||||
cropRegion,
|
||||
trimRegions = [],
|
||||
speedRegions = [],
|
||||
aspectRatio,
|
||||
annotationRegions = [],
|
||||
selectedAnnotationId,
|
||||
@@ -111,6 +113,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
const lockedVideoDimensionsRef = useRef<{ width: number; height: number } | null>(null);
|
||||
const layoutVideoContentRef = useRef<(() => void) | null>(null);
|
||||
const trimRegionsRef = useRef<TrimRegion[]>([]);
|
||||
const speedRegionsRef = useRef<SpeedRegion[]>([]);
|
||||
const motionBlurEnabledRef = useRef(motionBlurEnabled);
|
||||
const videoReadyRafRef = useRef<number | null>(null);
|
||||
|
||||
@@ -319,6 +322,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
trimRegionsRef.current = trimRegions;
|
||||
}, [trimRegions]);
|
||||
|
||||
useEffect(() => {
|
||||
speedRegionsRef.current = speedRegions;
|
||||
}, [speedRegions]);
|
||||
|
||||
useEffect(() => {
|
||||
motionBlurEnabledRef.current = motionBlurEnabled;
|
||||
}, [motionBlurEnabled]);
|
||||
@@ -557,6 +564,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
onPlayStateChange,
|
||||
onTimeUpdate,
|
||||
trimRegionsRef,
|
||||
speedRegionsRef,
|
||||
});
|
||||
|
||||
video.addEventListener('play', handlePlay);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo } from "react";
|
||||
import { useItem } from "dnd-timeline";
|
||||
import type { Span } from "dnd-timeline";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ZoomIn, Scissors, MessageSquare } from "lucide-react";
|
||||
import { ZoomIn, Scissors, MessageSquare, Gauge } from "lucide-react";
|
||||
import glassStyles from "./ItemGlass.module.css";
|
||||
|
||||
interface ItemProps {
|
||||
@@ -13,7 +13,8 @@ interface ItemProps {
|
||||
isSelected?: boolean;
|
||||
onSelect?: () => void;
|
||||
zoomDepth?: number;
|
||||
variant?: 'zoom' | 'trim' | 'annotation';
|
||||
speedValue?: number;
|
||||
variant?: 'zoom' | 'trim' | 'annotation' | 'speed';
|
||||
}
|
||||
|
||||
// Map zoom depth to multiplier labels
|
||||
@@ -36,13 +37,14 @@ function formatMs(ms: number): string {
|
||||
return `${seconds.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
export default function Item({
|
||||
id,
|
||||
span,
|
||||
rowId,
|
||||
isSelected = false,
|
||||
onSelect,
|
||||
export default function Item({
|
||||
id,
|
||||
span,
|
||||
rowId,
|
||||
isSelected = false,
|
||||
onSelect,
|
||||
zoomDepth = 1,
|
||||
speedValue,
|
||||
variant = 'zoom',
|
||||
children
|
||||
}: ItemProps) {
|
||||
@@ -54,17 +56,22 @@ export default function Item({
|
||||
|
||||
const isZoom = variant === 'zoom';
|
||||
const isTrim = variant === 'trim';
|
||||
const isSpeed = variant === 'speed';
|
||||
|
||||
const glassClass = isZoom
|
||||
? glassStyles.glassGreen
|
||||
: isTrim
|
||||
? glassStyles.glassRed
|
||||
: isSpeed
|
||||
? glassStyles.glassAmber
|
||||
: glassStyles.glassYellow;
|
||||
|
||||
const endCapColor = isZoom
|
||||
? '#21916A'
|
||||
: isTrim
|
||||
? '#ef4444'
|
||||
: isSpeed
|
||||
? '#d97706'
|
||||
: '#B4A046';
|
||||
|
||||
const timeLabel = useMemo(
|
||||
@@ -121,6 +128,13 @@ export default function Item({
|
||||
Trim
|
||||
</span>
|
||||
</>
|
||||
) : isSpeed ? (
|
||||
<>
|
||||
<Gauge className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
|
||||
{speedValue !== undefined ? `${speedValue}×` : 'Speed'}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MessageSquare className="w-3.5 h-3.5 shrink-0" />
|
||||
|
||||
@@ -76,6 +76,32 @@
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.glassAmber {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
-corner-smoothing: antialiased;
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
box-shadow: 0 2px 12px 0 rgba(245, 158, 11, 0.1) inset;
|
||||
margin: 2px 0;
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.glassAmber:hover {
|
||||
background: rgba(245, 158, 11, 0.25);
|
||||
border-color: rgba(245, 158, 11, 0.5);
|
||||
box-shadow: 0 4px 20px 0 rgba(245, 158, 11, 0.2) inset;
|
||||
}
|
||||
|
||||
.glassAmber.selected {
|
||||
background: rgba(245, 158, 11, 0.35);
|
||||
border-color: #f59e0b;
|
||||
box-shadow: 0 0 0 1px #f59e0b, 0 4px 20px 0 rgba(245, 158, 11, 0.3) inset;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.zoomEndCap {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -92,7 +118,9 @@
|
||||
.glassRed:hover .zoomEndCap,
|
||||
.glassRed.selected .zoomEndCap,
|
||||
.glassYellow:hover .zoomEndCap,
|
||||
.glassYellow.selected .zoomEndCap {
|
||||
.glassYellow.selected .zoomEndCap,
|
||||
.glassAmber:hover .zoomEndCap,
|
||||
.glassAmber.selected .zoomEndCap {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTimelineContext } from "dnd-timeline";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, Scissors, ZoomIn, MessageSquare, ChevronDown, Check } from "lucide-react";
|
||||
import { Plus, Scissors, ZoomIn, MessageSquare, ChevronDown, Check, Gauge } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import TimelineWrapper from "./TimelineWrapper";
|
||||
@@ -9,7 +9,7 @@ import Row from "./Row";
|
||||
import Item from "./Item";
|
||||
import KeyframeMarkers from "./KeyframeMarkers";
|
||||
import type { Range, Span } from "dnd-timeline";
|
||||
import type { ZoomRegion, TrimRegion, AnnotationRegion } from "../types";
|
||||
import type { ZoomRegion, TrimRegion, AnnotationRegion, SpeedRegion } from "../types";
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -24,6 +24,7 @@ import { TutorialHelp } from "../TutorialHelp";
|
||||
const ZOOM_ROW_ID = "row-zoom";
|
||||
const TRIM_ROW_ID = "row-trim";
|
||||
const ANNOTATION_ROW_ID = "row-annotation";
|
||||
const SPEED_ROW_ID = "row-speed";
|
||||
const FALLBACK_RANGE_MS = 1000;
|
||||
const TARGET_MARKER_COUNT = 12;
|
||||
|
||||
@@ -49,6 +50,12 @@ interface TimelineEditorProps {
|
||||
onAnnotationDelete?: (id: string) => void;
|
||||
selectedAnnotationId?: string | null;
|
||||
onSelectAnnotation?: (id: string | null) => void;
|
||||
speedRegions?: SpeedRegion[];
|
||||
onSpeedAdded?: (span: Span) => void;
|
||||
onSpeedSpanChange?: (id: string, span: Span) => void;
|
||||
onSpeedDelete?: (id: string) => void;
|
||||
selectedSpeedId?: string | null;
|
||||
onSelectSpeed?: (id: string | null) => void;
|
||||
aspectRatio: AspectRatio;
|
||||
onAspectRatioChange: (aspectRatio: AspectRatio) => void;
|
||||
}
|
||||
@@ -67,7 +74,8 @@ interface TimelineRenderItem {
|
||||
span: Span;
|
||||
label: string;
|
||||
zoomDepth?: number;
|
||||
variant: 'zoom' | 'trim' | 'annotation';
|
||||
speedValue?: number;
|
||||
variant: 'zoom' | 'trim' | 'annotation' | 'speed';
|
||||
}
|
||||
|
||||
const SCALE_CANDIDATES = [
|
||||
@@ -396,9 +404,11 @@ function Timeline({
|
||||
onSelectZoom,
|
||||
onSelectTrim,
|
||||
onSelectAnnotation,
|
||||
onSelectSpeed,
|
||||
selectedZoomId,
|
||||
selectedTrimId,
|
||||
selectedAnnotationId,
|
||||
selectedSpeedId,
|
||||
keyframes = [],
|
||||
}: {
|
||||
items: TimelineRenderItem[];
|
||||
@@ -409,9 +419,11 @@ function Timeline({
|
||||
onSelectZoom?: (id: string | null) => void;
|
||||
onSelectTrim?: (id: string | null) => void;
|
||||
onSelectAnnotation?: (id: string | null) => void;
|
||||
onSelectSpeed?: (id: string | null) => void;
|
||||
selectedZoomId: string | null;
|
||||
selectedTrimId?: string | null;
|
||||
selectedAnnotationId?: string | null;
|
||||
selectedSpeedId?: string | null;
|
||||
keyframes?: { id: string; time: number }[];
|
||||
}) {
|
||||
const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext();
|
||||
@@ -430,6 +442,7 @@ function Timeline({
|
||||
onSelectZoom?.(null);
|
||||
onSelectTrim?.(null);
|
||||
onSelectAnnotation?.(null);
|
||||
onSelectSpeed?.(null);
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const clickX = e.clientX - rect.left - sidebarWidth;
|
||||
@@ -441,11 +454,12 @@ function Timeline({
|
||||
const timeInSeconds = absoluteMs / 1000;
|
||||
|
||||
onSeek(timeInSeconds);
|
||||
}, [onSeek, onSelectZoom, onSelectTrim, onSelectAnnotation, videoDurationMs, sidebarWidth, range.start, pixelsToValue]);
|
||||
}, [onSeek, onSelectZoom, onSelectTrim, onSelectAnnotation, onSelectSpeed, videoDurationMs, sidebarWidth, range.start, pixelsToValue]);
|
||||
|
||||
const zoomItems = items.filter(item => item.rowId === ZOOM_ROW_ID);
|
||||
const trimItems = items.filter(item => item.rowId === TRIM_ROW_ID);
|
||||
const annotationItems = items.filter(item => item.rowId === ANNOTATION_ROW_ID);
|
||||
const speedItems = items.filter(item => item.rowId === SPEED_ROW_ID);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -512,6 +526,23 @@ function Timeline({
|
||||
</Item>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Row id={SPEED_ROW_ID} isEmpty={speedItems.length === 0} hint="Press S to add speed">
|
||||
{speedItems.map((item) => (
|
||||
<Item
|
||||
id={item.id}
|
||||
key={item.id}
|
||||
rowId={item.rowId}
|
||||
span={item.span}
|
||||
isSelected={item.id === selectedSpeedId}
|
||||
onSelect={() => onSelectSpeed?.(item.id)}
|
||||
variant="speed"
|
||||
speedValue={item.speedValue}
|
||||
>
|
||||
{item.label}
|
||||
</Item>
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -538,6 +569,12 @@ export default function TimelineEditor({
|
||||
onAnnotationDelete,
|
||||
selectedAnnotationId,
|
||||
onSelectAnnotation,
|
||||
speedRegions = [],
|
||||
onSpeedAdded,
|
||||
onSpeedSpanChange,
|
||||
onSpeedDelete,
|
||||
selectedSpeedId,
|
||||
onSelectSpeed,
|
||||
aspectRatio,
|
||||
onAspectRatioChange,
|
||||
}: TimelineEditorProps) {
|
||||
@@ -606,6 +643,12 @@ export default function TimelineEditor({
|
||||
onSelectAnnotation(null);
|
||||
}, [selectedAnnotationId, onAnnotationDelete, onSelectAnnotation]);
|
||||
|
||||
const deleteSelectedSpeed = useCallback(() => {
|
||||
if (!selectedSpeedId || !onSpeedDelete || !onSelectSpeed) return;
|
||||
onSpeedDelete(selectedSpeedId);
|
||||
onSelectSpeed(null);
|
||||
}, [selectedSpeedId, onSpeedDelete, onSelectSpeed]);
|
||||
|
||||
useEffect(() => {
|
||||
setRange(createInitialRange(totalMs));
|
||||
}, [totalMs]);
|
||||
@@ -615,8 +658,10 @@ export default function TimelineEditor({
|
||||
// this effect on every drag/resize and races with dnd-timeline's internal state.
|
||||
const zoomRegionsRef = useRef(zoomRegions);
|
||||
const trimRegionsRef = useRef(trimRegions);
|
||||
const speedRegionsRef = useRef(speedRegions);
|
||||
zoomRegionsRef.current = zoomRegions;
|
||||
trimRegionsRef.current = trimRegions;
|
||||
speedRegionsRef.current = speedRegions;
|
||||
|
||||
useEffect(() => {
|
||||
if (totalMs === 0 || safeMinDurationMs <= 0) {
|
||||
@@ -646,21 +691,34 @@ export default function TimelineEditor({
|
||||
onTrimSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd });
|
||||
}
|
||||
});
|
||||
|
||||
speedRegionsRef.current.forEach((region) => {
|
||||
const clampedStart = Math.max(0, Math.min(region.startMs, totalMs));
|
||||
const minEnd = clampedStart + safeMinDurationMs;
|
||||
const clampedEnd = Math.min(totalMs, Math.max(minEnd, region.endMs));
|
||||
const normalizedStart = Math.max(0, Math.min(clampedStart, totalMs - safeMinDurationMs));
|
||||
const normalizedEnd = Math.max(minEnd, Math.min(clampedEnd, totalMs));
|
||||
|
||||
if (normalizedStart !== region.startMs || normalizedEnd !== region.endMs) {
|
||||
onSpeedSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd });
|
||||
}
|
||||
});
|
||||
// Only re-run when the timeline scale changes, not on every region edit
|
||||
}, [totalMs, safeMinDurationMs, onZoomSpanChange, onTrimSpanChange]);
|
||||
}, [totalMs, safeMinDurationMs, onZoomSpanChange, onTrimSpanChange, onSpeedSpanChange]);
|
||||
|
||||
const hasOverlap = useCallback((newSpan: Span, excludeId?: string): boolean => {
|
||||
// Determine which row the item belongs to
|
||||
const isZoomItem = zoomRegions.some(r => r.id === excludeId);
|
||||
const isTrimItem = trimRegions.some(r => r.id === excludeId);
|
||||
const isAnnotationItem = annotationRegions.some(r => r.id === excludeId);
|
||||
const isSpeedItem = speedRegions.some(r => r.id === excludeId);
|
||||
|
||||
if (isAnnotationItem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Helper to check overlap against a specific set of regions
|
||||
const checkOverlap = (regions: (ZoomRegion | TrimRegion)[]) => {
|
||||
const checkOverlap = (regions: (ZoomRegion | TrimRegion | SpeedRegion)[]) => {
|
||||
return regions.some((region) => {
|
||||
if (region.id === excludeId) return false;
|
||||
// True overlap: regions actually intersect (not just adjacent)
|
||||
@@ -676,8 +734,12 @@ export default function TimelineEditor({
|
||||
return checkOverlap(trimRegions);
|
||||
}
|
||||
|
||||
if (isSpeedItem) {
|
||||
return checkOverlap(speedRegions);
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [zoomRegions, trimRegions, annotationRegions]);
|
||||
}, [zoomRegions, trimRegions, annotationRegions, speedRegions]);
|
||||
|
||||
// At least 5% of the timeline or 1000ms, whichever is larger, so the region
|
||||
// is always wide enough to grab and resize comfortably.
|
||||
@@ -746,6 +808,36 @@ export default function TimelineEditor({
|
||||
onTrimAdded({ start: startPos, end: startPos + actualDuration });
|
||||
}, [videoDuration, totalMs, currentTimeMs, trimRegions, onTrimAdded, defaultRegionDurationMs]);
|
||||
|
||||
const handleAddSpeed = useCallback(() => {
|
||||
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onSpeedAdded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
|
||||
if (defaultDuration <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Always place speed region at playhead
|
||||
const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
|
||||
// Find the next speed region after the playhead
|
||||
const sorted = [...speedRegions].sort((a, b) => a.startMs - b.startMs);
|
||||
const nextRegion = sorted.find(region => region.startMs > startPos);
|
||||
const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos;
|
||||
|
||||
// Check if playhead is inside any speed region
|
||||
const isOverlapping = sorted.some(region => startPos >= region.startMs && startPos < region.endMs);
|
||||
if (isOverlapping || gapToNext <= 0) {
|
||||
toast.error("Cannot place speed here", {
|
||||
description: "Speed region already exists at this location or not enough space available.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
|
||||
onSpeedAdded({ start: startPos, end: startPos + actualDuration });
|
||||
}, [videoDuration, totalMs, currentTimeMs, speedRegions, onSpeedAdded, defaultRegionDurationMs]);
|
||||
|
||||
const handleAddAnnotation = useCallback(() => {
|
||||
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAnnotationAdded) {
|
||||
return;
|
||||
@@ -781,6 +873,9 @@ export default function TimelineEditor({
|
||||
if (e.key === 'a' || e.key === 'A') {
|
||||
handleAddAnnotation();
|
||||
}
|
||||
if (e.key === 's' || e.key === 'S') {
|
||||
handleAddSpeed();
|
||||
}
|
||||
|
||||
// Tab: Cycle through overlapping annotations at current time
|
||||
if (e.key === 'Tab' && annotationRegions.length > 0) {
|
||||
@@ -814,12 +909,14 @@ export default function TimelineEditor({
|
||||
deleteSelectedTrim();
|
||||
} else if (selectedAnnotationId) {
|
||||
deleteSelectedAnnotation();
|
||||
} else if (selectedSpeedId) {
|
||||
deleteSelectedSpeed();
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [addKeyframe, handleAddZoom, handleAddTrim, handleAddAnnotation, deleteSelectedKeyframe, deleteSelectedZoom, deleteSelectedTrim, deleteSelectedAnnotation, selectedKeyframeId, selectedZoomId, selectedTrimId, selectedAnnotationId, annotationRegions, currentTime, onSelectAnnotation]);
|
||||
}, [addKeyframe, handleAddZoom, handleAddTrim, handleAddAnnotation, handleAddSpeed, deleteSelectedKeyframe, deleteSelectedZoom, deleteSelectedTrim, deleteSelectedAnnotation, deleteSelectedSpeed, selectedKeyframeId, selectedZoomId, selectedTrimId, selectedAnnotationId, selectedSpeedId, annotationRegions, currentTime, onSelectAnnotation]);
|
||||
|
||||
const clampedRange = useMemo<Range>(() => {
|
||||
if (totalMs === 0) {
|
||||
@@ -872,26 +969,38 @@ export default function TimelineEditor({
|
||||
};
|
||||
});
|
||||
|
||||
return [...zooms, ...trims, ...annotations];
|
||||
}, [zoomRegions, trimRegions, annotationRegions]);
|
||||
const speeds: TimelineRenderItem[] = speedRegions.map((region, index) => ({
|
||||
id: region.id,
|
||||
rowId: SPEED_ROW_ID,
|
||||
span: { start: region.startMs, end: region.endMs },
|
||||
label: `Speed ${index + 1}`,
|
||||
speedValue: region.speed,
|
||||
variant: 'speed',
|
||||
}));
|
||||
|
||||
return [...zooms, ...trims, ...annotations, ...speeds];
|
||||
}, [zoomRegions, trimRegions, annotationRegions, speedRegions]);
|
||||
|
||||
// Flat list of all non-annotation region spans for neighbour-clamping during drag/resize
|
||||
const allRegionSpans = useMemo(() => {
|
||||
const zooms = zoomRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs }));
|
||||
const trims = trimRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs }));
|
||||
return [...zooms, ...trims];
|
||||
}, [zoomRegions, trimRegions]);
|
||||
const speeds = speedRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs }));
|
||||
return [...zooms, ...trims, ...speeds];
|
||||
}, [zoomRegions, trimRegions, speedRegions]);
|
||||
|
||||
const handleItemSpanChange = useCallback((id: string, span: Span) => {
|
||||
// Check if it's a zoom or trim item
|
||||
// Check if it's a zoom, trim, speed, or annotation item
|
||||
if (zoomRegions.some(r => r.id === id)) {
|
||||
onZoomSpanChange(id, span);
|
||||
} else if (trimRegions.some(r => r.id === id)) {
|
||||
onTrimSpanChange?.(id, span);
|
||||
} else if (speedRegions.some(r => r.id === id)) {
|
||||
onSpeedSpanChange?.(id, span);
|
||||
} else if (annotationRegions.some(r => r.id === id)) {
|
||||
onAnnotationSpanChange?.(id, span);
|
||||
}
|
||||
}, [zoomRegions, trimRegions, annotationRegions, onZoomSpanChange, onTrimSpanChange, onAnnotationSpanChange]);
|
||||
}, [zoomRegions, trimRegions, speedRegions, annotationRegions, onZoomSpanChange, onTrimSpanChange, onSpeedSpanChange, onAnnotationSpanChange]);
|
||||
|
||||
if (!videoDuration || videoDuration === 0) {
|
||||
return (
|
||||
@@ -938,6 +1047,15 @@ export default function TimelineEditor({
|
||||
>
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAddSpeed}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-slate-400 hover:text-[#d97706] hover:bg-[#d97706]/10 transition-all"
|
||||
title="Add Speed (S)"
|
||||
>
|
||||
<Gauge className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DropdownMenu>
|
||||
@@ -1012,11 +1130,12 @@ export default function TimelineEditor({
|
||||
onSelectZoom={onSelectZoom}
|
||||
onSelectTrim={onSelectTrim}
|
||||
onSelectAnnotation={onSelectAnnotation}
|
||||
onSelectSpeed={onSelectSpeed}
|
||||
selectedZoomId={selectedZoomId}
|
||||
selectedTrimId={selectedTrimId}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
selectedSpeedId={selectedSpeedId}
|
||||
keyframes={keyframes}
|
||||
|
||||
/>
|
||||
</TimelineWrapper>
|
||||
</div>
|
||||
|
||||
@@ -108,6 +108,27 @@ export const DEFAULT_CROP_REGION: CropRegion = {
|
||||
height: 1,
|
||||
};
|
||||
|
||||
export type PlaybackSpeed = 0.25 | 0.5 | 0.75 | 1.25 | 1.5 | 1.75 | 2;
|
||||
|
||||
export interface SpeedRegion {
|
||||
id: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
speed: PlaybackSpeed;
|
||||
}
|
||||
|
||||
export const SPEED_OPTIONS: Array<{ speed: PlaybackSpeed; label: string }> = [
|
||||
{ speed: 0.25, label: "0.25×" },
|
||||
{ speed: 0.5, label: "0.5×" },
|
||||
{ speed: 0.75, label: "0.75×" },
|
||||
{ speed: 1.25, label: "1.25×" },
|
||||
{ speed: 1.5, label: "1.5×" },
|
||||
{ speed: 1.75, label: "1.75×" },
|
||||
{ speed: 2, label: "2×" },
|
||||
];
|
||||
|
||||
export const DEFAULT_PLAYBACK_SPEED: PlaybackSpeed = 1.5;
|
||||
|
||||
export const ZOOM_DEPTH_SCALES: Record<ZoomDepth, number> = {
|
||||
1: 1.25,
|
||||
2: 1.5,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type React from 'react';
|
||||
import type { TrimRegion } from '../types';
|
||||
import type { TrimRegion, SpeedRegion } from '../types';
|
||||
|
||||
interface VideoEventHandlersParams {
|
||||
video: HTMLVideoElement;
|
||||
@@ -11,6 +11,7 @@ interface VideoEventHandlersParams {
|
||||
onPlayStateChange: (playing: boolean) => void;
|
||||
onTimeUpdate: (time: number) => void;
|
||||
trimRegionsRef: React.MutableRefObject<TrimRegion[]>;
|
||||
speedRegionsRef: React.MutableRefObject<SpeedRegion[]>;
|
||||
}
|
||||
|
||||
export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
||||
@@ -24,6 +25,7 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
||||
onPlayStateChange,
|
||||
onTimeUpdate,
|
||||
trimRegionsRef,
|
||||
speedRegionsRef,
|
||||
} = params;
|
||||
|
||||
const emitTime = (timeValue: number) => {
|
||||
@@ -39,16 +41,23 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
||||
) || null;
|
||||
};
|
||||
|
||||
// Helper function to find the active speed region at the current time
|
||||
const findActiveSpeedRegion = (currentTimeMs: number): SpeedRegion | null => {
|
||||
return speedRegionsRef.current.find(
|
||||
(region) => currentTimeMs >= region.startMs && currentTimeMs < region.endMs
|
||||
) || null;
|
||||
};
|
||||
|
||||
function updateTime() {
|
||||
if (!video) return;
|
||||
|
||||
|
||||
const currentTimeMs = video.currentTime * 1000;
|
||||
const activeTrimRegion = findActiveTrimRegion(currentTimeMs);
|
||||
|
||||
|
||||
// If we're in a trim region during playback, skip to the end of it
|
||||
if (activeTrimRegion && !video.paused && !video.ended) {
|
||||
const skipToTime = activeTrimRegion.endMs / 1000;
|
||||
|
||||
|
||||
// If the skip would take us past the video duration, pause instead
|
||||
if (skipToTime >= video.duration) {
|
||||
video.pause();
|
||||
@@ -57,9 +66,12 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
||||
emitTime(skipToTime);
|
||||
}
|
||||
} else {
|
||||
// Apply playback speed from active speed region
|
||||
const activeSpeedRegion = findActiveSpeedRegion(currentTimeMs);
|
||||
video.playbackRate = activeSpeedRegion ? activeSpeedRegion.speed : 1;
|
||||
emitTime(video.currentTime);
|
||||
}
|
||||
|
||||
|
||||
if (!video.paused && !video.ended) {
|
||||
timeUpdateAnimationRef.current = requestAnimationFrame(updateTime);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Application, Container, Sprite, Graphics, BlurFilter, Texture } from 'pixi.js';
|
||||
import type { ZoomRegion, CropRegion, AnnotationRegion } from '@/components/video-editor/types';
|
||||
import type { ZoomRegion, CropRegion, AnnotationRegion, SpeedRegion } from '@/components/video-editor/types';
|
||||
import { ZOOM_DEPTH_SCALES } from '@/components/video-editor/types';
|
||||
import { findDominantRegion } from '@/components/video-editor/videoPlayback/zoomRegionUtils';
|
||||
import { applyZoomTransform } from '@/components/video-editor/videoPlayback/zoomTransform';
|
||||
@@ -22,6 +22,7 @@ interface FrameRenderConfig {
|
||||
videoWidth: number;
|
||||
videoHeight: number;
|
||||
annotationRegions?: AnnotationRegion[];
|
||||
speedRegions?: SpeedRegion[];
|
||||
previewWidth?: number;
|
||||
previewHeight?: number;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 } from '@/components/video-editor/types';
|
||||
import type { ZoomRegion, CropRegion, TrimRegion, AnnotationRegion, SpeedRegion } from '@/components/video-editor/types';
|
||||
|
||||
const GIF_WORKER_URL = new URL('gif.js/dist/gif.worker.js', import.meta.url).toString();
|
||||
|
||||
@@ -16,6 +16,7 @@ interface GifExporterConfig {
|
||||
wallpaper: string;
|
||||
zoomRegions: ZoomRegion[];
|
||||
trimRegions?: TrimRegion[];
|
||||
speedRegions?: SpeedRegion[];
|
||||
showShadow: boolean;
|
||||
shadowIntensity: number;
|
||||
showBlur: boolean;
|
||||
@@ -100,6 +101,7 @@ export class GifExporter {
|
||||
videoWidth: videoInfo.width,
|
||||
videoHeight: videoInfo.height,
|
||||
annotationRegions: this.config.annotationRegions,
|
||||
speedRegions: this.config.speedRegions,
|
||||
previewWidth: this.config.previewWidth,
|
||||
previewHeight: this.config.previewHeight,
|
||||
});
|
||||
|
||||
@@ -2,13 +2,14 @@ import type { ExportConfig, ExportProgress, ExportResult } from './types';
|
||||
import { StreamingVideoDecoder } from './streamingDecoder';
|
||||
import { FrameRenderer } from './frameRenderer';
|
||||
import { VideoMuxer } from './muxer';
|
||||
import type { ZoomRegion, CropRegion, TrimRegion, AnnotationRegion } from '@/components/video-editor/types';
|
||||
import type { ZoomRegion, CropRegion, TrimRegion, AnnotationRegion, SpeedRegion } from '@/components/video-editor/types';
|
||||
|
||||
interface VideoExporterConfig extends ExportConfig {
|
||||
videoUrl: string;
|
||||
wallpaper: string;
|
||||
zoomRegions: ZoomRegion[];
|
||||
trimRegions?: TrimRegion[];
|
||||
speedRegions?: SpeedRegion[];
|
||||
showShadow: boolean;
|
||||
shadowIntensity: number;
|
||||
showBlur: boolean;
|
||||
@@ -68,6 +69,7 @@ export class VideoExporter {
|
||||
videoWidth: videoInfo.width,
|
||||
videoHeight: videoInfo.height,
|
||||
annotationRegions: this.config.annotationRegions,
|
||||
speedRegions: this.config.speedRegions,
|
||||
previewWidth: this.config.previewWidth,
|
||||
previewHeight: this.config.previewHeight,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user