feat: speed thing

This commit is contained in:
Brodypen
2026-02-28 01:20:04 -06:00
parent 5573c9f427
commit 397a943426
11 changed files with 378 additions and 35 deletions
+58 -1
View File
@@ -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">
+80 -1
View File
@@ -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);
+22 -8
View File
@@ -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>
+21
View File
@@ -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);
}
+2 -1
View File
@@ -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;
}
+3 -1
View File
@@ -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,
});
+3 -1
View File
@@ -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,
});