;
+ onInstall: (ext: MarketplaceExtension) => void;
+ onViewDetail: (ext: MarketplaceExtension) => void;
}) {
- const t = useScopedT("extensions");
- return (
-
- {/* Search */}
-
-
- onSearchQueryChange(e.target.value)}
- onKeyDown={(e) => {
- e.stopPropagation();
- if (e.key === "Enter") onSearch();
- }}
- className="w-full h-8 pl-8 pr-3 rounded-lg bg-white/[0.04] border border-white/[0.08] text-[12px] text-slate-200 placeholder:text-slate-600 focus:outline-none focus:ring-1 focus:ring-[#2563EB]/50 focus:border-[#2563EB]/30 transition-colors"
- />
-
+ const t = useScopedT("extensions");
+ return (
+
+ {/* Search */}
+
+
+ onSearchQueryChange(e.target.value)}
+ onKeyDown={(e) => {
+ e.stopPropagation();
+ if (e.key === "Enter") onSearch();
+ }}
+ className="w-full h-8 pl-8 pr-3 rounded-lg bg-white/[0.04] border border-white/[0.08] text-[12px] text-slate-200 placeholder:text-slate-600 focus:outline-none focus:ring-1 focus:ring-[#2563EB]/50 focus:border-[#2563EB]/30 transition-colors"
+ />
+
- {/* Results */}
- {loading && (
-
-
-
- )}
+ {/* Results */}
+ {loading && (
+
+
+
+ )}
- {error && (
-
-
-
{error}
-
-
- )}
+ {error && (
+
+
+
{error}
+
+
+ )}
- {!loading && !error && results.length === 0 && (
-
-
-
- {searchQuery ? t("search.noResults") : t("search.noMarketplace")}
-
-
- )}
+ {!loading && !error && results.length === 0 && (
+
+
+
+ {searchQuery ? t("search.noResults") : t("search.noMarketplace")}
+
+
+ )}
- {!loading && !error && results.length > 0 && (
-
-
- {results.length !== 1
- ? t("search.countPlural", undefined, { count: results.length })
- : t("search.count", undefined, { count: results.length })}
-
- {results.map((ext) => (
-
onInstall(ext)}
- onClick={() => onViewDetail(ext)}
- />
- ))}
-
- )}
-
- );
+ {!loading && !error && results.length > 0 && (
+
+
+ {results.length !== 1
+ ? t("search.countPlural", undefined, { count: results.length })
+ : t("search.count", undefined, { count: results.length })}
+
+ {results.map((ext) => (
+
onInstall(ext)}
+ onClick={() => onViewDetail(ext)}
+ />
+ ))}
+
+ )}
+
+ );
}
diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx
index 6d5e46f2..60aa8e95 100644
--- a/src/components/video-editor/SettingsPanel.tsx
+++ b/src/components/video-editor/SettingsPanel.tsx
@@ -40,6 +40,7 @@ import type {
CaptionCue,
CropRegion,
CursorStyle,
+ EditorEffectSection,
FigureData,
PlaybackSpeed,
WebcamOverlaySettings,
@@ -111,19 +112,6 @@ const CAPTION_ANIMATION_OPTIONS: Array<{ value: AutoCaptionAnimation; label: str
];
type BackgroundTab = "image" | "video" | "color" | "gradient";
-export type EditorEffectSection =
- | "scene"
- | "cursor"
- | "captions"
- | "webcam"
- | "settings"
- | "zoom"
- | "frame"
- | "crop"
- | "extensions"
- | "clip"
- | `ext:${string}`;
-
function isHexWallpaper(value: string): boolean {
return /^#(?:[0-9a-f]{3}){1,2}$/i.test(value);
}
@@ -439,8 +427,6 @@ interface SettingsPanelProps {
onSpeedDelete?: (id: string) => void;
}
-export default SettingsPanel;
-
const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [
{ depth: 1, label: "1.25×" },
{ depth: 2, label: "1.5×" },
@@ -2291,13 +2277,13 @@ export function SettingsPanel({
{selectedZoomMode === "manual"
? tSettings(
- "zoom.modeManualDescription",
- "Set a fixed focus point for this zoom",
- )
+ "zoom.modeManualDescription",
+ "Set a fixed focus point for this zoom",
+ )
: tSettings(
- "zoom.modeAutoDescription",
- "Camera follows cursor automatically",
- )}
+ "zoom.modeAutoDescription",
+ "Camera follows cursor automatically",
+ )}
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx
index 9a1d610d..d4071c98 100644
--- a/src/components/video-editor/VideoEditor.tsx
+++ b/src/components/video-editor/VideoEditor.tsx
@@ -1,32 +1,32 @@
import {
- ArrowClockwise as Redo2,
- ArrowCounterClockwise as Undo2,
+ Check,
CaretDown as ChevronDown,
CaretUp as ChevronUp,
- Check,
ClosedCaptioning,
Crop,
Cursor,
DownloadSimple as Download,
- FloppyDisk as Save,
FolderOpen,
Gear,
- MagicWand as WandSparkles,
- MagnifyingGlassPlus as ZoomIn,
Pause,
+ Camera as PhCameraRegular,
Play,
Plus,
- Camera as PhCameraRegular,
PuzzlePiece,
+ ArrowClockwise as Redo2,
+ FloppyDisk as Save,
Scissors,
SkipBack,
SkipForward,
- SpeakerHigh as Volume2,
- SpeakerLow as Volume1,
- SpeakerX as VolumeX,
Sparkle,
+ ArrowCounterClockwise as Undo2,
UserCircle as User,
+ SpeakerLow as Volume1,
+ SpeakerHigh as Volume2,
+ SpeakerX as VolumeX,
+ MagicWand as WandSparkles,
X,
+ MagnifyingGlassPlus as ZoomIn,
} from "@phosphor-icons/react";
import type { Span } from "dnd-timeline";
import { motion } from "motion/react";
@@ -113,7 +113,7 @@ import {
toFileUrl,
validateProjectData,
} from "./projectPersistence";
-import { type EditorEffectSection, SettingsPanel } from "./SettingsPanel";
+import { SettingsPanel } from "./SettingsPanel";
import {
APP_HEADER_ICON_BUTTON_CLASS,
DiscordLinkButton,
@@ -153,6 +153,7 @@ import {
DEFAULT_ZOOM_IN_OVERLAP_MS,
DEFAULT_ZOOM_OUT_DURATION_MS,
DEFAULT_ZOOM_OUT_EASING,
+ type EditorEffectSection,
type FigureData,
getClipSourceEndMs,
type PlaybackSpeed,
@@ -676,7 +677,7 @@ export default function VideoEditor() {
window.clearTimeout(pendingFreshRecordingAutoSuggestTimeoutRef.current);
pendingFreshRecordingAutoSuggestTimeoutRef.current = null;
}
- }, [videoPath]);
+ }, []);
// Auto-activate builtin extensions at editor startup (idempotent)
useEffect(() => {
@@ -783,7 +784,27 @@ export default function VideoEditor() {
annotationRegions,
autoCaptions,
autoCaptionSettings,
- speedRegions: effectiveSpeedRegions,
+ speedRegions: (() => {
+ const clipDerived: SpeedRegion[] = clipRegions
+ .filter((clip) => clip.speed !== 1)
+ .map((clip) => ({
+ id: `clip-speed-${clip.id}`,
+ startMs: clip.startMs,
+ endMs: getClipSourceEndMs(clip),
+ speed: clip.speed as SpeedRegion["speed"],
+ }));
+ if (clipDerived.length === 0) return speedRegions;
+ const result = [...speedRegions];
+ for (const cs of clipDerived) {
+ const overlaps = speedRegions.some(
+ (sr) => sr.endMs > cs.startMs && sr.startMs < cs.endMs,
+ );
+ if (!overlaps) {
+ result.push(cs);
+ }
+ }
+ return result;
+ })(),
previewWidth,
previewHeight,
cursorTelemetry,
@@ -870,11 +891,11 @@ export default function VideoEditor() {
cursorStyle,
cursorSway,
cursorTelemetry,
+ clipRegions,
padding,
shadowIntensity,
showCursor,
speedRegions,
- clipRegions,
wallpaper,
webcam,
zoomInDurationMs,
@@ -884,6 +905,7 @@ export default function VideoEditor() {
zoomOutDurationMs,
zoomOutEasing,
zoomRegions,
+ zoomClassicMode,
]);
const markExportAsSaving = useCallback(() => {
@@ -1271,7 +1293,6 @@ export default function VideoEditor() {
shadowIntensity,
backgroundBlur,
zoomMotionBlur,
- autoApplyFreshRecordingAutoZooms,
connectZooms,
zoomInDurationMs,
zoomInOverlapMs,
@@ -1313,6 +1334,7 @@ export default function VideoEditor() {
gifFrameRate,
gifLoop,
gifSizePreset,
+ frame,
],
);
@@ -1729,8 +1751,9 @@ export default function VideoEditor() {
setVideoPath(sourceVideoUrl);
setCurrentProjectPath(null);
setLastSavedSnapshot(null);
- pendingFreshRecordingAutoZoomPathRef.current =
- autoApplyFreshRecordingAutoZooms ? sourceVideoUrl : null;
+ pendingFreshRecordingAutoZoomPathRef.current = autoApplyFreshRecordingAutoZooms
+ ? sourceVideoUrl
+ : null;
setWebcam((prev) => ({
...prev,
enabled: Boolean(sessionResult.session?.webcamPath),
@@ -1747,8 +1770,9 @@ export default function VideoEditor() {
setVideoPath(sourceVideoUrl);
setCurrentProjectPath(null);
setLastSavedSnapshot(null);
- pendingFreshRecordingAutoZoomPathRef.current =
- autoApplyFreshRecordingAutoZooms ? sourceVideoUrl : null;
+ pendingFreshRecordingAutoZoomPathRef.current = autoApplyFreshRecordingAutoZooms
+ ? sourceVideoUrl
+ : null;
setWebcam((prev) => ({
...prev,
enabled: false,
@@ -1767,8 +1791,13 @@ export default function VideoEditor() {
loadInitialData();
}, [
applyLoadedProject,
+ autoApplyFreshRecordingAutoZooms,
+ initialEditorPreferences,
smokeExportConfig.enabled,
smokeExportConfig.inputPath,
+ smokeExportConfig.webcamInputPath,
+ smokeExportConfig.webcamShadow,
+ smokeExportConfig.webcamSize,
]);
useEffect(() => {
@@ -1839,8 +1868,6 @@ export default function VideoEditor() {
cursorStyle,
cursorSize,
cursorSmoothing,
- zoomSmoothness,
- zoomClassicMode,
cursorMotionBlur,
cursorClickBounce,
cursorClickBounceDuration,
@@ -2362,7 +2389,6 @@ export default function VideoEditor() {
speed: clip.speed as SpeedRegion["speed"],
}));
if (clipDerived.length === 0) return speedRegions;
- // Timeline speed regions take precedence; only fill in clip speed where no overlap exists
const result = [...speedRegions];
for (const cs of clipDerived) {
const overlaps = speedRegions.some(
@@ -3852,8 +3878,6 @@ export default function VideoEditor() {
videoPath,
wallpaper,
trimRegions,
- speedRegions,
- clipRegions,
shadowIntensity,
backgroundBlur,
zoomMotionBlur,
@@ -3894,14 +3918,21 @@ export default function VideoEditor() {
effectiveZoomRegions,
ensureSupportedMp4SourceDimensions,
markExportAsSaving,
+ mp4FrameRate,
remountPreview,
showExportSuccessToast,
+ smokeExportConfig.backendPreference,
smokeExportConfig.enabled,
smokeExportConfig.useNativeExport,
smokeExportConfig.maxDecodeQueue,
smokeExportConfig.maxEncodeQueue,
smokeExportConfig.maxPendingFrames,
smokeExportConfig.outputPath,
+ smokeExportConfig.pipelineModel,
+ smokeExportConfig.shadowIntensity,
+ effectiveSpeedRegions,
+ frame,
+ smokeExportConfig.encodingMode,
],
);
@@ -4593,9 +4624,7 @@ export default function VideoEditor() {
whileHover={{ opacity: 1 }}
initial={{ opacity: 0.55 }}
>
-
+
@@ -4921,7 +4950,9 @@ export default function VideoEditor() {
className="h-7 gap-1 rounded-full border border-white/[0.08] bg-white/[0.04] px-2.5 text-[11px] text-white/65 shadow-[inset_0_1px_0_rgba(255,255,255,0.06)] transition-all hover:bg-white/[0.08] hover:text-white"
>
- {t("editor.toolbar.addLayer")}
+
+ {t("editor.toolbar.addLayer")}
+
diff --git a/src/components/video-editor/index.ts b/src/components/video-editor/index.ts
index 8f1578b0..4fcca759 100644
--- a/src/components/video-editor/index.ts
+++ b/src/components/video-editor/index.ts
@@ -1,6 +1,5 @@
-export { default as VideoEditor } from './VideoEditor';
-export { default as VideoPlayback } from './VideoPlayback';
-export { default as PlaybackControls } from './PlaybackControls';
-export { default as TimelineEditor } from './timeline/TimelineEditor';
-export { default as SettingsPanel } from './SettingsPanel';
-
+export { default as PlaybackControls } from "./PlaybackControls";
+export { SettingsPanel } from "./SettingsPanel";
+export { default as TimelineEditor } from "./timeline/TimelineEditor";
+export { default as VideoEditor } from "./VideoEditor";
+export { default as VideoPlayback } from "./VideoPlayback";
diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx
index ef6ca31f..17af7928 100644
--- a/src/components/video-editor/timeline/TimelineEditor.tsx
+++ b/src/components/video-editor/timeline/TimelineEditor.tsx
@@ -1,5 +1,29 @@
-import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type WheelEvent } from "react";
+import type { Range, Span } from "dnd-timeline";
import { useTimelineContext } from "dnd-timeline";
+import {
+ Check,
+ ChevronDown,
+ Crop,
+ MessageSquare,
+ Music,
+ Plus,
+ Scissors,
+ WandSparkles,
+ ZoomIn,
+} from "lucide-react";
+import {
+ forwardRef,
+ type KeyboardEvent as ReactKeyboardEvent,
+ useCallback,
+ useEffect,
+ useImperativeHandle,
+ useMemo,
+ useRef,
+ useState,
+ type WheelEvent,
+} from "react";
+import { toast } from "sonner";
+import { v4 as uuidv4 } from "uuid";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -7,26 +31,36 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
-import { Plus, Scissors, ZoomIn, MessageSquare, ChevronDown, Check, WandSparkles, Music, Crop } from "lucide-react";
-import { toast } from "sonner";
-import { cn } from "@/lib/utils";
-import { v4 as uuidv4 } from 'uuid';
import { useScopedT } from "@/contexts/I18nContext";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import { matchesShortcut } from "@/lib/shortcuts";
-import { ASPECT_RATIOS, type AspectRatio, getAspectRatioLabel, isCustomAspectRatio } from "@/utils/aspectRatioUtils";
+import { cn } from "@/lib/utils";
+import {
+ ASPECT_RATIOS,
+ type AspectRatio,
+ getAspectRatioLabel,
+ isCustomAspectRatio,
+} from "@/utils/aspectRatioUtils";
import { formatShortcut } from "@/utils/platformUtils";
import { loadEditorPreferences, saveEditorPreferences } from "../editorPreferences";
-import TimelineWrapper from "./TimelineWrapper";
-import Row from "./Row";
+import { toFileUrl } from "../projectPersistence";
+import type {
+ AnnotationRegion,
+ AudioRegion,
+ ClipRegion,
+ CursorTelemetryPoint,
+ SpeedRegion,
+ TrimRegion,
+ ZoomFocus,
+ ZoomRegion,
+} from "../types";
+import AudioWaveform from "./AudioWaveform";
import Item from "./Item";
import KeyframeMarkers from "./KeyframeMarkers";
-import type { Range, Span } from "dnd-timeline";
-import type { ZoomRegion, TrimRegion, ClipRegion, AnnotationRegion, SpeedRegion, AudioRegion, CursorTelemetryPoint, ZoomFocus } from "../types";
-import { toFileUrl } from "../projectPersistence";
+import Row from "./Row";
+import TimelineWrapper from "./TimelineWrapper";
+import { type AudioPeaksData, useAudioPeaks } from "./useAudioPeaks";
import { buildInteractionZoomSuggestions } from "./zoomSuggestionUtils";
-import { useAudioPeaks, type AudioPeaksData } from "./useAudioPeaks";
-import AudioWaveform from "./AudioWaveform";
const ZOOM_ROW_ID = "row-zoom";
const CLIP_ROW_ID = "row-clip";
@@ -35,70 +69,70 @@ const AUDIO_ROW_ID = "row-audio";
const FALLBACK_RANGE_MS = 1000;
const TARGET_MARKER_COUNT = 12;
interface TimelineEditorProps {
- videoDuration: number;
- currentTime: number;
- playheadTime?: number;
- onSeek?: (time: number) => void;
- cursorTelemetry?: CursorTelemetryPoint[];
- autoSuggestZoomsTrigger?: number;
- onAutoSuggestZoomsConsumed?: () => void;
- disableSuggestedZooms?: boolean;
- zoomRegions: ZoomRegion[];
- onZoomAdded: (span: Span) => void;
- onZoomSuggested?: (span: Span, focus: ZoomFocus) => void;
- onZoomSpanChange: (id: string, span: Span) => void;
- onZoomDelete: (id: string) => void;
- selectedZoomId: string | null;
- onSelectZoom: (id: string | null) => void;
- trimRegions?: TrimRegion[];
- onTrimAdded?: (span: Span) => void;
- onTrimSpanChange?: (id: string, span: Span) => void;
- onTrimDelete?: (id: string) => void;
- selectedTrimId?: string | null;
- onSelectTrim?: (id: string | null) => void;
- clipRegions?: ClipRegion[];
- onClipSplit?: (splitMs: number) => void;
- onClipSpanChange?: (id: string, span: Span) => void;
- onClipDelete?: (id: string) => void;
- selectedClipId?: string | null;
- onSelectClip?: (id: string | null) => void;
- annotationRegions?: AnnotationRegion[];
- onAnnotationAdded?: (span: Span, trackIndex?: number) => void;
- onAnnotationSpanChange?: (id: string, span: Span) => void;
- 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;
- audioRegions?: AudioRegion[];
- onAudioAdded?: (span: Span, audioPath: string) => void;
- onAudioSpanChange?: (id: string, span: Span) => void;
- onAudioDelete?: (id: string) => void;
- selectedAudioId?: string | null;
- onSelectAudio?: (id: string | null) => void;
- aspectRatio?: AspectRatio;
- onAspectRatioChange?: (aspectRatio: AspectRatio) => void;
- onOpenCropEditor?: () => void;
- isCropped?: boolean;
- videoPath?: string | null;
- isPlaying?: boolean;
- onTogglePlayPause?: () => void;
- volume?: number;
- onVolumeChange?: (volume: number) => void;
- hideToolbar?: boolean;
+ videoDuration: number;
+ currentTime: number;
+ playheadTime?: number;
+ onSeek?: (time: number) => void;
+ cursorTelemetry?: CursorTelemetryPoint[];
+ autoSuggestZoomsTrigger?: number;
+ onAutoSuggestZoomsConsumed?: () => void;
+ disableSuggestedZooms?: boolean;
+ zoomRegions: ZoomRegion[];
+ onZoomAdded: (span: Span) => void;
+ onZoomSuggested?: (span: Span, focus: ZoomFocus) => void;
+ onZoomSpanChange: (id: string, span: Span) => void;
+ onZoomDelete: (id: string) => void;
+ selectedZoomId: string | null;
+ onSelectZoom: (id: string | null) => void;
+ trimRegions?: TrimRegion[];
+ onTrimAdded?: (span: Span) => void;
+ onTrimSpanChange?: (id: string, span: Span) => void;
+ onTrimDelete?: (id: string) => void;
+ selectedTrimId?: string | null;
+ onSelectTrim?: (id: string | null) => void;
+ clipRegions?: ClipRegion[];
+ onClipSplit?: (splitMs: number) => void;
+ onClipSpanChange?: (id: string, span: Span) => void;
+ onClipDelete?: (id: string) => void;
+ selectedClipId?: string | null;
+ onSelectClip?: (id: string | null) => void;
+ annotationRegions?: AnnotationRegion[];
+ onAnnotationAdded?: (span: Span, trackIndex?: number) => void;
+ onAnnotationSpanChange?: (id: string, span: Span) => void;
+ 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;
+ audioRegions?: AudioRegion[];
+ onAudioAdded?: (span: Span, audioPath: string) => void;
+ onAudioSpanChange?: (id: string, span: Span) => void;
+ onAudioDelete?: (id: string) => void;
+ selectedAudioId?: string | null;
+ onSelectAudio?: (id: string | null) => void;
+ aspectRatio?: AspectRatio;
+ onAspectRatioChange?: (aspectRatio: AspectRatio) => void;
+ onOpenCropEditor?: () => void;
+ isCropped?: boolean;
+ videoPath?: string | null;
+ isPlaying?: boolean;
+ onTogglePlayPause?: () => void;
+ volume?: number;
+ onVolumeChange?: (volume: number) => void;
+ hideToolbar?: boolean;
}
export interface TimelineEditorHandle {
- addZoom: () => void;
- suggestZooms: () => void;
- splitClip: () => void;
- addAnnotation: (trackIndex?: number) => void;
- addAudio: () => Promise;
- keyframes: { id: string; time: number }[];
+ addZoom: () => void;
+ suggestZooms: () => void;
+ splitClip: () => void;
+ addAnnotation: (trackIndex?: number) => void;
+ addAudio: () => Promise;
+ keyframes: { id: string; time: number }[];
}
interface TimelineScaleConfig {
@@ -108,13 +142,13 @@ interface TimelineScaleConfig {
}
interface TimelineRenderItem {
- id: string;
- rowId: string;
- span: Span;
- label: string;
- zoomDepth?: number;
- speedValue?: number;
- variant: 'zoom' | 'trim' | 'clip' | 'annotation' | 'speed' | 'audio';
+ id: string;
+ rowId: string;
+ span: Span;
+ label: string;
+ zoomDepth?: number;
+ speedValue?: number;
+ variant: "zoom" | "trim" | "clip" | "annotation" | "speed" | "audio";
}
const SCALE_CANDIDATES = [
@@ -411,7 +445,7 @@ function TimelineAxis({
return (
{marker.label}
@@ -462,47 +498,47 @@ function TimelineAxis({
}
function Timeline({
- items,
- videoDurationMs,
- currentTimeMs,
- onSeek,
- onSelectZoom,
- onSelectTrim,
- onSelectClip,
- onSelectAnnotation,
- onSelectSpeed,
- onSelectAudio,
- selectedZoomId,
- selectedTrimId: _selectedTrimId,
- selectedClipId,
- selectedAnnotationId,
- selectedSpeedId: _selectedSpeedId,
- selectedAudioId,
- selectAllBlocksActive = false,
- onClearBlockSelection,
- keyframes = [],
- audioPeaks,
+ items,
+ videoDurationMs,
+ currentTimeMs,
+ onSeek,
+ onSelectZoom,
+ onSelectTrim,
+ onSelectClip,
+ onSelectAnnotation,
+ onSelectSpeed,
+ onSelectAudio,
+ selectedZoomId,
+ selectedTrimId: _selectedTrimId,
+ selectedClipId,
+ selectedAnnotationId,
+ selectedSpeedId: _selectedSpeedId,
+ selectedAudioId,
+ selectAllBlocksActive = false,
+ onClearBlockSelection,
+ keyframes = [],
+ audioPeaks,
}: {
- items: TimelineRenderItem[];
- videoDurationMs: number;
- currentTimeMs: number;
- onSeek?: (time: number) => void;
- onSelectZoom?: (id: string | null) => void;
- onSelectTrim?: (id: string | null) => void;
- onSelectClip?: (id: string | null) => void;
- onSelectAnnotation?: (id: string | null) => void;
- onSelectSpeed?: (id: string | null) => void;
- onSelectAudio?: (id: string | null) => void;
- selectedZoomId: string | null;
- selectedTrimId?: string | null;
- selectedClipId?: string | null;
- selectedAnnotationId?: string | null;
- selectedSpeedId?: string | null;
- selectedAudioId?: string | null;
- selectAllBlocksActive?: boolean;
- onClearBlockSelection?: () => void;
- keyframes?: { id: string; time: number }[];
- audioPeaks?: AudioPeaksData | null;
+ items: TimelineRenderItem[];
+ videoDurationMs: number;
+ currentTimeMs: number;
+ onSeek?: (time: number) => void;
+ onSelectZoom?: (id: string | null) => void;
+ onSelectTrim?: (id: string | null) => void;
+ onSelectClip?: (id: string | null) => void;
+ onSelectAnnotation?: (id: string | null) => void;
+ onSelectSpeed?: (id: string | null) => void;
+ onSelectAudio?: (id: string | null) => void;
+ selectedZoomId: string | null;
+ selectedTrimId?: string | null;
+ selectedClipId?: string | null;
+ selectedAnnotationId?: string | null;
+ selectedSpeedId?: string | null;
+ selectedAudioId?: string | null;
+ selectAllBlocksActive?: boolean;
+ onClearBlockSelection?: () => void;
+ keyframes?: { id: string; time: number }[];
+ audioPeaks?: AudioPeaksData | null;
}) {
const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext();
const localTimelineRef = useRef
(null);
@@ -519,15 +555,15 @@ function Timeline({
(e: React.MouseEvent) => {
if (!onSeek || videoDurationMs <= 0) return;
- // Only clear selection if clicking on empty space (not on items)
- // This is handled by event propagation - items stop propagation
- onSelectZoom?.(null);
- onSelectTrim?.(null);
- onSelectClip?.(null);
- onSelectAnnotation?.(null);
- onSelectSpeed?.(null);
- onSelectAudio?.(null);
- onClearBlockSelection?.();
+ // Only clear selection if clicking on empty space (not on items)
+ // This is handled by event propagation - items stop propagation
+ onSelectZoom?.(null);
+ onSelectTrim?.(null);
+ onSelectClip?.(null);
+ onSelectAnnotation?.(null);
+ onSelectSpeed?.(null);
+ onSelectAudio?.(null);
+ onClearBlockSelection?.();
const rect = e.currentTarget.getBoundingClientRect();
const clickX = e.clientX - rect.left - sidebarWidth;
@@ -538,20 +574,35 @@ function Timeline({
const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs));
const timeInSeconds = absoluteMs / 1000;
- onSeek(timeInSeconds);
- }, [onSeek, onSelectZoom, onSelectTrim, onSelectClip, onSelectAnnotation, onSelectSpeed, onSelectAudio, videoDurationMs, sidebarWidth, range.start, pixelsToValue]);
+ onSeek(timeInSeconds);
+ },
+ [
+ onSeek,
+ onSelectZoom,
+ onSelectTrim,
+ onSelectClip,
+ onSelectAnnotation,
+ onSelectSpeed,
+ onSelectAudio,
+ onClearBlockSelection,
+ videoDurationMs,
+ sidebarWidth,
+ range.start,
+ pixelsToValue,
+ ],
+ );
- const zoomItems = items.filter(item => item.rowId === ZOOM_ROW_ID);
+ const zoomItems = items.filter((item) => item.rowId === ZOOM_ROW_ID);
- const clipItems = items.filter(item => item.rowId === CLIP_ROW_ID);
- const annotationItems = items.filter(item => item.rowId === ANNOTATION_ROW_ID);
- const audioItems = items.filter(item => item.rowId === AUDIO_ROW_ID);
+ const clipItems = items.filter((item) => item.rowId === CLIP_ROW_ID);
+ const annotationItems = items.filter((item) => item.rowId === ANNOTATION_ROW_ID);
+ const audioItems = items.filter((item) => item.rowId === AUDIO_ROW_ID);
return (
@@ -564,1154 +615,1391 @@ function Timeline({
keyframes={keyframes}
/>
-
-
- {audioPeaks && }
- {clipItems.map((item) => (
- - onSelectClip?.(item.id)}
- variant="clip"
- >
- {item.label}
-
- ))}
-
+
+
+ {audioPeaks && }
+ {clipItems.map((item) => (
+ - onSelectClip?.(item.id)}
+ variant="clip"
+ >
+ {item.label}
+
+ ))}
+
-
- {zoomItems.map((item) => (
- - onSelectZoom?.(item.id)}
- zoomDepth={item.zoomDepth}
- variant="zoom"
- >
- {item.label}
-
- ))}
-
+
+ {zoomItems.map((item) => (
+ - onSelectZoom?.(item.id)}
+ zoomDepth={item.zoomDepth}
+ variant="zoom"
+ >
+ {item.label}
+
+ ))}
+
+
+ {annotationItems.map((item) => (
+ - onSelectAnnotation?.(item.id)}
+ variant="annotation"
+ >
+ {item.label}
+
+ ))}
+
-
- {annotationItems.map((item) => (
- - onSelectAnnotation?.(item.id)}
- variant="annotation"
- >
- {item.label}
-
- ))}
-
-
-
- {audioItems.map((item) => (
- - onSelectAudio?.(item.id)}
- variant="audio"
- >
- {item.label}
-
- ))}
-
-
-
- );
+
+ {audioItems.map((item) => (
+ - onSelectAudio?.(item.id)}
+ variant="audio"
+ >
+ {item.label}
+
+ ))}
+
+
+
+ );
}
-const TimelineEditor = forwardRef(function TimelineEditor({
- videoDuration,
- currentTime,
- playheadTime,
- onSeek,
- cursorTelemetry = [],
- autoSuggestZoomsTrigger = 0,
- onAutoSuggestZoomsConsumed,
- disableSuggestedZooms = false,
- zoomRegions,
- onZoomAdded,
- onZoomSuggested,
- onZoomSpanChange,
- onZoomDelete,
- selectedZoomId,
- onSelectZoom,
- trimRegions = [],
- onTrimAdded,
- onTrimSpanChange,
- onTrimDelete,
- selectedTrimId,
- onSelectTrim,
- clipRegions = [],
- onClipSplit,
- onClipSpanChange,
- onClipDelete,
- selectedClipId,
- onSelectClip,
- annotationRegions = [],
- onAnnotationAdded,
- onAnnotationSpanChange,
- onAnnotationDelete,
- selectedAnnotationId,
- onSelectAnnotation,
- speedRegions = [],
- onSpeedAdded,
- onSpeedSpanChange,
- onSpeedDelete,
- selectedSpeedId,
- onSelectSpeed,
- audioRegions = [],
- onAudioAdded,
- onAudioSpanChange,
- onAudioDelete,
- selectedAudioId,
- onSelectAudio,
- aspectRatio = 'native',
- onAspectRatioChange,
- onOpenCropEditor,
- isCropped = false,
- videoPath,
-}, ref) {
- const t = useScopedT("settings");
- const initialEditorPreferences = useMemo(() => loadEditorPreferences(), []);
- const totalMs = useMemo(() => Math.max(0, Math.round(videoDuration * 1000)), [videoDuration]);
- const currentTimeMs = useMemo(() => Math.round((playheadTime ?? currentTime) * 1000), [currentTime, playheadTime]);
- const timelineScale = useMemo(() => calculateTimelineScale(videoDuration), [videoDuration]);
- const safeMinDurationMs = useMemo(
- () => (totalMs > 0 ? Math.min(timelineScale.minItemDurationMs, totalMs) : timelineScale.minItemDurationMs),
- [timelineScale.minItemDurationMs, totalMs],
- );
-
- const [range, setRange] = useState(() => createInitialRange(totalMs));
- const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]);
- const [selectedKeyframeId, setSelectedKeyframeId] = useState(null);
- const [selectAllBlocksActive, setSelectAllBlocksActive] = useState(false);
- const [customAspectWidth, setCustomAspectWidth] = useState(initialEditorPreferences.customAspectWidth);
- const [customAspectHeight, setCustomAspectHeight] = useState(initialEditorPreferences.customAspectHeight);
- const [scrollLabels, setScrollLabels] = useState({
- pan: 'Shift + Ctrl + Scroll',
- zoom: 'Ctrl + Scroll'
- });
- const isTimelineFocusedRef = useRef(false);
- const timelineContainerRef = useRef(null);
- const { shortcuts: keyShortcuts, isMac } = useShortcuts();
- const audioPeaks = useAudioPeaks(videoPath);
-
- useEffect(() => {
- setRange(createInitialRange(totalMs));
- }, [totalMs]);
-
- useEffect(() => {
- if (aspectRatio === 'native') {
- return;
- }
- const [width, height] = aspectRatio.split(':');
- if (width && height) {
- setCustomAspectWidth(width);
- setCustomAspectHeight(height);
- }
- }, [aspectRatio]);
-
- useEffect(() => {
- saveEditorPreferences({
- customAspectWidth,
- customAspectHeight,
- });
- }, [customAspectHeight, customAspectWidth]);
-
- const applyCustomAspectRatio = useCallback(() => {
- const width = Number.parseInt(customAspectWidth, 10);
- const height = Number.parseInt(customAspectHeight, 10);
- if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
- toast.error('Custom aspect ratio must be positive numbers.');
- return;
- }
- onAspectRatioChange?.(`${width}:${height}` as AspectRatio);
- }, [customAspectHeight, customAspectWidth, onAspectRatioChange]);
-
- const handleCustomAspectRatioKeyDown = useCallback((event: ReactKeyboardEvent) => {
- // Prevent Radix DropdownMenu typeahead from selecting preset items while typing.
- event.stopPropagation();
- if (event.key === 'Enter') {
- event.preventDefault();
- applyCustomAspectRatio();
- }
- }, [applyCustomAspectRatio]);
-
- useEffect(() => {
- formatShortcut(['shift', 'mod', 'Scroll']).then(pan => {
- formatShortcut(['mod', 'Scroll']).then(zoom => {
- setScrollLabels({ pan, zoom });
- });
- });
- }, []);
-
- // Add keyframe at current playhead position
- const addKeyframe = useCallback(() => {
- if (totalMs === 0) return;
- const time = Math.max(0, Math.min(currentTimeMs, totalMs));
- if (keyframes.some(kf => Math.abs(kf.time - time) < 1)) return;
- setKeyframes(prev => [...prev, { id: uuidv4(), time }]);
- }, [currentTimeMs, totalMs, keyframes]);
-
- // Delete selected keyframe
- const deleteSelectedKeyframe = useCallback(() => {
- if (!selectedKeyframeId) return;
- setKeyframes(prev => prev.filter(kf => kf.id !== selectedKeyframeId));
- setSelectedKeyframeId(null);
- }, [selectedKeyframeId]);
-
- // Move keyframe to new time position
- const handleKeyframeMove = useCallback((id: string, newTime: number) => {
- setKeyframes(prev => prev.map(kf => kf.id === id ? { ...kf, time: Math.max(0, Math.min(newTime, totalMs)) } : kf));
- }, [totalMs]);
-
- // Delete selected zoom item
- const deleteSelectedZoom = useCallback(() => {
- if (!selectedZoomId) return;
- onZoomDelete(selectedZoomId);
- onSelectZoom(null);
- }, [selectedZoomId, onZoomDelete, onSelectZoom]);
-
- // Delete selected trim item
- const deleteSelectedTrim = useCallback(() => {
- if (!selectedTrimId || !onTrimDelete || !onSelectTrim) return;
- onTrimDelete(selectedTrimId);
- onSelectTrim(null);
- }, [selectedTrimId, onTrimDelete, onSelectTrim]);
-
- const deleteSelectedClip = useCallback(() => {
- if (!selectedClipId || !onClipDelete || !onSelectClip) return;
- onClipDelete(selectedClipId);
- onSelectClip(null);
- }, [selectedClipId, onClipDelete, onSelectClip]);
-
- const deleteSelectedAnnotation = useCallback(() => {
- if (!selectedAnnotationId || !onAnnotationDelete || !onSelectAnnotation) return;
- onAnnotationDelete(selectedAnnotationId);
- onSelectAnnotation(null);
- }, [selectedAnnotationId, onAnnotationDelete, onSelectAnnotation]);
-
- const deleteSelectedSpeed = useCallback(() => {
- if (!selectedSpeedId || !onSpeedDelete || !onSelectSpeed) return;
- onSpeedDelete(selectedSpeedId);
- onSelectSpeed(null);
- }, [selectedSpeedId, onSpeedDelete, onSelectSpeed]);
-
- const deleteSelectedAudio = useCallback(() => {
- if (!selectedAudioId || !onAudioDelete || !onSelectAudio) return;
- onAudioDelete(selectedAudioId);
- onSelectAudio(null);
- }, [selectedAudioId, onAudioDelete, onSelectAudio]);
-
- const clearSelectedBlocks = useCallback(() => {
- onSelectZoom(null);
- onSelectTrim?.(null);
- onSelectClip?.(null);
- onSelectAnnotation?.(null);
- onSelectSpeed?.(null);
- onSelectAudio?.(null);
- setSelectAllBlocksActive(false);
- }, [onSelectAnnotation, onSelectAudio, onSelectClip, onSelectSpeed, onSelectTrim, onSelectZoom]);
-
- const hasAnyTimelineBlocks =
- zoomRegions.length > 0 ||
- trimRegions.length > 0 ||
- clipRegions.length > 0 ||
- annotationRegions.length > 0 ||
- speedRegions.length > 0 ||
- audioRegions.length > 0;
-
- const deleteAllBlocks = useCallback(() => {
- const zoomIds = zoomRegions.map((region) => region.id);
- const trimIds = trimRegions.map((region) => region.id);
- const clipIds = clipRegions.map((region) => region.id);
- const annotationIds = annotationRegions.map((region) => region.id);
- const speedIds = speedRegions.map((region) => region.id);
- const audioIds = audioRegions.map((region) => region.id);
-
- zoomIds.forEach((id) => onZoomDelete(id));
- trimIds.forEach((id) => onTrimDelete?.(id));
- clipIds.forEach((id) => onClipDelete?.(id));
- annotationIds.forEach((id) => onAnnotationDelete?.(id));
- speedIds.forEach((id) => onSpeedDelete?.(id));
- audioIds.forEach((id) => onAudioDelete?.(id));
-
- clearSelectedBlocks();
- setSelectedKeyframeId(null);
- }, [
- annotationRegions,
- audioRegions,
- clearSelectedBlocks,
- clipRegions,
- onAnnotationDelete,
- onAudioDelete,
- onClipDelete,
- onSpeedDelete,
- onTrimDelete,
- onZoomDelete,
- speedRegions,
- trimRegions,
- zoomRegions,
- ]);
-
- const handleSelectZoom = useCallback((id: string | null) => {
- setSelectAllBlocksActive(false);
- onSelectZoom(id);
- }, [onSelectZoom]);
-
- const handleSelectTrim = useCallback((id: string | null) => {
- setSelectAllBlocksActive(false);
- onSelectTrim?.(id);
- }, [onSelectTrim]);
-
- const handleSelectClip = useCallback((id: string | null) => {
- setSelectAllBlocksActive(false);
- onSelectClip?.(id);
- }, [onSelectClip]);
-
- const handleSelectAnnotation = useCallback((id: string | null) => {
- setSelectAllBlocksActive(false);
- onSelectAnnotation?.(id);
- }, [onSelectAnnotation]);
-
- const handleSelectSpeed = useCallback((id: string | null) => {
- setSelectAllBlocksActive(false);
- onSelectSpeed?.(id);
- }, [onSelectSpeed]);
-
- const handleSelectAudio = useCallback((id: string | null) => {
- setSelectAllBlocksActive(false);
- onSelectAudio?.(id);
- }, [onSelectAudio]);
-
- useEffect(() => {
- setRange(createInitialRange(totalMs));
- }, [totalMs]);
-
- // Normalize regions only when timeline bounds change (not on every region edit).
- // Using refs to read current regions avoids a dependency-loop that re-fires
- // 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);
- const audioRegionsRef = useRef(audioRegions);
- zoomRegionsRef.current = zoomRegions;
- trimRegionsRef.current = trimRegions;
- speedRegionsRef.current = speedRegions;
- audioRegionsRef.current = audioRegions;
-
- useEffect(() => {
- if (totalMs === 0 || safeMinDurationMs <= 0) {
- return;
- }
-
- zoomRegionsRef.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) {
- onZoomSpanChange(region.id, { start: normalizedStart, end: normalizedEnd });
- }
- });
-
- trimRegionsRef.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) {
- 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 });
- }
- });
-
- audioRegionsRef.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) {
- onAudioSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd });
- }
- });
- // Only re-run when the timeline scale changes, not on every region edit
- }, [totalMs, safeMinDurationMs, onZoomSpanChange, onTrimSpanChange, onSpeedSpanChange, onAudioSpanChange]);
-
- 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 isClipItem = clipRegions.some(r => r.id === excludeId);
- const isAnnotationItem = annotationRegions.some(r => r.id === excludeId);
- const isSpeedItem = speedRegions.some(r => r.id === excludeId);
- const isAudioItem = audioRegions.some(r => r.id === excludeId);
-
- if (isAnnotationItem) {
- return false;
- }
-
- // Helper to check overlap against a specific set of regions
- const checkOverlap = (regions: (ZoomRegion | TrimRegion | ClipRegion | SpeedRegion | AudioRegion)[]) => {
- return regions.some((region) => {
- if (region.id === excludeId) return false;
- // True overlap: regions actually intersect (not just adjacent)
- return newSpan.end > region.startMs && newSpan.start < region.endMs;
- });
- };
-
- if (isZoomItem) {
- return checkOverlap(zoomRegions);
- }
-
- if (isTrimItem) {
- return checkOverlap(trimRegions);
- }
-
- if (isClipItem) {
- return checkOverlap(clipRegions);
- }
-
- if (isSpeedItem) {
- return checkOverlap(speedRegions);
- }
-
- if (isAudioItem) {
- return checkOverlap(audioRegions);
- }
-
- return false;
- }, [zoomRegions, trimRegions, clipRegions, annotationRegions, speedRegions, audioRegions]);
-
- // Keep newly added timeline regions at the original short default instead of
- // scaling them with the full recording length.
- const defaultRegionDurationMs = useMemo(
- () => Math.min(1000, totalMs),
- [totalMs],
- );
-
- const handleAddZoom = useCallback(() => {
- if (!videoDuration || videoDuration === 0 || totalMs === 0) {
- return;
- }
-
- const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
- if (defaultDuration <= 0) {
- return;
- }
-
- // Always place zoom at playhead
- const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
- // Find the next zoom region after the playhead
- const sorted = [...zoomRegions].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 zoom region
- const isOverlapping = sorted.some(region => startPos >= region.startMs && startPos < region.endMs);
- if (isOverlapping || gapToNext <= 0) {
- toast.error("Cannot place zoom here", {
- description: "Zoom already exists at this location or not enough space available.",
- });
- return;
- }
-
- const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
- onZoomAdded({ start: startPos, end: startPos + actualDuration });
- }, [videoDuration, totalMs, currentTimeMs, zoomRegions, onZoomAdded, defaultRegionDurationMs]);
-
- const handleSuggestZooms = useCallback(() => {
- if (!videoDuration || videoDuration === 0 || totalMs === 0) {
- return;
- }
-
- if (disableSuggestedZooms) {
- toast.info("Suggested zooms are unavailable while cursor looping is enabled.");
- return;
- }
-
- if (!onZoomSuggested) {
- toast.error("Zoom suggestion handler unavailable");
- return;
- }
-
- if (cursorTelemetry.length < 2) {
- toast.info("No cursor telemetry available", {
- description: "Record a screencast first to generate cursor-based suggestions.",
- });
- return;
- }
-
- const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
- if (defaultDuration <= 0) {
- return;
- }
-
- const result = buildInteractionZoomSuggestions({
- cursorTelemetry,
- totalMs,
- defaultDurationMs: defaultDuration,
- reservedSpans: zoomRegions
- .map((region) => ({ start: region.startMs, end: region.endMs }))
- .sort((a, b) => a.start - b.start),
- });
-
- if (result.status === 'no-telemetry') {
- toast.info("No usable cursor telemetry", {
- description: "The recording does not include enough cursor movement data.",
- });
- return;
- }
-
- if (result.status === 'no-interactions') {
- toast.info("No clear interaction moments found", {
- description: "Try a recording with pauses or clicks around important actions.",
- });
- return;
- }
-
- if (result.status === 'no-slots' || result.suggestions.length === 0) {
- toast.info("No auto-zoom slots available", {
- description: "Detected dwell points overlap existing zoom regions.",
- });
- return;
- }
-
- for (const region of result.suggestions) {
- onZoomSuggested({ start: region.start, end: region.end }, region.focus);
- }
-
- toast.success(`Added ${result.suggestions.length} interaction-based zoom suggestion${result.suggestions.length === 1 ? "" : "s"}`);
- }, [
- videoDuration,
- totalMs,
- defaultRegionDurationMs,
- zoomRegions,
- disableSuggestedZooms,
- onZoomSuggested,
- cursorTelemetry,
- ]);
-
- useEffect(() => {
- if (autoSuggestZoomsTrigger <= 0) {
- return;
- }
-
- onAutoSuggestZoomsConsumed?.();
-
- handleSuggestZooms();
- }, [autoSuggestZoomsTrigger, handleSuggestZooms, onAutoSuggestZoomsConsumed]);
-
- const handleAddTrim = useCallback(() => {
- if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onTrimAdded) {
- return;
- }
-
- const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
- if (defaultDuration <= 0) {
- return;
- }
-
- // Always place trim at playhead
- const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
- // Find the next trim region after the playhead
- const sorted = [...trimRegions].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 trim region
- const isOverlapping = sorted.some(region => startPos >= region.startMs && startPos < region.endMs);
- if (isOverlapping || gapToNext <= 0) {
- toast.error("Cannot place trim here", {
- description: "Trim already exists at this location or not enough space available.",
- });
- return;
- }
-
- const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
- onTrimAdded({ start: startPos, end: startPos + actualDuration });
- }, [videoDuration, totalMs, currentTimeMs, trimRegions, onTrimAdded, defaultRegionDurationMs]);
-
- const handleSplitClip = useCallback(() => {
- if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onClipSplit) {
- return;
- }
- onClipSplit(currentTimeMs);
- }, [videoDuration, totalMs, currentTimeMs, onClipSplit]);
-
- 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 handleAddAudio = useCallback(async () => {
- if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAudioAdded) {
- return;
- }
-
- const result = await (window as any).electronAPI.openAudioFilePicker();
- if (!result?.success || !result.path) {
- return;
- }
-
- const audioPath = result.path;
-
- // Load the audio file to get its full duration
- const audioDurationMs = await new Promise((resolve) => {
- const audio = new Audio(toFileUrl(audioPath));
- audio.addEventListener('loadedmetadata', () => {
- resolve(Math.round(audio.duration * 1000));
- });
- audio.addEventListener('error', () => {
- resolve(0);
- });
- });
-
- if (audioDurationMs <= 0) {
- toast.error("Could not read audio file", {
- description: "The selected file may be corrupted or in an unsupported format.",
- });
- return;
- }
-
- const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
- const sorted = [...audioRegions].sort((a, b) => a.startMs - b.startMs);
- const nextRegion = sorted.find(region => region.startMs > startPos);
- const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos;
-
- const isOverlapping = sorted.some(region => startPos >= region.startMs && startPos < region.endMs);
- if (isOverlapping || gapToNext <= 0) {
- toast.error("Cannot place audio here", {
- description: "Audio region already exists at this location or not enough space available.",
- });
- return;
- }
-
- // Use full audio duration, but clamp to available gap and video length
- const actualDuration = Math.min(audioDurationMs, gapToNext, totalMs - startPos);
- onAudioAdded({ start: startPos, end: startPos + actualDuration }, result.path);
- }, [videoDuration, totalMs, currentTimeMs, audioRegions, onAudioAdded]);
-
- const handleAddAnnotation = useCallback((trackIndex = 0) => {
- if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAnnotationAdded) {
- return;
- }
-
- const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
- if (defaultDuration <= 0) {
- return;
- }
-
- // Multiple annotations can exist at the same timestamp
- const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
- const endPos = Math.min(startPos + defaultDuration, totalMs);
-
- onAnnotationAdded({ start: startPos, end: endPos }, trackIndex);
- }, [videoDuration, totalMs, currentTimeMs, onAnnotationAdded, defaultRegionDurationMs]);
-
- useEffect(() => {
- const handleKeyDown = (e: KeyboardEvent) => {
- if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
- return;
- }
-
- if (matchesShortcut(e, { key: 'a', ctrl: true }, isMac)) {
- if (!hasAnyTimelineBlocks || !isTimelineFocusedRef.current) {
- return;
- }
-
- e.preventDefault();
- setSelectedKeyframeId(null);
- setSelectAllBlocksActive(true);
- return;
- }
-
- if (matchesShortcut(e, keyShortcuts.addKeyframe, isMac)) {
- addKeyframe();
- }
- if (matchesShortcut(e, keyShortcuts.addZoom, isMac)) {
- handleAddZoom();
- }
- if (matchesShortcut(e, keyShortcuts.addTrim, isMac)) {
- handleAddTrim();
- }
- if (matchesShortcut(e, keyShortcuts.splitClip, isMac)) {
- handleSplitClip();
- }
- if (matchesShortcut(e, keyShortcuts.addAnnotation, isMac)) {
- handleAddAnnotation();
- }
- if (matchesShortcut(e, keyShortcuts.addSpeed, isMac)) {
- handleAddSpeed();
- }
-
- // Tab: Cycle through overlapping annotations at current time
- if (e.key === 'Tab' && annotationRegions.length > 0) {
- const currentTimeMs = Math.round(currentTime * 1000);
- const overlapping = annotationRegions
- .filter(a => currentTimeMs >= a.startMs && currentTimeMs <= a.endMs)
- .sort((a, b) => a.zIndex - b.zIndex); // Sort by z-index
-
- if (overlapping.length > 0) {
- e.preventDefault();
-
- if (!selectedAnnotationId || !overlapping.some(a => a.id === selectedAnnotationId)) {
- onSelectAnnotation?.(overlapping[0].id);
- } else {
- // Cycle to next annotation
- const currentIndex = overlapping.findIndex(a => a.id === selectedAnnotationId);
- const nextIndex = e.shiftKey
- ? (currentIndex - 1 + overlapping.length) % overlapping.length // Shift+Tab = backward
- : (currentIndex + 1) % overlapping.length; // Tab = forward
- onSelectAnnotation?.(overlapping[nextIndex].id);
- }
- }
- }
- // Delete key or Ctrl+D / Cmd+D
- if (e.key === 'Delete' || e.key === 'Backspace' || matchesShortcut(e, keyShortcuts.deleteSelected, isMac)) {
- if (selectAllBlocksActive) {
- e.preventDefault();
- deleteAllBlocks();
- } else if (selectedKeyframeId) {
- deleteSelectedKeyframe();
- } else if (selectedZoomId) {
- deleteSelectedZoom();
- } else if (selectedTrimId) {
- deleteSelectedTrim();
- } else if (selectedClipId) {
- deleteSelectedClip();
- } else if (selectedAnnotationId) {
- deleteSelectedAnnotation();
- } else if (selectedSpeedId) {
- deleteSelectedSpeed();
- } else if (selectedAudioId) {
- deleteSelectedAudio();
- }
- }
- };
- window.addEventListener('keydown', handleKeyDown);
- return () => window.removeEventListener('keydown', handleKeyDown);
- }, [addKeyframe, handleAddZoom, handleAddTrim, handleSplitClip, handleAddAnnotation, handleAddSpeed, deleteAllBlocks, deleteSelectedKeyframe, deleteSelectedZoom, deleteSelectedTrim, deleteSelectedClip, deleteSelectedAnnotation, deleteSelectedSpeed, deleteSelectedAudio, selectedKeyframeId, selectedZoomId, selectedTrimId, selectedClipId, selectedAnnotationId, selectedSpeedId, selectedAudioId, annotationRegions, currentTime, hasAnyTimelineBlocks, onSelectAnnotation, keyShortcuts, isMac, selectAllBlocksActive]);
-
- const clampedRange = useMemo(() => {
- if (totalMs === 0) {
- return range;
- }
-
- return {
- start: Math.max(0, Math.min(range.start, totalMs)),
- end: Math.min(range.end, totalMs),
- };
- }, [range, totalMs]);
-
- useImperativeHandle(ref, () => ({
- addZoom: handleAddZoom,
- suggestZooms: handleSuggestZooms,
- splitClip: handleSplitClip,
- addAnnotation: handleAddAnnotation,
- addAudio: handleAddAudio,
- keyframes,
- }), [handleAddAnnotation, handleAddAudio, handleAddZoom, handleSuggestZooms, handleSplitClip, keyframes]);
-
- const timelineItems = useMemo(() => {
- const zooms: TimelineRenderItem[] = zoomRegions.map((region, index) => ({
- id: region.id,
- rowId: ZOOM_ROW_ID,
- span: { start: region.startMs, end: region.endMs },
- label: `Zoom ${index + 1}`,
- zoomDepth: region.depth,
- variant: 'zoom',
- }));
-
- const clips: TimelineRenderItem[] = clipRegions.map((region, index) => ({
- id: region.id,
- rowId: CLIP_ROW_ID,
- span: { start: region.startMs, end: region.endMs },
- label: `Clip ${index + 1}`,
- variant: 'clip',
- }));
-
- const annotations: TimelineRenderItem[] = annotationRegions.map((region) => {
- let label: string;
-
- if (region.type === 'text') {
- // Show text preview
- const preview = region.content.trim() || 'Empty text';
- label = preview.length > 20 ? `${preview.substring(0, 20)}...` : preview;
- } else if (region.type === 'image') {
- label = 'Image';
- } else {
- label = 'Annotation';
- }
-
- return {
- id: region.id,
- rowId: ANNOTATION_ROW_ID,
- span: { start: region.startMs, end: region.endMs },
- label,
- variant: 'annotation',
- };
- });
-
- const audios: TimelineRenderItem[] = audioRegions.map((region) => {
- const fileName = region.audioPath.split(/[\\/]/).pop()?.replace(/\.[^.]+$/, '') || 'Audio';
- return {
- id: region.id,
- rowId: AUDIO_ROW_ID,
- span: { start: region.startMs, end: region.endMs },
- label: fileName,
- variant: 'audio',
- };
- });
-
- return [...zooms, ...clips, ...annotations, ...audios];
- }, [zoomRegions, clipRegions, annotationRegions, audioRegions]);
-
- // Flat list of draggable row spans for neighbour-clamping during drag/resize.
- const allRegionSpans = useMemo(() => {
- const zooms = zoomRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs, rowId: ZOOM_ROW_ID }));
- const clips = clipRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs, rowId: CLIP_ROW_ID }));
- const audios = audioRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs, rowId: AUDIO_ROW_ID }));
- return [...zooms, ...clips, ...audios];
- }, [zoomRegions, clipRegions, audioRegions]);
-
- const handleItemSpanChange = useCallback((id: string, span: Span) => {
- // Check if it's a zoom, trim, clip, speed, or annotation item
- if (zoomRegions.some(r => r.id === id)) {
- onZoomSpanChange(id, span);
- } else if (clipRegions.some(r => r.id === id)) {
- onClipSpanChange?.(id, span);
- } else if (annotationRegions.some(r => r.id === id)) {
- onAnnotationSpanChange?.(id, span);
- } else if (audioRegions.some(r => r.id === id)) {
- onAudioSpanChange?.(id, span);
- }
- }, [zoomRegions, clipRegions, annotationRegions, audioRegions, onZoomSpanChange, onClipSpanChange, onAnnotationSpanChange, onAudioSpanChange]);
-
- const panTimelineRange = useCallback((deltaMs: number) => {
- if (!Number.isFinite(deltaMs) || deltaMs === 0 || totalMs <= 0) {
- return;
- }
-
- setRange((previous) => {
- const visibleSpan = Math.max(1, previous.end - previous.start);
- const maxStart = Math.max(0, totalMs - visibleSpan);
- const nextStart = Math.max(0, Math.min(previous.start + deltaMs, maxStart));
-
- return {
- start: nextStart,
- end: nextStart + visibleSpan,
- };
- });
- }, [totalMs]);
-
- const handleTimelineWheel = useCallback((event: WheelEvent) => {
- if (event.ctrlKey || event.metaKey || totalMs <= 0) {
- return;
- }
-
- const rawHorizontalDelta = Math.abs(event.deltaX) > 0
- ? event.deltaX
- : event.shiftKey && Math.abs(event.deltaY) > 0
- ? event.deltaY
- : 0;
-
- if (rawHorizontalDelta === 0) {
- return;
- }
-
- const containerWidth = timelineContainerRef.current?.clientWidth ?? 0;
- const visibleRangeMs = clampedRange.end - clampedRange.start;
-
- if (containerWidth <= 0 || visibleRangeMs <= 0) {
- return;
- }
-
- event.preventDefault();
-
- const horizontalDeltaPx = normalizeWheelDeltaToPixels(rawHorizontalDelta, event.deltaMode);
- const deltaMs = (horizontalDeltaPx / containerWidth) * visibleRangeMs;
-
- panTimelineRange(deltaMs);
- }, [clampedRange.end, clampedRange.start, panTimelineRange, totalMs]);
-
- if (!videoDuration || videoDuration === 0) {
- return (
-
-
-
-
No Video Loaded
-
Drag and drop a video to start editing
-
-
- );
- }
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {ASPECT_RATIOS.map((ratio) => (
- onAspectRatioChange?.(ratio)}
- className="text-slate-300 hover:text-white hover:bg-white/10 cursor-pointer flex items-center justify-between gap-3"
- >
- {getAspectRatioLabel(ratio)}
- {aspectRatio === ratio && }
-
- ))}
-
-
- Custom
- setCustomAspectWidth(event.target.value.replace(/\D/g, ''))}
- onKeyDown={handleCustomAspectRatioKeyDown}
- className="w-12 h-7 rounded border border-white/15 bg-black/20 px-1.5 text-sm text-slate-100 focus:outline-none focus:ring-1 focus:ring-[#2563EB]"
- aria-label="Custom aspect width"
- />
- :
- setCustomAspectHeight(event.target.value.replace(/\D/g, ''))}
- onKeyDown={handleCustomAspectRatioKeyDown}
- className="w-12 h-7 rounded border border-white/15 bg-black/20 px-1.5 text-sm text-slate-100 focus:outline-none focus:ring-1 focus:ring-[#2563EB]"
- aria-label="Custom aspect height"
- />
-
- {isCustomAspectRatio(aspectRatio) && }
-
-
-
-
-
-
-
-
-
- Side Scroll
- Pan
-
-
- {scrollLabels.pan}
- Pan
-
-
- {scrollLabels.zoom}
- Zoom
-
-
-
-
{
- isTimelineFocusedRef.current = true;
- }}
- onBlur={() => {
- isTimelineFocusedRef.current = false;
- }}
- onMouseDown={() => {
- timelineContainerRef.current?.focus();
- isTimelineFocusedRef.current = true;
- }}
- onClick={() => {
- setSelectedKeyframeId(null);
- setSelectAllBlocksActive(false);
- }}
- onWheel={handleTimelineWheel}
- >
-
-
-
-
-
-
- );
-});
+const TimelineEditor = forwardRef(
+ function TimelineEditor(
+ {
+ videoDuration,
+ currentTime,
+ playheadTime,
+ onSeek,
+ cursorTelemetry = [],
+ autoSuggestZoomsTrigger = 0,
+ onAutoSuggestZoomsConsumed,
+ disableSuggestedZooms = false,
+ zoomRegions,
+ onZoomAdded,
+ onZoomSuggested,
+ onZoomSpanChange,
+ onZoomDelete,
+ selectedZoomId,
+ onSelectZoom,
+ trimRegions = [],
+ onTrimAdded,
+ onTrimSpanChange,
+ onTrimDelete,
+ selectedTrimId,
+ onSelectTrim,
+ clipRegions = [],
+ onClipSplit,
+ onClipSpanChange,
+ onClipDelete,
+ selectedClipId,
+ onSelectClip,
+ annotationRegions = [],
+ onAnnotationAdded,
+ onAnnotationSpanChange,
+ onAnnotationDelete,
+ selectedAnnotationId,
+ onSelectAnnotation,
+ speedRegions = [],
+ onSpeedAdded,
+ onSpeedSpanChange,
+ onSpeedDelete,
+ selectedSpeedId,
+ onSelectSpeed,
+ audioRegions = [],
+ onAudioAdded,
+ onAudioSpanChange,
+ onAudioDelete,
+ selectedAudioId,
+ onSelectAudio,
+ aspectRatio = "native",
+ onAspectRatioChange,
+ onOpenCropEditor,
+ isCropped = false,
+ videoPath,
+ },
+ ref,
+ ) {
+ const t = useScopedT("settings");
+ const initialEditorPreferences = useMemo(() => loadEditorPreferences(), []);
+ const totalMs = useMemo(
+ () => Math.max(0, Math.round(videoDuration * 1000)),
+ [videoDuration],
+ );
+ const currentTimeMs = useMemo(
+ () => Math.round((playheadTime ?? currentTime) * 1000),
+ [currentTime, playheadTime],
+ );
+ const timelineScale = useMemo(() => calculateTimelineScale(videoDuration), [videoDuration]);
+ const safeMinDurationMs = useMemo(
+ () =>
+ totalMs > 0
+ ? Math.min(timelineScale.minItemDurationMs, totalMs)
+ : timelineScale.minItemDurationMs,
+ [timelineScale.minItemDurationMs, totalMs],
+ );
+
+ const [range, setRange] = useState(() => createInitialRange(totalMs));
+ const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]);
+ const [selectedKeyframeId, setSelectedKeyframeId] = useState(null);
+ const [selectAllBlocksActive, setSelectAllBlocksActive] = useState(false);
+ const [customAspectWidth, setCustomAspectWidth] = useState(
+ initialEditorPreferences.customAspectWidth,
+ );
+ const [customAspectHeight, setCustomAspectHeight] = useState(
+ initialEditorPreferences.customAspectHeight,
+ );
+ const [scrollLabels, setScrollLabels] = useState({
+ pan: "Shift + Ctrl + Scroll",
+ zoom: "Ctrl + Scroll",
+ });
+ const isTimelineFocusedRef = useRef(false);
+ const timelineContainerRef = useRef(null);
+ const { shortcuts: keyShortcuts, isMac } = useShortcuts();
+ const audioPeaks = useAudioPeaks(videoPath);
+
+ useEffect(() => {
+ setRange(createInitialRange(totalMs));
+ }, [totalMs]);
+
+ useEffect(() => {
+ if (aspectRatio === "native") {
+ return;
+ }
+ const [width, height] = aspectRatio.split(":");
+ if (width && height) {
+ setCustomAspectWidth(width);
+ setCustomAspectHeight(height);
+ }
+ }, [aspectRatio]);
+
+ useEffect(() => {
+ saveEditorPreferences({
+ customAspectWidth,
+ customAspectHeight,
+ });
+ }, [customAspectHeight, customAspectWidth]);
+
+ const applyCustomAspectRatio = useCallback(() => {
+ const width = Number.parseInt(customAspectWidth, 10);
+ const height = Number.parseInt(customAspectHeight, 10);
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
+ toast.error("Custom aspect ratio must be positive numbers.");
+ return;
+ }
+ onAspectRatioChange?.(`${width}:${height}` as AspectRatio);
+ }, [customAspectHeight, customAspectWidth, onAspectRatioChange]);
+
+ const handleCustomAspectRatioKeyDown = useCallback(
+ (event: ReactKeyboardEvent) => {
+ // Prevent Radix DropdownMenu typeahead from selecting preset items while typing.
+ event.stopPropagation();
+ if (event.key === "Enter") {
+ event.preventDefault();
+ applyCustomAspectRatio();
+ }
+ },
+ [applyCustomAspectRatio],
+ );
+
+ useEffect(() => {
+ formatShortcut(["shift", "mod", "Scroll"]).then((pan) => {
+ formatShortcut(["mod", "Scroll"]).then((zoom) => {
+ setScrollLabels({ pan, zoom });
+ });
+ });
+ }, []);
+
+ // Add keyframe at current playhead position
+ const addKeyframe = useCallback(() => {
+ if (totalMs === 0) return;
+ const time = Math.max(0, Math.min(currentTimeMs, totalMs));
+ if (keyframes.some((kf) => Math.abs(kf.time - time) < 1)) return;
+ setKeyframes((prev) => [...prev, { id: uuidv4(), time }]);
+ }, [currentTimeMs, totalMs, keyframes]);
+
+ // Delete selected keyframe
+ const deleteSelectedKeyframe = useCallback(() => {
+ if (!selectedKeyframeId) return;
+ setKeyframes((prev) => prev.filter((kf) => kf.id !== selectedKeyframeId));
+ setSelectedKeyframeId(null);
+ }, [selectedKeyframeId]);
+
+ // Move keyframe to new time position
+ const handleKeyframeMove = useCallback(
+ (id: string, newTime: number) => {
+ setKeyframes((prev) =>
+ prev.map((kf) =>
+ kf.id === id
+ ? { ...kf, time: Math.max(0, Math.min(newTime, totalMs)) }
+ : kf,
+ ),
+ );
+ },
+ [totalMs],
+ );
+
+ // Delete selected zoom item
+ const deleteSelectedZoom = useCallback(() => {
+ if (!selectedZoomId) return;
+ onZoomDelete(selectedZoomId);
+ onSelectZoom(null);
+ }, [selectedZoomId, onZoomDelete, onSelectZoom]);
+
+ // Delete selected trim item
+ const deleteSelectedTrim = useCallback(() => {
+ if (!selectedTrimId || !onTrimDelete || !onSelectTrim) return;
+ onTrimDelete(selectedTrimId);
+ onSelectTrim(null);
+ }, [selectedTrimId, onTrimDelete, onSelectTrim]);
+
+ const deleteSelectedClip = useCallback(() => {
+ if (!selectedClipId || !onClipDelete || !onSelectClip) return;
+ onClipDelete(selectedClipId);
+ onSelectClip(null);
+ }, [selectedClipId, onClipDelete, onSelectClip]);
+
+ const deleteSelectedAnnotation = useCallback(() => {
+ if (!selectedAnnotationId || !onAnnotationDelete || !onSelectAnnotation) return;
+ onAnnotationDelete(selectedAnnotationId);
+ onSelectAnnotation(null);
+ }, [selectedAnnotationId, onAnnotationDelete, onSelectAnnotation]);
+
+ const deleteSelectedSpeed = useCallback(() => {
+ if (!selectedSpeedId || !onSpeedDelete || !onSelectSpeed) return;
+ onSpeedDelete(selectedSpeedId);
+ onSelectSpeed(null);
+ }, [selectedSpeedId, onSpeedDelete, onSelectSpeed]);
+
+ const deleteSelectedAudio = useCallback(() => {
+ if (!selectedAudioId || !onAudioDelete || !onSelectAudio) return;
+ onAudioDelete(selectedAudioId);
+ onSelectAudio(null);
+ }, [selectedAudioId, onAudioDelete, onSelectAudio]);
+
+ const clearSelectedBlocks = useCallback(() => {
+ onSelectZoom(null);
+ onSelectTrim?.(null);
+ onSelectClip?.(null);
+ onSelectAnnotation?.(null);
+ onSelectSpeed?.(null);
+ onSelectAudio?.(null);
+ setSelectAllBlocksActive(false);
+ }, [
+ onSelectAnnotation,
+ onSelectAudio,
+ onSelectClip,
+ onSelectSpeed,
+ onSelectTrim,
+ onSelectZoom,
+ ]);
+
+ const hasAnyTimelineBlocks =
+ zoomRegions.length > 0 ||
+ trimRegions.length > 0 ||
+ clipRegions.length > 0 ||
+ annotationRegions.length > 0 ||
+ speedRegions.length > 0 ||
+ audioRegions.length > 0;
+
+ const deleteAllBlocks = useCallback(() => {
+ const zoomIds = zoomRegions.map((region) => region.id);
+ const trimIds = trimRegions.map((region) => region.id);
+ const clipIds = clipRegions.map((region) => region.id);
+ const annotationIds = annotationRegions.map((region) => region.id);
+ const speedIds = speedRegions.map((region) => region.id);
+ const audioIds = audioRegions.map((region) => region.id);
+
+ zoomIds.forEach((id) => onZoomDelete(id));
+ trimIds.forEach((id) => onTrimDelete?.(id));
+ clipIds.forEach((id) => onClipDelete?.(id));
+ annotationIds.forEach((id) => onAnnotationDelete?.(id));
+ speedIds.forEach((id) => onSpeedDelete?.(id));
+ audioIds.forEach((id) => onAudioDelete?.(id));
+
+ clearSelectedBlocks();
+ setSelectedKeyframeId(null);
+ }, [
+ annotationRegions,
+ audioRegions,
+ clearSelectedBlocks,
+ clipRegions,
+ onAnnotationDelete,
+ onAudioDelete,
+ onClipDelete,
+ onSpeedDelete,
+ onTrimDelete,
+ onZoomDelete,
+ speedRegions,
+ trimRegions,
+ zoomRegions,
+ ]);
+
+ const handleSelectZoom = useCallback(
+ (id: string | null) => {
+ setSelectAllBlocksActive(false);
+ onSelectZoom(id);
+ },
+ [onSelectZoom],
+ );
+
+ const handleSelectTrim = useCallback(
+ (id: string | null) => {
+ setSelectAllBlocksActive(false);
+ onSelectTrim?.(id);
+ },
+ [onSelectTrim],
+ );
+
+ const handleSelectClip = useCallback(
+ (id: string | null) => {
+ setSelectAllBlocksActive(false);
+ onSelectClip?.(id);
+ },
+ [onSelectClip],
+ );
+
+ const handleSelectAnnotation = useCallback(
+ (id: string | null) => {
+ setSelectAllBlocksActive(false);
+ onSelectAnnotation?.(id);
+ },
+ [onSelectAnnotation],
+ );
+
+ const handleSelectSpeed = useCallback(
+ (id: string | null) => {
+ setSelectAllBlocksActive(false);
+ onSelectSpeed?.(id);
+ },
+ [onSelectSpeed],
+ );
+
+ const handleSelectAudio = useCallback(
+ (id: string | null) => {
+ setSelectAllBlocksActive(false);
+ onSelectAudio?.(id);
+ },
+ [onSelectAudio],
+ );
+
+ useEffect(() => {
+ setRange(createInitialRange(totalMs));
+ }, [totalMs]);
+
+ // Normalize regions only when timeline bounds change (not on every region edit).
+ // Using refs to read current regions avoids a dependency-loop that re-fires
+ // 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);
+ const audioRegionsRef = useRef(audioRegions);
+ zoomRegionsRef.current = zoomRegions;
+ trimRegionsRef.current = trimRegions;
+ speedRegionsRef.current = speedRegions;
+ audioRegionsRef.current = audioRegions;
+
+ useEffect(() => {
+ if (totalMs === 0 || safeMinDurationMs <= 0) {
+ return;
+ }
+
+ zoomRegionsRef.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) {
+ onZoomSpanChange(region.id, { start: normalizedStart, end: normalizedEnd });
+ }
+ });
+
+ trimRegionsRef.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) {
+ 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 });
+ }
+ });
+
+ audioRegionsRef.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) {
+ onAudioSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd });
+ }
+ });
+ // Only re-run when the timeline scale changes, not on every region edit
+ }, [
+ totalMs,
+ safeMinDurationMs,
+ onZoomSpanChange,
+ onTrimSpanChange,
+ onSpeedSpanChange,
+ onAudioSpanChange,
+ ]);
+
+ 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 isClipItem = clipRegions.some((r) => r.id === excludeId);
+ const isAnnotationItem = annotationRegions.some((r) => r.id === excludeId);
+ const isSpeedItem = speedRegions.some((r) => r.id === excludeId);
+ const isAudioItem = audioRegions.some((r) => r.id === excludeId);
+
+ if (isAnnotationItem) {
+ return false;
+ }
+
+ // Helper to check overlap against a specific set of regions
+ const checkOverlap = (
+ regions: (ZoomRegion | TrimRegion | ClipRegion | SpeedRegion | AudioRegion)[],
+ ) => {
+ return regions.some((region) => {
+ if (region.id === excludeId) return false;
+ // True overlap: regions actually intersect (not just adjacent)
+ return newSpan.end > region.startMs && newSpan.start < region.endMs;
+ });
+ };
+
+ if (isZoomItem) {
+ return checkOverlap(zoomRegions);
+ }
+
+ if (isTrimItem) {
+ return checkOverlap(trimRegions);
+ }
+
+ if (isClipItem) {
+ return checkOverlap(clipRegions);
+ }
+
+ if (isSpeedItem) {
+ return checkOverlap(speedRegions);
+ }
+
+ if (isAudioItem) {
+ return checkOverlap(audioRegions);
+ }
+
+ return false;
+ },
+ [zoomRegions, trimRegions, clipRegions, annotationRegions, speedRegions, audioRegions],
+ );
+
+ // Keep newly added timeline regions at the original short default instead of
+ // scaling them with the full recording length.
+ const defaultRegionDurationMs = useMemo(() => Math.min(1000, totalMs), [totalMs]);
+
+ const handleAddZoom = useCallback(() => {
+ if (!videoDuration || videoDuration === 0 || totalMs === 0) {
+ return;
+ }
+
+ const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
+ if (defaultDuration <= 0) {
+ return;
+ }
+
+ // Always place zoom at playhead
+ const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
+ // Find the next zoom region after the playhead
+ const sorted = [...zoomRegions].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 zoom region
+ const isOverlapping = sorted.some(
+ (region) => startPos >= region.startMs && startPos < region.endMs,
+ );
+ if (isOverlapping || gapToNext <= 0) {
+ toast.error("Cannot place zoom here", {
+ description:
+ "Zoom already exists at this location or not enough space available.",
+ });
+ return;
+ }
+
+ const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
+ onZoomAdded({ start: startPos, end: startPos + actualDuration });
+ }, [
+ videoDuration,
+ totalMs,
+ currentTimeMs,
+ zoomRegions,
+ onZoomAdded,
+ defaultRegionDurationMs,
+ ]);
+
+ const handleSuggestZooms = useCallback(() => {
+ if (!videoDuration || videoDuration === 0 || totalMs === 0) {
+ return;
+ }
+
+ if (disableSuggestedZooms) {
+ toast.info("Suggested zooms are unavailable while cursor looping is enabled.");
+ return;
+ }
+
+ if (!onZoomSuggested) {
+ toast.error("Zoom suggestion handler unavailable");
+ return;
+ }
+
+ if (cursorTelemetry.length < 2) {
+ toast.info("No cursor telemetry available", {
+ description: "Record a screencast first to generate cursor-based suggestions.",
+ });
+ return;
+ }
+
+ const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
+ if (defaultDuration <= 0) {
+ return;
+ }
+
+ const result = buildInteractionZoomSuggestions({
+ cursorTelemetry,
+ totalMs,
+ defaultDurationMs: defaultDuration,
+ reservedSpans: zoomRegions
+ .map((region) => ({ start: region.startMs, end: region.endMs }))
+ .sort((a, b) => a.start - b.start),
+ });
+
+ if (result.status === "no-telemetry") {
+ toast.info("No usable cursor telemetry", {
+ description: "The recording does not include enough cursor movement data.",
+ });
+ return;
+ }
+
+ if (result.status === "no-interactions") {
+ toast.info("No clear interaction moments found", {
+ description: "Try a recording with pauses or clicks around important actions.",
+ });
+ return;
+ }
+
+ if (result.status === "no-slots" || result.suggestions.length === 0) {
+ toast.info("No auto-zoom slots available", {
+ description: "Detected dwell points overlap existing zoom regions.",
+ });
+ return;
+ }
+
+ for (const region of result.suggestions) {
+ onZoomSuggested({ start: region.start, end: region.end }, region.focus);
+ }
+
+ toast.success(
+ `Added ${result.suggestions.length} interaction-based zoom suggestion${result.suggestions.length === 1 ? "" : "s"}`,
+ );
+ }, [
+ videoDuration,
+ totalMs,
+ defaultRegionDurationMs,
+ zoomRegions,
+ disableSuggestedZooms,
+ onZoomSuggested,
+ cursorTelemetry,
+ ]);
+
+ useEffect(() => {
+ if (autoSuggestZoomsTrigger <= 0) {
+ return;
+ }
+
+ onAutoSuggestZoomsConsumed?.();
+
+ handleSuggestZooms();
+ }, [autoSuggestZoomsTrigger, handleSuggestZooms, onAutoSuggestZoomsConsumed]);
+
+ const handleAddTrim = useCallback(() => {
+ if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onTrimAdded) {
+ return;
+ }
+
+ const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
+ if (defaultDuration <= 0) {
+ return;
+ }
+
+ // Always place trim at playhead
+ const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
+ // Find the next trim region after the playhead
+ const sorted = [...trimRegions].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 trim region
+ const isOverlapping = sorted.some(
+ (region) => startPos >= region.startMs && startPos < region.endMs,
+ );
+ if (isOverlapping || gapToNext <= 0) {
+ toast.error("Cannot place trim here", {
+ description:
+ "Trim already exists at this location or not enough space available.",
+ });
+ return;
+ }
+
+ const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
+ onTrimAdded({ start: startPos, end: startPos + actualDuration });
+ }, [
+ videoDuration,
+ totalMs,
+ currentTimeMs,
+ trimRegions,
+ onTrimAdded,
+ defaultRegionDurationMs,
+ ]);
+
+ const handleSplitClip = useCallback(() => {
+ if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onClipSplit) {
+ return;
+ }
+ onClipSplit(currentTimeMs);
+ }, [videoDuration, totalMs, currentTimeMs, onClipSplit]);
+
+ 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 handleAddAudio = useCallback(async () => {
+ if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAudioAdded) {
+ return;
+ }
+
+ const result = await window.electronAPI.openAudioFilePicker();
+ if (!result?.success || !result.path) {
+ return;
+ }
+
+ const audioPath = result.path;
+
+ // Load the audio file to get its full duration
+ const audioDurationMs = await new Promise((resolve) => {
+ const audio = new Audio(toFileUrl(audioPath));
+ audio.addEventListener("loadedmetadata", () => {
+ resolve(Math.round(audio.duration * 1000));
+ });
+ audio.addEventListener("error", () => {
+ resolve(0);
+ });
+ });
+
+ if (audioDurationMs <= 0) {
+ toast.error("Could not read audio file", {
+ description: "The selected file may be corrupted or in an unsupported format.",
+ });
+ return;
+ }
+
+ const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
+ const sorted = [...audioRegions].sort((a, b) => a.startMs - b.startMs);
+ const nextRegion = sorted.find((region) => region.startMs > startPos);
+ const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos;
+
+ const isOverlapping = sorted.some(
+ (region) => startPos >= region.startMs && startPos < region.endMs,
+ );
+ if (isOverlapping || gapToNext <= 0) {
+ toast.error("Cannot place audio here", {
+ description:
+ "Audio region already exists at this location or not enough space available.",
+ });
+ return;
+ }
+
+ // Use full audio duration, but clamp to available gap and video length
+ const actualDuration = Math.min(audioDurationMs, gapToNext, totalMs - startPos);
+ onAudioAdded({ start: startPos, end: startPos + actualDuration }, result.path);
+ }, [videoDuration, totalMs, currentTimeMs, audioRegions, onAudioAdded]);
+
+ const handleAddAnnotation = useCallback(
+ (trackIndex = 0) => {
+ if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAnnotationAdded) {
+ return;
+ }
+
+ const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
+ if (defaultDuration <= 0) {
+ return;
+ }
+
+ // Multiple annotations can exist at the same timestamp
+ const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
+ const endPos = Math.min(startPos + defaultDuration, totalMs);
+
+ onAnnotationAdded({ start: startPos, end: endPos }, trackIndex);
+ },
+ [videoDuration, totalMs, currentTimeMs, onAnnotationAdded, defaultRegionDurationMs],
+ );
+
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (
+ e.target instanceof HTMLInputElement ||
+ e.target instanceof HTMLTextAreaElement
+ ) {
+ return;
+ }
+
+ if (matchesShortcut(e, { key: "a", ctrl: true }, isMac)) {
+ if (!hasAnyTimelineBlocks || !isTimelineFocusedRef.current) {
+ return;
+ }
+
+ e.preventDefault();
+ setSelectedKeyframeId(null);
+ setSelectAllBlocksActive(true);
+ return;
+ }
+
+ if (matchesShortcut(e, keyShortcuts.addKeyframe, isMac)) {
+ addKeyframe();
+ }
+ if (matchesShortcut(e, keyShortcuts.addZoom, isMac)) {
+ handleAddZoom();
+ }
+ if (matchesShortcut(e, keyShortcuts.addTrim, isMac)) {
+ handleAddTrim();
+ }
+ if (matchesShortcut(e, keyShortcuts.splitClip, isMac)) {
+ handleSplitClip();
+ }
+ if (matchesShortcut(e, keyShortcuts.addAnnotation, isMac)) {
+ handleAddAnnotation();
+ }
+ if (matchesShortcut(e, keyShortcuts.addSpeed, isMac)) {
+ handleAddSpeed();
+ }
+
+ // Tab: Cycle through overlapping annotations at current time
+ if (e.key === "Tab" && annotationRegions.length > 0) {
+ const currentTimeMs = Math.round(currentTime * 1000);
+ const overlapping = annotationRegions
+ .filter((a) => currentTimeMs >= a.startMs && currentTimeMs <= a.endMs)
+ .sort((a, b) => a.zIndex - b.zIndex); // Sort by z-index
+
+ if (overlapping.length > 0) {
+ e.preventDefault();
+
+ if (
+ !selectedAnnotationId ||
+ !overlapping.some((a) => a.id === selectedAnnotationId)
+ ) {
+ onSelectAnnotation?.(overlapping[0].id);
+ } else {
+ // Cycle to next annotation
+ const currentIndex = overlapping.findIndex(
+ (a) => a.id === selectedAnnotationId,
+ );
+ const nextIndex = e.shiftKey
+ ? (currentIndex - 1 + overlapping.length) % overlapping.length // Shift+Tab = backward
+ : (currentIndex + 1) % overlapping.length; // Tab = forward
+ onSelectAnnotation?.(overlapping[nextIndex].id);
+ }
+ }
+ }
+ // Delete key or Ctrl+D / Cmd+D
+ if (
+ e.key === "Delete" ||
+ e.key === "Backspace" ||
+ matchesShortcut(e, keyShortcuts.deleteSelected, isMac)
+ ) {
+ if (selectAllBlocksActive) {
+ e.preventDefault();
+ deleteAllBlocks();
+ } else if (selectedKeyframeId) {
+ deleteSelectedKeyframe();
+ } else if (selectedZoomId) {
+ deleteSelectedZoom();
+ } else if (selectedTrimId) {
+ deleteSelectedTrim();
+ } else if (selectedClipId) {
+ deleteSelectedClip();
+ } else if (selectedAnnotationId) {
+ deleteSelectedAnnotation();
+ } else if (selectedSpeedId) {
+ deleteSelectedSpeed();
+ } else if (selectedAudioId) {
+ deleteSelectedAudio();
+ }
+ }
+ };
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [
+ addKeyframe,
+ handleAddZoom,
+ handleAddTrim,
+ handleSplitClip,
+ handleAddAnnotation,
+ handleAddSpeed,
+ deleteAllBlocks,
+ deleteSelectedKeyframe,
+ deleteSelectedZoom,
+ deleteSelectedTrim,
+ deleteSelectedClip,
+ deleteSelectedAnnotation,
+ deleteSelectedSpeed,
+ deleteSelectedAudio,
+ selectedKeyframeId,
+ selectedZoomId,
+ selectedTrimId,
+ selectedClipId,
+ selectedAnnotationId,
+ selectedSpeedId,
+ selectedAudioId,
+ annotationRegions,
+ currentTime,
+ hasAnyTimelineBlocks,
+ onSelectAnnotation,
+ keyShortcuts,
+ isMac,
+ selectAllBlocksActive,
+ ]);
+
+ const clampedRange = useMemo(() => {
+ if (totalMs === 0) {
+ return range;
+ }
+
+ return {
+ start: Math.max(0, Math.min(range.start, totalMs)),
+ end: Math.min(range.end, totalMs),
+ };
+ }, [range, totalMs]);
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ addZoom: handleAddZoom,
+ suggestZooms: handleSuggestZooms,
+ splitClip: handleSplitClip,
+ addAnnotation: handleAddAnnotation,
+ addAudio: handleAddAudio,
+ keyframes,
+ }),
+ [
+ handleAddAnnotation,
+ handleAddAudio,
+ handleAddZoom,
+ handleSuggestZooms,
+ handleSplitClip,
+ keyframes,
+ ],
+ );
+
+ const timelineItems = useMemo(() => {
+ const zooms: TimelineRenderItem[] = zoomRegions.map((region, index) => ({
+ id: region.id,
+ rowId: ZOOM_ROW_ID,
+ span: { start: region.startMs, end: region.endMs },
+ label: `Zoom ${index + 1}`,
+ zoomDepth: region.depth,
+ variant: "zoom",
+ }));
+
+ const clips: TimelineRenderItem[] = clipRegions.map((region, index) => ({
+ id: region.id,
+ rowId: CLIP_ROW_ID,
+ span: { start: region.startMs, end: region.endMs },
+ label: `Clip ${index + 1}`,
+ variant: "clip",
+ }));
+
+ const annotations: TimelineRenderItem[] = annotationRegions.map((region) => {
+ let label: string;
+
+ if (region.type === "text") {
+ // Show text preview
+ const preview = region.content.trim() || "Empty text";
+ label = preview.length > 20 ? `${preview.substring(0, 20)}...` : preview;
+ } else if (region.type === "image") {
+ label = "Image";
+ } else {
+ label = "Annotation";
+ }
+
+ return {
+ id: region.id,
+ rowId: ANNOTATION_ROW_ID,
+ span: { start: region.startMs, end: region.endMs },
+ label,
+ variant: "annotation",
+ };
+ });
+
+ const audios: TimelineRenderItem[] = audioRegions.map((region) => {
+ const fileName =
+ region.audioPath
+ .split(/[\\/]/)
+ .pop()
+ ?.replace(/\.[^.]+$/, "") || "Audio";
+ return {
+ id: region.id,
+ rowId: AUDIO_ROW_ID,
+ span: { start: region.startMs, end: region.endMs },
+ label: fileName,
+ variant: "audio",
+ };
+ });
+
+ return [...zooms, ...clips, ...annotations, ...audios];
+ }, [zoomRegions, clipRegions, annotationRegions, audioRegions]);
+
+ // Flat list of draggable row spans for neighbour-clamping during drag/resize.
+ const allRegionSpans = useMemo(() => {
+ const zooms = zoomRegions.map((r) => ({
+ id: r.id,
+ start: r.startMs,
+ end: r.endMs,
+ rowId: ZOOM_ROW_ID,
+ }));
+ const clips = clipRegions.map((r) => ({
+ id: r.id,
+ start: r.startMs,
+ end: r.endMs,
+ rowId: CLIP_ROW_ID,
+ }));
+ const audios = audioRegions.map((r) => ({
+ id: r.id,
+ start: r.startMs,
+ end: r.endMs,
+ rowId: AUDIO_ROW_ID,
+ }));
+ return [...zooms, ...clips, ...audios];
+ }, [zoomRegions, clipRegions, audioRegions]);
+
+ const handleItemSpanChange = useCallback(
+ (id: string, span: Span) => {
+ // Check if it's a zoom, trim, clip, speed, or annotation item
+ if (zoomRegions.some((r) => r.id === id)) {
+ onZoomSpanChange(id, span);
+ } else if (clipRegions.some((r) => r.id === id)) {
+ onClipSpanChange?.(id, span);
+ } else if (annotationRegions.some((r) => r.id === id)) {
+ onAnnotationSpanChange?.(id, span);
+ } else if (audioRegions.some((r) => r.id === id)) {
+ onAudioSpanChange?.(id, span);
+ }
+ },
+ [
+ zoomRegions,
+ clipRegions,
+ annotationRegions,
+ audioRegions,
+ onZoomSpanChange,
+ onClipSpanChange,
+ onAnnotationSpanChange,
+ onAudioSpanChange,
+ ],
+ );
+
+ const panTimelineRange = useCallback(
+ (deltaMs: number) => {
+ if (!Number.isFinite(deltaMs) || deltaMs === 0 || totalMs <= 0) {
+ return;
+ }
+
+ setRange((previous) => {
+ const visibleSpan = Math.max(1, previous.end - previous.start);
+ const maxStart = Math.max(0, totalMs - visibleSpan);
+ const nextStart = Math.max(0, Math.min(previous.start + deltaMs, maxStart));
+
+ return {
+ start: nextStart,
+ end: nextStart + visibleSpan,
+ };
+ });
+ },
+ [totalMs],
+ );
+
+ const handleTimelineWheel = useCallback(
+ (event: WheelEvent) => {
+ if (event.ctrlKey || event.metaKey || totalMs <= 0) {
+ return;
+ }
+
+ const rawHorizontalDelta =
+ Math.abs(event.deltaX) > 0
+ ? event.deltaX
+ : event.shiftKey && Math.abs(event.deltaY) > 0
+ ? event.deltaY
+ : 0;
+
+ if (rawHorizontalDelta === 0) {
+ return;
+ }
+
+ const containerWidth = timelineContainerRef.current?.clientWidth ?? 0;
+ const visibleRangeMs = clampedRange.end - clampedRange.start;
+
+ if (containerWidth <= 0 || visibleRangeMs <= 0) {
+ return;
+ }
+
+ event.preventDefault();
+
+ const horizontalDeltaPx = normalizeWheelDeltaToPixels(
+ rawHorizontalDelta,
+ event.deltaMode,
+ );
+ const deltaMs = (horizontalDeltaPx / containerWidth) * visibleRangeMs;
+
+ panTimelineRange(deltaMs);
+ },
+ [clampedRange.end, clampedRange.start, panTimelineRange, totalMs],
+ );
+
+ if (!videoDuration || videoDuration === 0) {
+ return (
+
+
+
+
No Video Loaded
+
+ Drag and drop a video to start editing
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {ASPECT_RATIOS.map((ratio) => (
+ onAspectRatioChange?.(ratio)}
+ className="text-slate-300 hover:text-white hover:bg-white/10 cursor-pointer flex items-center justify-between gap-3"
+ >
+ {getAspectRatioLabel(ratio)}
+ {aspectRatio === ratio && (
+
+ )}
+
+ ))}
+
+
+ Custom
+
+ setCustomAspectWidth(
+ event.target.value.replace(/\D/g, ""),
+ )
+ }
+ onKeyDown={handleCustomAspectRatioKeyDown}
+ className="w-12 h-7 rounded border border-white/15 bg-black/20 px-1.5 text-sm text-slate-100 focus:outline-none focus:ring-1 focus:ring-[#2563EB]"
+ aria-label="Custom aspect width"
+ />
+ :
+
+ setCustomAspectHeight(
+ event.target.value.replace(/\D/g, ""),
+ )
+ }
+ onKeyDown={handleCustomAspectRatioKeyDown}
+ className="w-12 h-7 rounded border border-white/15 bg-black/20 px-1.5 text-sm text-slate-100 focus:outline-none focus:ring-1 focus:ring-[#2563EB]"
+ aria-label="Custom aspect height"
+ />
+
+ {isCustomAspectRatio(aspectRatio) && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ Side Scroll
+
+ Pan
+
+
+
+ {scrollLabels.pan}
+
+ Pan
+
+
+
+ {scrollLabels.zoom}
+
+ Zoom
+
+
+
+
{
+ isTimelineFocusedRef.current = true;
+ }}
+ onBlur={() => {
+ isTimelineFocusedRef.current = false;
+ }}
+ onMouseDown={() => {
+ timelineContainerRef.current?.focus();
+ isTimelineFocusedRef.current = true;
+ }}
+ onClick={() => {
+ setSelectedKeyframeId(null);
+ setSelectAllBlocksActive(false);
+ }}
+ onWheel={handleTimelineWheel}
+ >
+
+
+
+
+
+
+ );
+ },
+);
TimelineEditor.displayName = "TimelineEditor";
diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts
index 0d71fbb7..858ab65f 100644
--- a/src/components/video-editor/types.ts
+++ b/src/components/video-editor/types.ts
@@ -20,7 +20,13 @@ export interface CursorTelemetryPoint {
timeMs: number;
cx: number;
cy: number;
- interactionType?: "move" | "click" | "double-click" | "right-click" | "middle-click" | "mouseup";
+ interactionType?:
+ | "move"
+ | "click"
+ | "double-click"
+ | "right-click"
+ | "middle-click"
+ | "mouseup";
cursorType?:
| "arrow"
| "text"
@@ -43,14 +49,22 @@ export interface CursorVisualSettings {
style: CursorStyle;
}
-export type CursorStyle =
- | "tahoe"
- | "dot"
- | "figma"
- | "mono"
- | (string & {}); // extension-contributed cursor styles
+export type CursorStyle = "tahoe" | "dot" | "figma" | "mono" | (string & {}); // extension-contributed cursor styles
export const DEFAULT_CURSOR_STYLE: CursorStyle = "tahoe";
+export type EditorEffectSection =
+ | "scene"
+ | "cursor"
+ | "captions"
+ | "webcam"
+ | "settings"
+ | "zoom"
+ | "frame"
+ | "crop"
+ | "extensions"
+ | "clip"
+ | `ext:${string}`;
+
export type ZoomTransitionEasing = "recordly" | "glide" | "smooth" | "snappy" | "linear";
export type WebcamCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
@@ -183,7 +197,6 @@ export const BLUR_ANNOTATION_STRENGTH = 20;
export const BASE_PREVIEW_WIDTH = 1920;
export const BASE_PREVIEW_HEIGHT = 1080;
-
export type ArrowDirection =
| "up"
| "down"
diff --git a/src/i18n/locales/ko/launch.json b/src/i18n/locales/ko/launch.json
index bfe0a403..674c7646 100644
--- a/src/i18n/locales/ko/launch.json
+++ b/src/i18n/locales/ko/launch.json
@@ -27,6 +27,8 @@
"noMicrophonesFound": "마이크를 찾을 수 없습니다",
"webcam": "웹캠",
"turnOffWebcam": "웹캠 끄기",
+ "hideFloatingWebcamPreview": "플로팅 미리보기 숨기기",
+ "showFloatingWebcamPreview": "플로팅 미리보기 표시",
"selectWebcamToEnable": "사용할 웹캠을 선택하세요",
"noWebcamsFound": "웹캠을 찾을 수 없습니다",
"recordingsFolder": "녹화 폴더",
diff --git a/src/i18n/locales/ko/settings.json b/src/i18n/locales/ko/settings.json
index 750be65f..76646b52 100644
--- a/src/i18n/locales/ko/settings.json
+++ b/src/i18n/locales/ko/settings.json
@@ -159,6 +159,7 @@
"high": "높음",
"original": "원본"
},
+ "fpsTitle": "FPS",
"loop": "반복",
"outputDimensions": "출력: {{dimensions}}px",
"sizePresetOriginalShort": "원본",
diff --git a/src/i18n/locales/nl/launch.json b/src/i18n/locales/nl/launch.json
index 785c6098..33729275 100644
--- a/src/i18n/locales/nl/launch.json
+++ b/src/i18n/locales/nl/launch.json
@@ -27,6 +27,8 @@
"noMicrophonesFound": "Geen microfoons gevonden",
"webcam": "Webcam",
"turnOffWebcam": "Webcam uitschakelen",
+ "hideFloatingWebcamPreview": "Zwevende voorbeeldweergave verbergen",
+ "showFloatingWebcamPreview": "Zwevende voorbeeldweergave tonen",
"selectWebcamToEnable": "Selecteer een webcam om in te schakelen",
"noWebcamsFound": "Geen webcams gevonden",
"recordingsFolder": "Opnamepad",