From 397a9434261d7d3d7a172b458034922546444325 Mon Sep 17 00:00:00 2001 From: Brodypen Date: Sat, 28 Feb 2026 01:20:04 -0600 Subject: [PATCH] feat: speed thing --- src/components/video-editor/SettingsPanel.tsx | 59 ++++++- src/components/video-editor/VideoEditor.tsx | 81 +++++++++- src/components/video-editor/VideoPlayback.tsx | 10 +- src/components/video-editor/timeline/Item.tsx | 30 +++- .../timeline/ItemGlass.module.css | 30 +++- .../video-editor/timeline/TimelineEditor.tsx | 149 ++++++++++++++++-- src/components/video-editor/types.ts | 21 +++ .../videoPlayback/videoEventHandlers.ts | 22 ++- src/lib/exporter/frameRenderer.ts | 3 +- src/lib/exporter/gifExporter.ts | 4 +- src/lib/exporter/videoExporter.ts | 4 +- 11 files changed, 378 insertions(+), 35 deletions(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index db5e9d8..b2d5c47 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -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) => 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([]); const [customImages, setCustomImages] = useState([]); @@ -321,6 +330,54 @@ export function SettingsPanel({ )} +
+
+ Playback Speed + {selectedSpeedId && selectedSpeedValue && ( + + {SPEED_OPTIONS.find(o => o.speed === selectedSpeedValue)?.label ?? `${selectedSpeedValue}×`} + + )} +
+
+ {SPEED_OPTIONS.map((option) => { + const isActive = selectedSpeedValue === option.speed; + return ( + + ); + })} +
+ {!selectedSpeedId && ( +

Select a speed region to adjust

+ )} + {selectedSpeedId && ( + + )} +
+ diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index c0a038e..c5b8af2 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -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(null); const [trimRegions, setTrimRegions] = useState([]); const [selectedTrimId, setSelectedTrimId] = useState(null); + const [speedRegions, setSpeedRegions] = useState([]); + const [selectedSpeedId, setSelectedSpeedId] = useState(null); const [annotationRegions, setAnnotationRegions] = useState([]); const [selectedAnnotationId, setSelectedAnnotationId] = useState(null); const [isExporting, setIsExporting] = useState(false); @@ -69,6 +74,7 @@ export default function VideoEditor() { const videoPlaybackRef = useRef(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(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} /> diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 4dc491e..2801f1a 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -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(({ padding = 50, cropRegion, trimRegions = [], + speedRegions = [], aspectRatio, annotationRegions = [], selectedAnnotationId, @@ -111,6 +113,7 @@ const VideoPlayback = forwardRef(({ const lockedVideoDimensionsRef = useRef<{ width: number; height: number } | null>(null); const layoutVideoContentRef = useRef<(() => void) | null>(null); const trimRegionsRef = useRef([]); + const speedRegionsRef = useRef([]); const motionBlurEnabledRef = useRef(motionBlurEnabled); const videoReadyRafRef = useRef(null); @@ -319,6 +322,10 @@ const VideoPlayback = forwardRef(({ trimRegionsRef.current = trimRegions; }, [trimRegions]); + useEffect(() => { + speedRegionsRef.current = speedRegions; + }, [speedRegions]); + useEffect(() => { motionBlurEnabledRef.current = motionBlurEnabled; }, [motionBlurEnabled]); @@ -557,6 +564,7 @@ const VideoPlayback = forwardRef(({ onPlayStateChange, onTimeUpdate, trimRegionsRef, + speedRegionsRef, }); video.addEventListener('play', handlePlay); diff --git a/src/components/video-editor/timeline/Item.tsx b/src/components/video-editor/timeline/Item.tsx index ed5fc8b..6f9a706 100644 --- a/src/components/video-editor/timeline/Item.tsx +++ b/src/components/video-editor/timeline/Item.tsx @@ -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 + ) : isSpeed ? ( + <> + + + {speedValue !== undefined ? `${speedValue}×` : 'Speed'} + + ) : ( <> diff --git a/src/components/video-editor/timeline/ItemGlass.module.css b/src/components/video-editor/timeline/ItemGlass.module.css index d89cc0e..92796be 100644 --- a/src/components/video-editor/timeline/ItemGlass.module.css +++ b/src/components/video-editor/timeline/ItemGlass.module.css @@ -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; } diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 9b091ef..7a0d478 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -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 (
))} + + + {speedItems.map((item) => ( + onSelectSpeed?.(item.id)} + variant="speed" + speedValue={item.speedValue} + > + {item.label} + + ))} +
); } @@ -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(() => { 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({ > +
@@ -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} - />
diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index e138d75..f1e8c09 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -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 = { 1: 1.25, 2: 1.5, diff --git a/src/components/video-editor/videoPlayback/videoEventHandlers.ts b/src/components/video-editor/videoPlayback/videoEventHandlers.ts index 8a55545..c5d92ee 100644 --- a/src/components/video-editor/videoPlayback/videoEventHandlers.ts +++ b/src/components/video-editor/videoPlayback/videoEventHandlers.ts @@ -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; + speedRegionsRef: React.MutableRefObject; } 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); } diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 0e7cfe2..8dbae17 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -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; } diff --git a/src/lib/exporter/gifExporter.ts b/src/lib/exporter/gifExporter.ts index bf7a5f4..382a010 100644 --- a/src/lib/exporter/gifExporter.ts +++ b/src/lib/exporter/gifExporter.ts @@ -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, }); diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index 937c4a3..8c513b2 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -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, });