diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx
index 586e09d2a..6d67c5588 100644
--- a/src/components/video-editor/SettingsPanel.tsx
+++ b/src/components/video-editor/SettingsPanel.tsx
@@ -519,6 +519,7 @@ interface SettingsPanelProps {
selectedZoomMode?: ZoomMode | null;
onZoomModeChange?: (mode: ZoomMode) => void;
onZoomDelete?: (id: string) => void;
+ onZoomDuplicate?: () => boolean | void;
selectedClipId?: string | null;
selectedClipSpeed?: number | null;
selectedClipMuted?: boolean | null;
@@ -538,6 +539,7 @@ interface SettingsPanelProps {
onAudioVolumeChange?: (volume: number) => void;
onAudioNormalizeChange?: (normalize: boolean) => void;
onAudioDelete?: (id: string) => void;
+ onAudioDuplicate?: () => boolean | void;
shadowIntensity?: number;
onShadowChange?: (intensity: number) => void;
backgroundBlur?: number;
@@ -626,6 +628,7 @@ interface SettingsPanelProps {
onAnnotationBlurIntensityChange?: (id: string, intensity: number) => void;
onAnnotationBlurColorChange?: (id: string, color: string) => void;
onAnnotationDelete?: (id: string) => void;
+ onAnnotationDuplicate?: () => boolean | void;
autoCaptions?: CaptionCue[];
autoCaptionSettings?: AutoCaptionSettings;
whisperExecutablePath?: string | null;
@@ -979,6 +982,7 @@ export function SettingsPanel({
selectedZoomMode,
onZoomModeChange,
onZoomDelete,
+ onZoomDuplicate,
selectedClipId,
selectedClipSpeed,
selectedClipMuted,
@@ -998,6 +1002,7 @@ export function SettingsPanel({
onAudioVolumeChange,
onAudioNormalizeChange,
onAudioDelete,
+ onAudioDuplicate,
shadowIntensity = 0.67,
onShadowChange,
backgroundBlur = 0,
@@ -1074,6 +1079,7 @@ export function SettingsPanel({
onAnnotationBlurIntensityChange,
onAnnotationBlurColorChange,
onAnnotationDelete,
+ onAnnotationDuplicate,
autoCaptions = [],
autoCaptionSettings = DEFAULT_AUTO_CAPTION_SETTINGS,
whisperModelPath,
@@ -3788,44 +3794,95 @@ export function SettingsPanel({
)}
{activeEffectSection === "zoom" && selectedZoomId && (
-
+ <>
+
+
+ >
)}
{activeEffectSection === "audio" && selectedAudioId && (
-
+ <>
+
+
+ >
)}
{selectedAnnotationId && (
-
+ <>
+
+
+ >
)}
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx
index e3f98e1a1..2bb128be7 100644
--- a/src/components/video-editor/VideoEditor.tsx
+++ b/src/components/video-editor/VideoEditor.tsx
@@ -349,6 +349,7 @@ export default function VideoEditor() {
handleShowCursorChange,
currentTime,
isPlaying,
+ timelineDurationMs: Math.round(projection.timelineDuration * 1000),
aspectRatio,
setAspectRatio,
whisperExecutablePath,
diff --git a/src/components/video-editor/hooks/useAnnotationRegionCommands.ts b/src/components/video-editor/hooks/useAnnotationRegionCommands.ts
index e6cd9942c..0c792154e 100644
--- a/src/components/video-editor/hooks/useAnnotationRegionCommands.ts
+++ b/src/components/video-editor/hooks/useAnnotationRegionCommands.ts
@@ -1,5 +1,6 @@
import type { Span } from "dnd-timeline";
import { type Dispatch, type MutableRefObject, type SetStateAction, useCallback } from "react";
+import { placeSpanAfter } from "../timeline/hooks/utils/timelineDuplicateUtils";
import {
type AnnotationRegion,
DEFAULT_ANNOTATION_POSITION,
@@ -86,6 +87,47 @@ export function useAnnotationRegionCommands({
[selectedAnnotationId, setAnnotationRegions, setSelectedAnnotationId],
);
+ const handleAnnotationDuplicate = useCallback(
+ (id: string, totalMs: number): boolean => {
+ let createdId: string | null = null;
+ setAnnotationRegions((current) => {
+ const source = current.find((region) => region.id === id);
+ if (!source) return current;
+
+ const placed = placeSpanAfter(source, totalMs);
+ if (!placed) return current;
+
+ createdId = `annotation-${nextAnnotationIdRef.current++}`;
+ return [
+ ...current,
+ {
+ ...source,
+ id: createdId,
+ startMs: placed.startMs,
+ endMs: placed.endMs,
+ position: { ...source.position },
+ size: { ...source.size },
+ style: { ...source.style },
+ figureData: source.figureData ? { ...source.figureData } : undefined,
+ zIndex: nextAnnotationZIndexRef.current++,
+ },
+ ];
+ });
+
+ if (!createdId) return false;
+ setSelectedAnnotationId(createdId);
+ setSelectedZoomId(null);
+ return true;
+ },
+ [
+ nextAnnotationIdRef,
+ nextAnnotationZIndexRef,
+ setAnnotationRegions,
+ setSelectedAnnotationId,
+ setSelectedZoomId,
+ ],
+ );
+
const handleAnnotationContentChange = useCallback(
(id: string, content: string) => {
setAnnotationRegions((current) =>
@@ -167,6 +209,7 @@ export function useAnnotationRegionCommands({
handleAnnotationAdded,
handleAnnotationSpanChange,
handleAnnotationDelete,
+ handleAnnotationDuplicate,
handleAnnotationContentChange,
handleAnnotationTypeChange,
handleAnnotationStyleChange,
diff --git a/src/components/video-editor/hooks/useAudioRegionCommands.ts b/src/components/video-editor/hooks/useAudioRegionCommands.ts
index c02a8f6d9..778eb0f97 100644
--- a/src/components/video-editor/hooks/useAudioRegionCommands.ts
+++ b/src/components/video-editor/hooks/useAudioRegionCommands.ts
@@ -1,5 +1,6 @@
import type { Span } from "dnd-timeline";
import { type Dispatch, type MutableRefObject, type SetStateAction, useCallback } from "react";
+import { placeSpanAfter } from "../timeline/hooks/utils/timelineDuplicateUtils";
import type { AudioRegion, EditorEffectSection } from "../types";
interface UseAudioRegionCommandsParams {
@@ -117,6 +118,47 @@ export function useAudioRegionCommands({
[selectedAudioId, setAudioRegions, setSelectedAudioId],
);
+ const handleAudioDuplicate = useCallback(
+ (id: string, totalMs: number): boolean => {
+ let createdId: string | null = null;
+ setAudioRegions((current) => {
+ const source = current.find((region) => region.id === id);
+ if (!source) return current;
+
+ const placed = placeSpanAfter(source, totalMs);
+ if (!placed) return current;
+
+ createdId = `audio-${nextAudioIdRef.current++}`;
+ return [
+ ...current,
+ {
+ ...source,
+ id: createdId,
+ startMs: placed.startMs,
+ endMs: placed.endMs,
+ },
+ ];
+ });
+
+ if (!createdId) return false;
+ setSelectedAudioId(createdId);
+ setSelectedZoomId(null);
+ setSelectedAnnotationId(null);
+ setSelectedCaptionId(null);
+ setActiveEffectSection("audio");
+ return true;
+ },
+ [
+ nextAudioIdRef,
+ setActiveEffectSection,
+ setAudioRegions,
+ setSelectedAnnotationId,
+ setSelectedAudioId,
+ setSelectedCaptionId,
+ setSelectedZoomId,
+ ],
+ );
+
const handleAudioNormalizeChange = useCallback(
(normalize: boolean) => {
if (!selectedAudioId) return;
@@ -135,6 +177,7 @@ export function useAudioRegionCommands({
handleAudioSpanChange,
handleAudioVolumeChange,
handleAudioDelete,
+ handleAudioDuplicate,
handleAudioNormalizeChange,
};
}
diff --git a/src/components/video-editor/hooks/useZoomRegionCommands.ts b/src/components/video-editor/hooks/useZoomRegionCommands.ts
index 0fadb5785..b93056064 100644
--- a/src/components/video-editor/hooks/useZoomRegionCommands.ts
+++ b/src/components/video-editor/hooks/useZoomRegionCommands.ts
@@ -1,5 +1,6 @@
import type { Span } from "dnd-timeline";
import { type Dispatch, type MutableRefObject, type SetStateAction, useCallback } from "react";
+import { placeSpanAfter } from "../timeline/hooks/utils/timelineDuplicateUtils";
import {
clampFocusToDepth,
DEFAULT_AUTO_ZOOM_DEPTH,
@@ -170,6 +171,48 @@ export function useZoomRegionCommands({
[selectedZoomId, setSelectedZoomId, setZoomRegions],
);
+ const handleZoomDuplicate = useCallback(
+ (id: string, totalMs: number): boolean => {
+ let createdId: string | null = null;
+ setZoomRegions((current) => {
+ const source = current.find((region) => region.id === id);
+ if (!source) return current;
+
+ const placed = placeSpanAfter(source, totalMs);
+ if (!placed) return current;
+
+ createdId = `zoom-${nextZoomIdRef.current++}`;
+ return [
+ ...current,
+ {
+ ...source,
+ id: createdId,
+ startMs: placed.startMs,
+ endMs: placed.endMs,
+ focus: { ...source.focus },
+ },
+ ];
+ });
+
+ if (!createdId) return false;
+ setSelectedZoomId(createdId);
+ setSelectedAnnotationId(null);
+ setSelectedAudioId(null);
+ setSelectedCaptionId(null);
+ setActiveEffectSection("zoom");
+ return true;
+ },
+ [
+ nextZoomIdRef,
+ setActiveEffectSection,
+ setSelectedAnnotationId,
+ setSelectedAudioId,
+ setSelectedCaptionId,
+ setSelectedZoomId,
+ setZoomRegions,
+ ],
+ );
+
return {
handleSelectZoom,
handleZoomAdded,
@@ -179,5 +222,6 @@ export function useZoomRegionCommands({
handleZoomDepthChange,
handleZoomModeChange,
handleZoomDelete,
+ handleZoomDuplicate,
};
}
diff --git a/src/components/video-editor/layout/EditorTimelinePanel.tsx b/src/components/video-editor/layout/EditorTimelinePanel.tsx
index 78bfca53e..e7b84bd9a 100644
--- a/src/components/video-editor/layout/EditorTimelinePanel.tsx
+++ b/src/components/video-editor/layout/EditorTimelinePanel.tsx
@@ -75,6 +75,12 @@ export function EditorTimelinePanel(props: Props) {
onZoomSuggested={zoomCommands.handleZoomSuggested}
onZoomSpanChange={zoomCommands.handleZoomSpanChange}
onZoomDelete={zoomCommands.handleZoomDelete}
+ onZoomDuplicate={(id) =>
+ zoomCommands.handleZoomDuplicate(
+ id,
+ Math.round(projection.timelineDuration * 1000),
+ )
+ }
selectedZoomId={timeline.selectedZoomId}
onSelectZoom={zoomCommands.handleSelectZoom}
trimRegions={timeline.trimRegions}
@@ -87,6 +93,12 @@ export function EditorTimelinePanel(props: Props) {
onAudioAdded={audioCommands.handleAudioAdded}
onAudioSpanChange={audioCommands.handleAudioSpanChange}
onAudioDelete={audioCommands.handleAudioDelete}
+ onAudioDuplicate={(id) =>
+ audioCommands.handleAudioDuplicate(
+ id,
+ Math.round(projection.timelineDuration * 1000),
+ )
+ }
selectedAudioId={timeline.selectedAudioId}
onSelectAudio={audioCommands.handleSelectAudio}
captionRegions={projection.effectiveCaptionRegions}
@@ -106,6 +118,12 @@ export function EditorTimelinePanel(props: Props) {
onAnnotationAdded={annotationCommands.handleAnnotationAdded}
onAnnotationSpanChange={annotationCommands.handleAnnotationSpanChange}
onAnnotationDelete={annotationCommands.handleAnnotationDelete}
+ onAnnotationDuplicate={(id) =>
+ annotationCommands.handleAnnotationDuplicate(
+ id,
+ Math.round(projection.timelineDuration * 1000),
+ )
+ }
selectedAnnotationId={timeline.selectedAnnotationId}
onSelectAnnotation={handleSelectAnnotation}
showSourceAudioTrack={timeline.clipRegions.some((clip) => clip.showSourceAudio)}
diff --git a/src/components/video-editor/layout/useEditorSettingsPanelProps.ts b/src/components/video-editor/layout/useEditorSettingsPanelProps.ts
index fc7427c26..c4465010c 100644
--- a/src/components/video-editor/layout/useEditorSettingsPanelProps.ts
+++ b/src/components/video-editor/layout/useEditorSettingsPanelProps.ts
@@ -27,6 +27,7 @@ type Input = {
handleShowCursorChange: (show: boolean) => void;
currentTime: number;
isPlaying: boolean;
+ timelineDurationMs: number;
aspectRatio: AspectRatio;
setAspectRatio: Dispatch>;
whisperExecutablePath: string | null;
@@ -56,6 +57,7 @@ export function useEditorSettingsPanelProps(input: Input): ComponentProps
timeline.selectedZoomId && zoomCommands.handleZoomModeChange(mode),
onZoomDelete: zoomCommands.handleZoomDelete,
+ onZoomDuplicate: timeline.selectedZoomId
+ ? () => zoomCommands.handleZoomDuplicate(timeline.selectedZoomId!, timelineDurationMs)
+ : undefined,
selectedClipId: timeline.selectedClipId,
selectedClipSpeed: selectedClip?.speed ?? (timeline.selectedClipId ? 1 : null),
selectedClipMuted: selectedClip?.muted ?? (timeline.selectedClipId ? false : null),
@@ -112,6 +117,13 @@ export function useEditorSettingsPanelProps(input: Input): ComponentProps
+ audioCommands.handleAudioDuplicate(
+ timeline.selectedAudioId!,
+ timelineDurationMs,
+ )
+ : undefined,
shadowIntensity: appearance.shadowIntensity,
onShadowChange: appearance.setShadowIntensity,
backgroundBlur: appearance.backgroundBlur,
@@ -224,5 +236,12 @@ export function useEditorSettingsPanelProps(input: Input): ComponentProps
+ annotationCommands.handleAnnotationDuplicate(
+ timeline.selectedAnnotationId!,
+ timelineDurationMs,
+ )
+ : undefined,
};
}
diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx
index 46a610f29..dc1ff817c 100644
--- a/src/components/video-editor/timeline/TimelineEditor.tsx
+++ b/src/components/video-editor/timeline/TimelineEditor.tsx
@@ -45,6 +45,7 @@ export interface TimelineEditorProps {
onZoomSuggested?: (span: Span, focus: ZoomFocus) => void;
onZoomSpanChange: (id: string, span: Span) => void;
onZoomDelete: (id: string) => void;
+ onZoomDuplicate?: (id: string) => boolean;
selectedZoomId: string | null;
onSelectZoom: (id: string | null) => void;
trimRegions?: TrimRegion[];
@@ -59,6 +60,7 @@ export interface TimelineEditorProps {
onAnnotationAdded?: (span: Span, trackIndex?: number) => void;
onAnnotationSpanChange?: (id: string, span: Span, trackIndex?: number) => void;
onAnnotationDelete?: (id: string) => void;
+ onAnnotationDuplicate?: (id: string) => boolean;
selectedAnnotationId?: string | null;
onSelectAnnotation?: (id: string | null) => void;
speedRegions?: SpeedRegion[];
@@ -67,6 +69,7 @@ export interface TimelineEditorProps {
onAudioAdded?: (span: Span, audioPath: string, trackIndex?: number) => void;
onAudioSpanChange?: (id: string, span: Span, trackIndex?: number) => void;
onAudioDelete?: (id: string) => void;
+ onAudioDuplicate?: (id: string) => boolean;
selectedAudioId?: string | null;
onSelectAudio?: (id: string | null) => void;
captionRegions?: CaptionCue[];
@@ -128,6 +131,7 @@ const TimelineEditor = forwardRef(
onZoomSuggested,
onZoomSpanChange,
onZoomDelete,
+ onZoomDuplicate,
selectedZoomId,
onSelectZoom,
trimRegions = [],
@@ -142,6 +146,7 @@ const TimelineEditor = forwardRef(
onAnnotationAdded,
onAnnotationSpanChange,
onAnnotationDelete,
+ onAnnotationDuplicate,
selectedAnnotationId,
onSelectAnnotation,
speedRegions = [],
@@ -150,6 +155,7 @@ const TimelineEditor = forwardRef(
onAudioAdded,
onAudioSpanChange,
onAudioDelete,
+ onAudioDuplicate,
selectedAudioId,
onSelectAudio,
captionRegions = [],
@@ -367,6 +373,7 @@ const TimelineEditor = forwardRef(
onZoomSuggested,
onZoomSpanChange,
onZoomDelete,
+ onZoomDuplicate,
selectedZoomId,
onSelectZoom,
trimRegions,
@@ -381,6 +388,7 @@ const TimelineEditor = forwardRef(
onAnnotationAdded,
onAnnotationSpanChange,
onAnnotationDelete,
+ onAnnotationDuplicate,
selectedAnnotationId,
onSelectAnnotation,
speedRegions,
@@ -389,6 +397,7 @@ const TimelineEditor = forwardRef(
onAudioAdded,
onAudioSpanChange,
onAudioDelete,
+ onAudioDuplicate,
selectedAudioId,
onSelectAudio,
captionCues: captionRegions,
diff --git a/src/components/video-editor/timeline/core/timelineTypes.ts b/src/components/video-editor/timeline/core/timelineTypes.ts
index 881ec39ca..ca8bc90d4 100644
--- a/src/components/video-editor/timeline/core/timelineTypes.ts
+++ b/src/components/video-editor/timeline/core/timelineTypes.ts
@@ -25,6 +25,7 @@ export interface TimelineShortcutBindings {
splitClip: ShortcutBinding;
addAnnotation: ShortcutBinding;
deleteSelected: ShortcutBinding;
+ duplicateSelected: ShortcutBinding;
}
export interface TimelineRenderItem {
diff --git a/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts b/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts
index 9f7092db0..b80e18da4 100644
--- a/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts
+++ b/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts
@@ -1,6 +1,7 @@
import type { Span } from "dnd-timeline";
import type { ForwardedRef, RefObject } from "react";
import { useCallback, useImperativeHandle } from "react";
+import { toast } from "sonner";
import type {
AnnotationRegion,
AudioRegion,
@@ -21,6 +22,7 @@ import { useTimelineDndBindings } from "./useTimelineDndBindings";
import { useTimelineKeyboardShortcuts } from "./useTimelineKeyboardShortcuts";
import { useTimelineNormalization } from "./useTimelineNormalization";
import { useTimelineSelection } from "./useTimelineSelection";
+import { resolveDuplicateSelectionTarget } from "./utils/timelineSelectionUtils";
interface UseTimelineEditorRuntimeParams {
ref: ForwardedRef;
@@ -37,6 +39,7 @@ interface UseTimelineEditorRuntimeParams {
onZoomSuggested?: (span: Span, focus: ZoomFocus) => void;
onZoomSpanChange: (id: string, span: Span) => void;
onZoomDelete: (id: string) => void;
+ onZoomDuplicate?: (id: string) => boolean;
selectedZoomId: string | null;
onSelectZoom: (id: string | null) => void;
trimRegions: TrimRegion[];
@@ -51,6 +54,7 @@ interface UseTimelineEditorRuntimeParams {
onAnnotationAdded?: (span: Span, trackIndex?: number) => void;
onAnnotationSpanChange?: (id: string, span: Span, trackIndex?: number) => void;
onAnnotationDelete?: (id: string) => void;
+ onAnnotationDuplicate?: (id: string) => boolean;
selectedAnnotationId?: string | null;
onSelectAnnotation?: (id: string | null) => void;
speedRegions: SpeedRegion[];
@@ -59,6 +63,7 @@ interface UseTimelineEditorRuntimeParams {
onAudioAdded?: (span: Span, audioPath: string, trackIndex?: number) => void;
onAudioSpanChange?: (id: string, span: Span, trackIndex?: number) => void;
onAudioDelete?: (id: string) => void;
+ onAudioDuplicate?: (id: string) => boolean;
selectedAudioId?: string | null;
onSelectAudio?: (id: string | null) => void;
captionCues: CaptionCue[];
@@ -87,6 +92,7 @@ export function useTimelineEditorRuntime({
onZoomSuggested,
onZoomSpanChange,
onZoomDelete,
+ onZoomDuplicate,
selectedZoomId,
onSelectZoom,
trimRegions,
@@ -101,6 +107,7 @@ export function useTimelineEditorRuntime({
onAnnotationAdded,
onAnnotationSpanChange,
onAnnotationDelete,
+ onAnnotationDuplicate,
selectedAnnotationId,
onSelectAnnotation,
speedRegions,
@@ -109,6 +116,7 @@ export function useTimelineEditorRuntime({
onAudioAdded,
onAudioSpanChange,
onAudioDelete,
+ onAudioDuplicate,
selectedAudioId,
onSelectAudio,
captionCues,
@@ -260,6 +268,36 @@ export function useTimelineEditorRuntime({
[videoDuration, totalMs, currentTimeMs, defaultRegionDurationMs, onAnnotationAdded],
);
+ const handleDuplicateSelected = useCallback(() => {
+ const target = resolveDuplicateSelectionTarget({
+ selectedZoomId,
+ selectedAnnotationId,
+ selectedAudioId,
+ });
+
+ let ok = false;
+ if (target === "zoom" && selectedZoomId && onZoomDuplicate) {
+ ok = onZoomDuplicate(selectedZoomId);
+ } else if (target === "annotation" && selectedAnnotationId && onAnnotationDuplicate) {
+ ok = onAnnotationDuplicate(selectedAnnotationId);
+ } else if (target === "audio" && selectedAudioId && onAudioDuplicate) {
+ ok = onAudioDuplicate(selectedAudioId);
+ } else {
+ return;
+ }
+
+ if (!ok) {
+ toast.error("Not enough space to duplicate after the selected item");
+ }
+ }, [
+ onAnnotationDuplicate,
+ onAudioDuplicate,
+ onZoomDuplicate,
+ selectedAnnotationId,
+ selectedAudioId,
+ selectedZoomId,
+ ]);
+
useTimelineKeyboardShortcuts({
isMac,
keyShortcuts,
@@ -278,6 +316,7 @@ export function useTimelineEditorRuntime({
handleAddZoom,
handleSplitClip,
handleAddAnnotation: () => handleAddAnnotation(),
+ handleDuplicateSelected,
deleteSelectedKeyframe,
deleteSelectedZoom,
deleteSelectedClip,
diff --git a/src/components/video-editor/timeline/hooks/useTimelineKeyboardShortcuts.ts b/src/components/video-editor/timeline/hooks/useTimelineKeyboardShortcuts.ts
index 94cb39735..2a22c038c 100644
--- a/src/components/video-editor/timeline/hooks/useTimelineKeyboardShortcuts.ts
+++ b/src/components/video-editor/timeline/hooks/useTimelineKeyboardShortcuts.ts
@@ -21,6 +21,7 @@ interface UseTimelineKeyboardShortcutsParams {
handleAddZoom: () => void;
handleSplitClip: () => void;
handleAddAnnotation: () => void;
+ handleDuplicateSelected: () => void;
deleteSelectedKeyframe: () => void;
deleteSelectedZoom: () => void;
deleteSelectedClip: () => void;
@@ -48,6 +49,7 @@ export function useTimelineKeyboardShortcuts({
handleAddZoom,
handleSplitClip,
handleAddAnnotation,
+ handleDuplicateSelected,
deleteSelectedKeyframe,
deleteSelectedZoom,
deleteSelectedClip,
@@ -87,6 +89,11 @@ export function useTimelineKeyboardShortcuts({
if (matchesShortcut(e, keyShortcuts.addAnnotation, isMac)) {
handleAddAnnotation();
}
+ if (matchesShortcut(e, keyShortcuts.duplicateSelected, isMac)) {
+ e.preventDefault();
+ handleDuplicateSelected();
+ return;
+ }
if (e.key === "Tab" && annotationCount > 0) {
if (cycleAnnotationsAtCurrentTime(e.shiftKey)) {
@@ -142,6 +149,7 @@ export function useTimelineKeyboardShortcuts({
deleteSelectedZoom,
handleAddAnnotation,
handleAddZoom,
+ handleDuplicateSelected,
handleSplitClip,
hasAnyZoomBlocks,
isMac,
diff --git a/src/components/video-editor/timeline/hooks/utils/timelineDuplicateUtils.test.ts b/src/components/video-editor/timeline/hooks/utils/timelineDuplicateUtils.test.ts
new file mode 100644
index 000000000..b2923079e
--- /dev/null
+++ b/src/components/video-editor/timeline/hooks/utils/timelineDuplicateUtils.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from "vitest";
+import { placeSpanAfter } from "./timelineDuplicateUtils";
+
+describe("placeSpanAfter", () => {
+ it("places a copy immediately after the source span", () => {
+ expect(placeSpanAfter({ startMs: 1000, endMs: 2000 }, 10_000)).toEqual({
+ startMs: 2000,
+ endMs: 3000,
+ });
+ });
+
+ it("clamps to the timeline end when space is partial", () => {
+ expect(placeSpanAfter({ startMs: 8000, endMs: 9500 }, 10_000)).toEqual({
+ startMs: 9500,
+ endMs: 10_000,
+ });
+ });
+
+ it("returns null when there is no room after the source", () => {
+ expect(placeSpanAfter({ startMs: 9000, endMs: 10_000 }, 10_000)).toBeNull();
+ expect(placeSpanAfter({ startMs: 0, endMs: 0 }, 10_000)).toBeNull();
+ expect(placeSpanAfter({ startMs: 0, endMs: 1000 }, 0)).toBeNull();
+ });
+});
diff --git a/src/components/video-editor/timeline/hooks/utils/timelineDuplicateUtils.ts b/src/components/video-editor/timeline/hooks/utils/timelineDuplicateUtils.ts
new file mode 100644
index 000000000..41d10f204
--- /dev/null
+++ b/src/components/video-editor/timeline/hooks/utils/timelineDuplicateUtils.ts
@@ -0,0 +1,31 @@
+export type TimelineSpan = {
+ startMs: number;
+ endMs: number;
+};
+
+/**
+ * Place a copy of a span immediately after the original.
+ * Returns null when there is no remaining room on the timeline.
+ */
+export function placeSpanAfter(
+ span: TimelineSpan,
+ totalMs: number,
+): { startMs: number; endMs: number } | null {
+ const duration = Math.max(0, Math.round(span.endMs) - Math.round(span.startMs));
+ const timelineEnd = Math.max(0, Math.round(totalMs));
+ if (duration <= 0 || timelineEnd <= 0) {
+ return null;
+ }
+
+ const startMs = Math.round(span.endMs);
+ if (startMs >= timelineEnd) {
+ return null;
+ }
+
+ const endMs = Math.min(startMs + duration, timelineEnd);
+ if (endMs - startMs < 1) {
+ return null;
+ }
+
+ return { startMs, endMs };
+}
diff --git a/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.test.ts b/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.test.ts
index c1e3289a5..89bb9006d 100644
--- a/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.test.ts
+++ b/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.test.ts
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
-import { resolveDeleteSelectionTarget } from "./timelineSelectionUtils";
+import {
+ resolveDeleteSelectionTarget,
+ resolveDuplicateSelectionTarget,
+} from "./timelineSelectionUtils";
describe("timelineSelectionUtils", () => {
it("treats zoom select-all as a zoom deletion target", () => {
@@ -51,4 +54,35 @@ describe("timelineSelectionUtils", () => {
}),
).toBe("none");
});
+
+ it("resolves duplicate targets for zoom, annotation, and audio", () => {
+ expect(
+ resolveDuplicateSelectionTarget({
+ selectedZoomId: "z-1",
+ selectedAnnotationId: "a-1",
+ selectedAudioId: "au-1",
+ }),
+ ).toBe("zoom");
+ expect(
+ resolveDuplicateSelectionTarget({
+ selectedZoomId: null,
+ selectedAnnotationId: "a-1",
+ selectedAudioId: "au-1",
+ }),
+ ).toBe("annotation");
+ expect(
+ resolveDuplicateSelectionTarget({
+ selectedZoomId: null,
+ selectedAnnotationId: null,
+ selectedAudioId: "au-1",
+ }),
+ ).toBe("audio");
+ expect(
+ resolveDuplicateSelectionTarget({
+ selectedZoomId: null,
+ selectedAnnotationId: null,
+ selectedAudioId: null,
+ }),
+ ).toBe("none");
+ });
});
diff --git a/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.ts b/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.ts
index 9e825635b..5502b0b58 100644
--- a/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.ts
+++ b/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.ts
@@ -7,6 +7,8 @@ export type DeleteSelectionTarget =
| "caption"
| "none";
+export type DuplicateSelectionTarget = "zoom" | "annotation" | "audio" | "none";
+
interface ResolveDeleteSelectionTargetParams {
selectAllBlocksActive: boolean;
selectedKeyframeId: string | null;
@@ -35,3 +37,19 @@ export function resolveDeleteSelectionTarget({
if (selectedCaptionId) return "caption";
return "none";
}
+
+/** Resolve which selected timeline item can be duplicated (MVP: zoom, annotation, audio). */
+export function resolveDuplicateSelectionTarget({
+ selectedZoomId,
+ selectedAnnotationId,
+ selectedAudioId,
+}: {
+ selectedZoomId: string | null;
+ selectedAnnotationId?: string | null;
+ selectedAudioId?: string | null;
+}): DuplicateSelectionTarget {
+ if (selectedZoomId) return "zoom";
+ if (selectedAnnotationId) return "annotation";
+ if (selectedAudioId) return "audio";
+ return "none";
+}
diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json
index 31953838b..692f70572 100644
--- a/src/i18n/locales/en/settings.json
+++ b/src/i18n/locales/en/settings.json
@@ -9,6 +9,7 @@
"level": "Zoom Level",
"selectRegion": "Select a zoom region to adjust",
"deleteZoom": "Delete Zoom",
+ "duplicateZoom": "Duplicate Zoom",
"modeAuto": "Auto",
"modeManual": "Manual",
"modeManualDescription": "Set a fixed focus point for this zoom",
@@ -260,6 +261,7 @@
"systemLabel": "Source System",
"micLabel": "Source Mic",
"mixedLabel": "Source",
- "deleteRegion": "Delete Audio"
+ "deleteRegion": "Delete Audio",
+ "duplicateRegion": "Duplicate Audio"
}
}
diff --git a/src/i18n/locales/en/shortcuts.json b/src/i18n/locales/en/shortcuts.json
index dd789d984..b2c944b4f 100644
--- a/src/i18n/locales/en/shortcuts.json
+++ b/src/i18n/locales/en/shortcuts.json
@@ -6,6 +6,7 @@
"addAnnotation": "Add Annotation",
"addKeyframe": "Add Keyframe",
"deleteSelected": "Delete Selected",
+ "duplicateSelected": "Duplicate Selected",
"playPause": "Play / Pause",
"cycleForward": "Cycle Annotations Forward",
"cycleBackward": "Cycle Annotations Backward",
diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts
index d43846930..b27ce5024 100644
--- a/src/lib/shortcuts.ts
+++ b/src/lib/shortcuts.ts
@@ -4,6 +4,7 @@ export const SHORTCUT_ACTIONS = [
"addAnnotation",
"addKeyframe",
"deleteSelected",
+ "duplicateSelected",
"playPause",
] as const;
@@ -78,6 +79,7 @@ export const DEFAULT_SHORTCUTS: ShortcutsConfig = {
addAnnotation: { key: "a" },
addKeyframe: { key: "f" },
deleteSelected: { key: "d", ctrl: true },
+ duplicateSelected: { key: "d", ctrl: true, shift: true },
playPause: { key: " " },
};
@@ -87,6 +89,7 @@ export const SHORTCUT_LABELS: Record = {
addAnnotation: "Add Annotation",
addKeyframe: "Add Keyframe",
deleteSelected: "Delete Selected",
+ duplicateSelected: "Duplicate Selected",
playPause: "Play / Pause",
};