From 397a9434261d7d3d7a172b458034922546444325 Mon Sep 17 00:00:00 2001 From: Brodypen Date: Sat, 28 Feb 2026 01:20:04 -0600 Subject: [PATCH 1/3] 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, }); From 185969a9d1fc67e236f3c6d71c5b7894ded8c4a1 Mon Sep 17 00:00:00 2001 From: Brodypen Date: Sat, 28 Feb 2026 01:27:01 -0600 Subject: [PATCH 2/3] build: package-lock stuff --- package-lock.json | 78 +++++++++++++++++++++++++---------------------- 1 file changed, 42 insertions(+), 36 deletions(-) diff --git a/package-lock.json b/package-lock.json index c0183e3..7bf3c08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -113,7 +113,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -342,7 +341,6 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -1317,6 +1315,7 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, + "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1338,6 +1337,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -1354,6 +1354,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -1368,6 +1369,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 10.0.0" } @@ -2051,7 +2053,6 @@ "integrity": "sha512-LTATglVUPGkPf15zX1wTMlZ0+AU7cGEGF6ekVF1crA8eHUWsGjrYTB+Ht4E3HTrCok8weQG+K01rJndCp/l4XA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.7.2", "@jimp/core": "^0.16.13" @@ -2094,7 +2095,6 @@ "integrity": "sha512-8Z1k96ZFxlhK2bgrY1JNWNwvaBeI/bciLM0yDOni2+aZwfIIiC7Y6PeWHTAvjHNjphz+XCt01WQmOYWCn0ML6g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.7.2", "@jimp/utils": "^0.16.13" @@ -2109,7 +2109,6 @@ "integrity": "sha512-PvLrfa8vkej3qinlebyhLpksJgCF5aiysDMSVhOZqwH5nQLLtDE9WYbnsofGw4r0VVpyw3H/ANCIzYTyCtP9Cg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.7.2", "@jimp/utils": "^0.16.13" @@ -2138,7 +2137,6 @@ "integrity": "sha512-xW+9BtEvoIkkH/Wde9ql4nAFbYLkVINhpgAE7VcBUsuuB34WUbcBl/taOuUYQrPEFQJ4jfXiAJZ2H/rvKjCVnQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.7.2", "@jimp/utils": "^0.16.13", @@ -2188,7 +2186,6 @@ "integrity": "sha512-WEl2tPVYwzYL8OKme6Go2xqiWgKsgxlMwyHabdAU4tXaRwOCnOI7v4021gCcBb9zn/oWwguHuKHmK30Fw2Z/PA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.7.2", "@jimp/utils": "^0.16.13" @@ -2332,7 +2329,6 @@ "integrity": "sha512-qoqtN8LDknm3fJm9nuPygJv30O3vGhSBD2TxrsCnhtOsxKAqVPJtFVdGd/qVuZ8nqQANQmTlfqTiK9mVWQ7MiQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.7.2", "@jimp/utils": "^0.16.13" @@ -2347,7 +2343,6 @@ "integrity": "sha512-Ev+Jjmj1nHYw897z9C3R9dYsPv7S2/nxdgfFb/h8hOwK0Ovd1k/+yYS46A0uj/JCKK0pQk8wOslYBkPwdnLorw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.7.2", "@jimp/utils": "^0.16.13" @@ -2365,7 +2360,6 @@ "integrity": "sha512-05POQaEJVucjTiSGMoH68ZiELc7QqpIpuQlZ2JBbhCV+WCbPFUBcGSmE7w4Jd0E2GvCho/NoMODLwgcVGQA97A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.7.2", "@jimp/utils": "^0.16.13" @@ -2792,6 +2786,7 @@ "resolved": "https://registry.npmjs.org/@pixi/color/-/color-7.4.3.tgz", "integrity": "sha512-a6R+bXKeXMDcRmjYQoBIK+v2EYqxSX49wcjAY579EYM/WrFKS98nSees6lqVUcLKrcQh2DT9srJHX7XMny3voQ==", "license": "MIT", + "peer": true, "dependencies": { "@pixi/colord": "^2.9.6" } @@ -2806,7 +2801,8 @@ "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/constants/-/constants-7.4.3.tgz", "integrity": "sha512-QGmwJUNQy/vVEHzL6VGQvnwawLZ1wceZMI8HwJAT4/I2uAzbBeFDdmCS8WsTpSWLZjF/DszDc1D8BFp4pVJ5UQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@pixi/core": { "version": "7.4.3", @@ -2833,7 +2829,8 @@ "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/extensions/-/extensions-7.4.3.tgz", "integrity": "sha512-FhoiYkHQEDYHUE7wXhqfsTRz6KxLXjuMbSiAwnLb9uG1vAgp6q6qd6HEsf4X30YaZbLFY8a4KY6hFZWjF+4Fdw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@pixi/filter-drop-shadow": { "version": "5.2.0", @@ -2860,19 +2857,22 @@ "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/math/-/math-7.4.3.tgz", "integrity": "sha512-/uJOVhR2DOZ+zgdI6Bs/CwcXT4bNRKsS+TqX3ekRIxPCwaLra+Qdm7aDxT5cTToDzdxbKL5+rwiLu3Y1egILDw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@pixi/runner": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/runner/-/runner-7.4.3.tgz", "integrity": "sha512-TJyfp7y23u5vvRAyYhVSa7ytq0PdKSvPLXu4G3meoFh1oxTLHH6g/RIzLuxUAThPG2z7ftthuW3qWq6dRV+dhw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@pixi/settings": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/settings/-/settings-7.4.3.tgz", "integrity": "sha512-SmGK8smc0PxRB9nr0UJioEtE9hl4gvj9OedCvZx3bxBwA3omA5BmP3CyhQfN8XJ29+o2OUL01r3zAPVol4l4lA==", "license": "MIT", + "peer": true, "dependencies": { "@pixi/constants": "7.4.3", "@types/css-font-loading-module": "^0.0.12", @@ -2884,6 +2884,7 @@ "resolved": "https://registry.npmjs.org/@pixi/ticker/-/ticker-7.4.3.tgz", "integrity": "sha512-tHsAD0iOUb6QSGGw+c8cyRBvxsq/NlfzIFBZLEHhWZ+Bx4a0MmXup6I/yJDGmyPCYE+ctCcAfY13wKAzdiVFgQ==", "license": "MIT", + "peer": true, "dependencies": { "@pixi/extensions": "7.4.3", "@pixi/settings": "7.4.3", @@ -2895,6 +2896,7 @@ "resolved": "https://registry.npmjs.org/@pixi/utils/-/utils-7.4.3.tgz", "integrity": "sha512-NO3Y9HAn2UKS1YdxffqsPp+kDpVm8XWvkZcS/E+rBzY9VTLnNOI7cawSRm+dacdET3a8Jad3aDKEDZ0HmAqAFA==", "license": "MIT", + "peer": true, "dependencies": { "@pixi/color": "7.4.3", "@pixi/constants": "7.4.3", @@ -2909,19 +2911,22 @@ "version": "2.1.4", "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-2.1.4.tgz", "integrity": "sha512-qp3m9PPz4gULB9MhjGID7wpo3gJ4bTGXm7ltNDsmOvsPduTeHp8wSW9YckBj3mljeOh4F0m2z/0JKAALRKbmLQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@pixi/utils/node_modules/earcut": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/@pixi/utils/node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", @@ -4402,7 +4407,6 @@ "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -4414,7 +4418,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -4723,7 +4726,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5528,7 +5530,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -5841,6 +5842,7 @@ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "license": "MIT", + "peer": true, "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" @@ -6283,7 +6285,8 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -6579,7 +6582,6 @@ "integrity": "sha512-uOOBA3f+kW3o4KpSoMQ6SNpdXU7WtxlJRb9vCZgOvqhTz4b3GjcoWKstdisizNZLsylhTMv8TLHFPFW0Uxsj/g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "26.7.0", "builder-util": "26.4.1", @@ -7006,6 +7008,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -7026,6 +7029,7 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -8666,7 +8670,6 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -9825,6 +9828,7 @@ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4" }, @@ -10414,7 +10418,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -10559,6 +10562,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -10576,6 +10580,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -10724,6 +10729,7 @@ "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", "license": "BSD-3-Clause", + "peer": true, "dependencies": { "side-channel": "^1.1.0" }, @@ -10782,7 +10788,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -10795,7 +10800,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -11579,6 +11583,7 @@ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "license": "MIT", + "peer": true, "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", @@ -11598,6 +11603,7 @@ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", "license": "MIT", + "peer": true, "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" @@ -11614,6 +11620,7 @@ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "license": "MIT", + "peer": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -11632,6 +11639,7 @@ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "license": "MIT", + "peer": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -12269,7 +12277,6 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -12342,6 +12349,7 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -12405,6 +12413,7 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -12419,6 +12428,7 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -12432,7 +12442,6 @@ "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -12585,7 +12594,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -12819,6 +12827,7 @@ "resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz", "integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==", "license": "MIT", + "peer": true, "dependencies": { "punycode": "^1.4.1", "qs": "^6.12.3" @@ -12831,7 +12840,8 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/use-callback-ref": { "version": "1.3.3", @@ -12945,7 +12955,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -13020,8 +13029,7 @@ "resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz", "integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/vitest": { "version": "4.0.16", @@ -13585,7 +13593,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -13599,7 +13606,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", From cf8d211eb2f376eca0134691147e71c854747710 Mon Sep 17 00:00:00 2001 From: Brodypen Date: Sat, 28 Feb 2026 02:16:03 -0600 Subject: [PATCH 3/3] feat: add the speed to exporter lol --- src/lib/exporter/gifExporter.ts | 3 +- src/lib/exporter/streamingDecoder.ts | 51 +++++++++++++++++++++++----- src/lib/exporter/videoExporter.ts | 3 +- 3 files changed, 46 insertions(+), 11 deletions(-) diff --git a/src/lib/exporter/gifExporter.ts b/src/lib/exporter/gifExporter.ts index 382a010..db7f299 100644 --- a/src/lib/exporter/gifExporter.ts +++ b/src/lib/exporter/gifExporter.ts @@ -124,7 +124,7 @@ export class GifExporter { }); // Calculate effective duration and frame count (excluding trim regions) - const effectiveDuration = this.streamingDecoder.getEffectiveDuration(this.config.trimRegions); + const effectiveDuration = this.streamingDecoder.getEffectiveDuration(this.config.trimRegions, this.config.speedRegions); const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate); // Calculate frame delay in milliseconds (gif.js uses ms) @@ -144,6 +144,7 @@ export class GifExporter { await this.streamingDecoder.decodeAll( this.config.frameRate, this.config.trimRegions, + this.config.speedRegions, async (videoFrame, _exportTimestampUs, sourceTimestampMs) => { if (this.cancelled) { videoFrame.close(); diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index d5610fc..d07e164 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -1,5 +1,5 @@ import { WebDemuxer } from 'web-demuxer'; -import type { TrimRegion } from '@/components/video-editor/types'; +import type { TrimRegion, SpeedRegion } from '@/components/video-editor/types'; export interface DecodedVideoInfo { width: number; @@ -67,6 +67,7 @@ export class StreamingVideoDecoder { async decodeAll( targetFrameRate: number, trimRegions: TrimRegion[] | undefined, + speedRegions: SpeedRegion[] | undefined, onFrame: OnFrameCallback ): Promise { if (!this.demuxer || !this.metadata) { @@ -74,7 +75,10 @@ export class StreamingVideoDecoder { } const decoderConfig = await this.demuxer.getDecoderConfig('video'); - const segments = this.computeSegments(this.metadata.duration, trimRegions); + const segments = this.splitBySpeed( + this.computeSegments(this.metadata.duration, trimRegions), + speedRegions + ); const frameDurationUs = 1_000_000 / targetFrameRate; // Async frame queue — decoder pushes, consumer pulls @@ -218,7 +222,7 @@ export class StreamingVideoDecoder { */ private async deliverSegment( frames: VideoFrame[], - segment: { startSec: number; endSec: number }, + segment: { startSec: number; endSec: number; speed: number }, targetFrameRate: number, frameDurationUs: number, startExportFrameIndex: number, @@ -226,7 +230,9 @@ export class StreamingVideoDecoder { ): Promise { if (frames.length === 0) return startExportFrameIndex; - const segmentFrameCount = Math.ceil((segment.endSec - segment.startSec) * targetFrameRate); + const segmentFrameCount = Math.ceil( + (segment.endSec - segment.startSec) / segment.speed * targetFrameRate + ); let exportFrameIndex = startExportFrameIndex; for (let i = 0; i < segmentFrameCount && !this.cancelled; i++) { @@ -271,12 +277,39 @@ export class StreamingVideoDecoder { return segments; } - getEffectiveDuration(trimRegions?: TrimRegion[]): number { + getEffectiveDuration(trimRegions?: TrimRegion[], speedRegions?: SpeedRegion[]): number { if (!this.metadata) throw new Error('Must call loadMetadata() first'); - const trimmed = (trimRegions || []).reduce( - (sum, r) => sum + (r.endMs - r.startMs) / 1000, 0 - ); - return this.metadata.duration - trimmed; + const trimSegments = this.computeSegments(this.metadata.duration, trimRegions); + const speedSegments = this.splitBySpeed(trimSegments, speedRegions); + return speedSegments.reduce((sum, seg) => sum + (seg.endSec - seg.startSec) / seg.speed, 0); + } + + private splitBySpeed( + segments: Array<{ startSec: number; endSec: number }>, + speedRegions?: SpeedRegion[] + ): Array<{ startSec: number; endSec: number; speed: number }> { + if (!speedRegions || speedRegions.length === 0) + return segments.map(s => ({ ...s, speed: 1 })); + + const result: Array<{ startSec: number; endSec: number; speed: number }> = []; + for (const segment of segments) { + const overlapping = speedRegions + .filter(sr => (sr.startMs / 1000) < segment.endSec && (sr.endMs / 1000) > segment.startSec) + .sort((a, b) => a.startMs - b.startMs); + + if (overlapping.length === 0) { result.push({ ...segment, speed: 1 }); continue; } + + let cursor = segment.startSec; + for (const sr of overlapping) { + const srStart = Math.max(sr.startMs / 1000, segment.startSec); + const srEnd = Math.min(sr.endMs / 1000, segment.endSec); + if (cursor < srStart) result.push({ startSec: cursor, endSec: srStart, speed: 1 }); + result.push({ startSec: srStart, endSec: srEnd, speed: sr.speed }); + cursor = srEnd; + } + if (cursor < segment.endSec) result.push({ startSec: cursor, endSec: segment.endSec, speed: 1 }); + } + return result.filter(s => s.endSec - s.startSec > 0.0001); } cancel(): void { diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index 8c513b2..e41bc47 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -83,7 +83,7 @@ export class VideoExporter { await this.muxer.initialize(); // Calculate effective duration and frame count (excluding trim regions) - const effectiveDuration = this.streamingDecoder.getEffectiveDuration(this.config.trimRegions); + const effectiveDuration = this.streamingDecoder.getEffectiveDuration(this.config.trimRegions, this.config.speedRegions); const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate); console.log('[VideoExporter] Original duration:', videoInfo.duration, 's'); @@ -98,6 +98,7 @@ export class VideoExporter { await this.streamingDecoder.decodeAll( this.config.frameRate, this.config.trimRegions, + this.config.speedRegions, async (videoFrame, _exportTimestampUs, sourceTimestampMs) => { if (this.cancelled) { videoFrame.close();