region.id === activeItemId),
+ })
+ ) {
+ return;
+ }
onItemSpanChange(activeItemId, resolvedSpan);
},
[allRegionSpans, hasOverlap, minItemDurationMs, onItemSpanChange, totalMs],
@@ -82,6 +93,20 @@ export default function TimelineWrapper({
resolveTargetRowId,
);
if (!resolved) return;
+ // Plain clicks on an item run a full drag cycle (no sensor activation
+ // constraint); skip zero-distance gestures and drags that resolve back
+ // to the item's current placement so selecting never commits a span.
+ if (
+ isNoOpSpanCommit({
+ deltaX: event.delta.x,
+ deltaY: event.delta.y,
+ resolvedSpan: resolved.span,
+ resolvedRowId: resolved.rowId,
+ currentItem: allRegionSpans.find((region) => region.id === activeItemId),
+ })
+ ) {
+ return;
+ }
onItemSpanChange(activeItemId, resolved.span, resolved.rowId);
},
[
diff --git a/src/components/video-editor/timeline/core/constants.ts b/src/components/video-editor/timeline/core/constants.ts
index 88acba236..7c60b9a5a 100644
--- a/src/components/video-editor/timeline/core/constants.ts
+++ b/src/components/video-editor/timeline/core/constants.ts
@@ -1,4 +1,6 @@
export const ZOOM_ROW_ID = "row-zoom";
+export const CAMERA_ROW_ID = "row-camera";
+export const FILL_FRAME_ROW_ID = "row-fill-frame";
export const CLIP_ROW_ID = "row-clip";
export const ANNOTATION_ROW_ID = "row-annotation";
export const AUDIO_ROW_ID = "row-audio";
diff --git a/src/components/video-editor/timeline/core/timelineTypes.ts b/src/components/video-editor/timeline/core/timelineTypes.ts
index d1f705d7e..582eca884 100644
--- a/src/components/video-editor/timeline/core/timelineTypes.ts
+++ b/src/components/video-editor/timeline/core/timelineTypes.ts
@@ -1,5 +1,5 @@
-import type { ShortcutBinding } from "@/lib/shortcuts";
import type { Span } from "dnd-timeline";
+import type { ShortcutBinding } from "@/lib/shortcuts";
import type { ZoomMode } from "../../types";
export interface TimelineRegionSpan {
@@ -41,7 +41,7 @@ export interface TimelineRenderItem {
speedValue?: number;
showSourceAudio?: boolean;
muted?: boolean;
- variant: "zoom" | "trim" | "clip" | "annotation" | "speed" | "audio";
+ variant: "zoom" | "trim" | "clip" | "annotation" | "speed" | "audio" | "camera" | "fillFrame";
}
export interface AudioPeaksData {
diff --git a/src/components/video-editor/timeline/dnd/engine.test.ts b/src/components/video-editor/timeline/dnd/engine.test.ts
index c7879acbb..6e77d18ef 100644
--- a/src/components/video-editor/timeline/dnd/engine.test.ts
+++ b/src/components/video-editor/timeline/dnd/engine.test.ts
@@ -5,6 +5,7 @@ import {
clampResizedSpanToNeighbours,
clampSpanToBounds,
getSiblingSpans,
+ isNoOpSpanCommit,
resolveDragEnd,
resolveResizeEnd,
} from "./engine";
@@ -241,3 +242,81 @@ describe("timeline dnd engine", () => {
expect(result?.rowId).toBe("row-audio-2");
});
});
+
+describe("isNoOpSpanCommit", () => {
+ const clip = { id: "a", start: 0, end: 1000, rowId: "row-clip" };
+
+ it("skips zero-distance gestures (selection clicks)", () => {
+ // A plain click on an item fires a full drag cycle with delta {0,0};
+ // regression: with the magnet on this used to surface the clip-edit
+ // toast when a clip was merely selected before pressing Delete.
+ expect(
+ isNoOpSpanCommit({
+ deltaX: 0,
+ deltaY: 0,
+ resolvedSpan: { start: 0.4, end: 1000.4 },
+ resolvedRowId: "row-clip",
+ currentItem: clip,
+ }),
+ ).toBe(true);
+ // Clicking a resize edge only reports an x delta.
+ expect(
+ isNoOpSpanCommit({
+ deltaX: 0,
+ resolvedSpan: { start: 0, end: 1000 },
+ currentItem: clip,
+ }),
+ ).toBe(true);
+ });
+
+ it("skips jittery gestures that resolve back to the current placement", () => {
+ expect(
+ isNoOpSpanCommit({
+ deltaX: 1,
+ deltaY: 0,
+ resolvedSpan: { start: 0, end: 1000 },
+ resolvedRowId: "row-clip",
+ currentItem: clip,
+ }),
+ ).toBe(true);
+ });
+
+ it("commits genuine moves, resizes, and row changes", () => {
+ expect(
+ isNoOpSpanCommit({
+ deltaX: 40,
+ deltaY: 0,
+ resolvedSpan: { start: 200, end: 1200 },
+ resolvedRowId: "row-clip",
+ currentItem: clip,
+ }),
+ ).toBe(false);
+ expect(
+ isNoOpSpanCommit({
+ deltaX: -25,
+ resolvedSpan: { start: 0, end: 900 },
+ currentItem: clip,
+ }),
+ ).toBe(false);
+ // Vertical-only drag onto another row still commits.
+ expect(
+ isNoOpSpanCommit({
+ deltaX: 1,
+ deltaY: 30,
+ resolvedSpan: { start: 0, end: 1000 },
+ resolvedRowId: "row-audio-1",
+ currentItem: clip,
+ }),
+ ).toBe(false);
+ });
+
+ it("commits moved gestures when the item is unknown", () => {
+ expect(
+ isNoOpSpanCommit({
+ deltaX: 12,
+ deltaY: 0,
+ resolvedSpan: { start: 100, end: 1100 },
+ }),
+ ).toBe(false);
+ });
+});
diff --git a/src/components/video-editor/timeline/dnd/engine.ts b/src/components/video-editor/timeline/dnd/engine.ts
index 84c9d99c9..93ad28bc7 100644
--- a/src/components/video-editor/timeline/dnd/engine.ts
+++ b/src/components/video-editor/timeline/dnd/engine.ts
@@ -265,6 +265,44 @@ export function clampDraggedSpanToNeighbours(
);
}
+/**
+ * True when a drag/resize gesture must not commit a span change.
+ *
+ * The dnd sensors have no activation constraint, so a plain selection click
+ * on an item runs a full drag (or, near an edge, resize) cycle and lands in
+ * the span-commit path with a zero-pixel delta. Likewise a jittery click can
+ * resolve back to the item's current placement once clamped against its
+ * neighbors. Committing those "changes" is at best a no-op write and at
+ * worst surfaces user-facing feedback meant for real edits (e.g. the
+ * magnet-on "Turn the magnet off to adjust clip edges" toast firing when a
+ * clip is merely selected before being deleted).
+ */
+export function isNoOpSpanCommit(params: {
+ deltaX: number;
+ deltaY?: number;
+ resolvedSpan: Span;
+ resolvedRowId?: string;
+ currentItem?: TimelineRegionSpan;
+}): boolean {
+ const { deltaX, deltaY = 0, resolvedSpan, resolvedRowId, currentItem } = params;
+
+ if (deltaX === 0 && deltaY === 0) {
+ return true;
+ }
+
+ if (!currentItem) {
+ return false;
+ }
+
+ const sameRow = resolvedRowId === undefined || resolvedRowId === currentItem.rowId;
+ const epsilonMs = 0.001;
+ return (
+ sameRow &&
+ Math.abs(resolvedSpan.start - currentItem.start) < epsilonMs &&
+ Math.abs(resolvedSpan.end - currentItem.end) < epsilonMs
+ );
+}
+
export function resolveResizeEnd(
activeItemId: string,
updatedSpan: Span,
diff --git a/src/components/video-editor/timeline/hooks/useTimelineDndBindings.ts b/src/components/video-editor/timeline/hooks/useTimelineDndBindings.ts
index c49fc338c..75a3b8305 100644
--- a/src/components/video-editor/timeline/hooks/useTimelineDndBindings.ts
+++ b/src/components/video-editor/timeline/hooks/useTimelineDndBindings.ts
@@ -8,9 +8,14 @@ import type {
TrimRegion,
ZoomRegion,
} from "../../types";
-import type { TimelineRenderItem } from "../core/timelineTypes";
-import { getAnnotationTrackIndex, getAudioTrackIndex, isAnnotationTrackRowId, isAudioTrackRowId } from "../core/rows";
+import {
+ getAnnotationTrackIndex,
+ getAudioTrackIndex,
+ isAnnotationTrackRowId,
+ isAudioTrackRowId,
+} from "../core/rows";
import { spansOverlap } from "../core/spans";
+import type { TimelineRegion, TimelineRenderItem } from "../core/timelineTypes";
import { buildAllRegionSpans, buildTimelineItems, resolveDropRowId } from "../model/timelineModel";
interface UseTimelineDndBindingsParams {
@@ -20,15 +25,28 @@ interface UseTimelineDndBindingsParams {
annotationRegions: AnnotationRegion[];
speedRegions: SpeedRegion[];
audioRegions: AudioRegion[];
+ cameraRegions: TimelineRegion[];
+ fillFrameRegions: TimelineRegion[];
onZoomSpanChange: (id: string, span: Span) => void;
onTrimSpanChange?: (id: string, span: Span) => void;
onClipSpanChange?: (id: string, span: Span) => void;
onAnnotationSpanChange?: (id: string, span: Span, trackIndex?: number) => void;
onSpeedSpanChange?: (id: string, span: Span) => void;
onAudioSpanChange?: (id: string, span: Span, trackIndex?: number) => void;
+ onCameraSpanChange?: (id: string, span: Span) => void;
+ onFillFrameSpanChange?: (id: string, span: Span) => void;
}
-type TimelineItemKind = "zoom" | "trim" | "clip" | "annotation" | "speed" | "audio" | null;
+type TimelineItemKind =
+ | "zoom"
+ | "trim"
+ | "clip"
+ | "annotation"
+ | "speed"
+ | "audio"
+ | "camera"
+ | "fillFrame"
+ | null;
export function useTimelineDndBindings({
zoomRegions,
@@ -37,12 +55,16 @@ export function useTimelineDndBindings({
annotationRegions,
speedRegions,
audioRegions,
+ cameraRegions,
+ fillFrameRegions,
onZoomSpanChange,
onTrimSpanChange,
onClipSpanChange,
onAnnotationSpanChange,
onSpeedSpanChange,
onAudioSpanChange,
+ onCameraSpanChange,
+ onFillFrameSpanChange,
}: UseTimelineDndBindingsParams) {
const resolveItemKind = useCallback(
(id: string): TimelineItemKind => {
@@ -52,9 +74,20 @@ export function useTimelineDndBindings({
if (annotationRegions.some((r) => r.id === id)) return "annotation";
if (speedRegions.some((r) => r.id === id)) return "speed";
if (audioRegions.some((r) => r.id === id)) return "audio";
+ if (cameraRegions.some((r) => r.id === id)) return "camera";
+ if (fillFrameRegions.some((r) => r.id === id)) return "fillFrame";
return null;
},
- [zoomRegions, trimRegions, clipRegions, annotationRegions, speedRegions, audioRegions],
+ [
+ zoomRegions,
+ trimRegions,
+ clipRegions,
+ annotationRegions,
+ speedRegions,
+ audioRegions,
+ cameraRegions,
+ fillFrameRegions,
+ ],
);
const resolveTrackIndex = useCallback(
@@ -79,7 +112,14 @@ export function useTimelineDndBindings({
if (itemKind === "annotation") return false;
const checkOverlap = (
- regions: (ZoomRegion | TrimRegion | ClipRegion | SpeedRegion | AudioRegion)[],
+ regions: (
+ | ZoomRegion
+ | TrimRegion
+ | ClipRegion
+ | SpeedRegion
+ | AudioRegion
+ | TimelineRegion
+ )[],
) =>
regions.some((region) => {
if (region.id === excludeId) return false;
@@ -90,6 +130,8 @@ export function useTimelineDndBindings({
if (itemKind === "trim") return checkOverlap(trimRegions);
if (itemKind === "clip") return checkOverlap(clipRegions);
if (itemKind === "speed") return checkOverlap(speedRegions);
+ if (itemKind === "camera") return checkOverlap(cameraRegions);
+ if (itemKind === "fillFrame") return checkOverlap(fillFrameRegions);
if (itemKind === "audio") {
const activeTrackIndex = resolveTrackIndex("audio", excludeId, rowId);
@@ -108,6 +150,8 @@ export function useTimelineDndBindings({
clipRegions,
audioRegions,
speedRegions,
+ cameraRegions,
+ fillFrameRegions,
],
);
@@ -118,8 +162,17 @@ export function useTimelineDndBindings({
clipRegions,
annotationRegions,
audioRegions,
+ cameraRegions,
+ fillFrameRegions,
}),
- [zoomRegions, clipRegions, annotationRegions, audioRegions],
+ [
+ zoomRegions,
+ clipRegions,
+ annotationRegions,
+ audioRegions,
+ cameraRegions,
+ fillFrameRegions,
+ ],
);
const allRegionSpans = useMemo(
@@ -128,8 +181,10 @@ export function useTimelineDndBindings({
zoomRegions,
clipRegions,
audioRegions,
+ cameraRegions,
+ fillFrameRegions,
}),
- [zoomRegions, clipRegions, audioRegions],
+ [zoomRegions, clipRegions, audioRegions, cameraRegions, fillFrameRegions],
);
const getResolvedDropRowId = useCallback(
@@ -154,6 +209,10 @@ export function useTimelineDndBindings({
} else if (itemKind === "audio") {
const nextTrackIndex = resolveTrackIndex("audio", id, rowId);
onAudioSpanChange?.(id, span, nextTrackIndex);
+ } else if (itemKind === "camera") {
+ onCameraSpanChange?.(id, span);
+ } else if (itemKind === "fillFrame") {
+ onFillFrameSpanChange?.(id, span);
}
},
[
@@ -165,6 +224,8 @@ export function useTimelineDndBindings({
onAnnotationSpanChange,
onSpeedSpanChange,
onAudioSpanChange,
+ onCameraSpanChange,
+ onFillFrameSpanChange,
],
);
diff --git a/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts b/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts
index 7cf3c1627..820a51bd9 100644
--- a/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts
+++ b/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts
@@ -11,7 +11,7 @@ import type {
ZoomFocus,
ZoomRegion,
} from "../../types";
-import type { TimelineShortcutBindings } from "../core/timelineTypes";
+import type { TimelineRegion, TimelineShortcutBindings } from "../core/timelineTypes";
import type { TimelineEditorHandle } from "../TimelineEditor";
import { useTimelineAudioActions } from "./actions/useTimelineAudioActions";
import { useTimelineZoomActions } from "./actions/useTimelineZoomActions";
@@ -59,6 +59,16 @@ interface UseTimelineEditorRuntimeParams {
onAudioDelete?: (id: string) => void;
selectedAudioId?: string | null;
onSelectAudio?: (id: string | null) => void;
+ cameraRegions: TimelineRegion[];
+ onCameraSpanChange?: (id: string, span: Span) => void;
+ onCameraDelete?: (id: string) => void;
+ selectedCameraId?: string | null;
+ onSelectCamera?: (id: string | null) => void;
+ fillFrameRegions: TimelineRegion[];
+ onFillFrameSpanChange?: (id: string, span: Span) => void;
+ onFillFrameDelete?: (id: string) => void;
+ selectedFillFrameId?: string | null;
+ onSelectFillFrame?: (id: string | null) => void;
isMac: boolean;
keyShortcuts: TimelineShortcutBindings;
isTimelineFocusedRef: RefObject
;
@@ -103,6 +113,16 @@ export function useTimelineEditorRuntime({
onAudioDelete,
selectedAudioId,
onSelectAudio,
+ cameraRegions,
+ onCameraSpanChange,
+ onCameraDelete,
+ selectedCameraId,
+ onSelectCamera,
+ fillFrameRegions,
+ onFillFrameSpanChange,
+ onFillFrameDelete,
+ selectedFillFrameId,
+ onSelectFillFrame,
isMac,
keyShortcuts,
isTimelineFocusedRef,
@@ -122,11 +142,19 @@ export function useTimelineEditorRuntime({
deleteSelectedClip,
deleteSelectedAnnotation,
deleteSelectedAudio,
+ deleteSelectedCamera,
+ deleteSelectedFillFrame,
+ multiSelectedItems,
+ multiSelectedIds,
+ applyMarqueeSelection,
+ deleteMultiSelectedItems,
clearSelectedBlocks,
handleSelectZoom,
handleSelectClip,
handleSelectAnnotation,
handleSelectAudio,
+ handleSelectCamera,
+ handleSelectFillFrame,
cycleAnnotationsAtCurrentTime,
} = useTimelineSelection({
totalMs,
@@ -139,14 +167,20 @@ export function useTimelineEditorRuntime({
selectedClipId,
selectedAnnotationId,
selectedAudioId,
+ selectedCameraId,
+ selectedFillFrameId,
onZoomDelete,
onClipDelete,
onAnnotationDelete,
onAudioDelete,
+ onCameraDelete,
+ onFillFrameDelete,
onSelectZoom,
onSelectClip,
onSelectAnnotation,
onSelectAudio,
+ onSelectCamera,
+ onSelectFillFrame,
});
useTimelineNormalization({
@@ -175,12 +209,16 @@ export function useTimelineEditorRuntime({
annotationRegions,
speedRegions,
audioRegions,
+ cameraRegions,
+ fillFrameRegions,
onZoomSpanChange,
onTrimSpanChange,
onClipSpanChange,
onAnnotationSpanChange,
onSpeedSpanChange,
onAudioSpanChange,
+ onCameraSpanChange,
+ onFillFrameSpanChange,
});
const {
@@ -244,7 +282,10 @@ export function useTimelineEditorRuntime({
selectedClipId,
selectedAnnotationId,
selectedAudioId,
+ selectedCameraId,
+ selectedFillFrameId,
selectAllBlocksActive,
+ multiSelectedCount: multiSelectedItems.length,
addKeyframe,
handleAddZoom,
handleSplitClip,
@@ -254,6 +295,9 @@ export function useTimelineEditorRuntime({
deleteSelectedClip,
deleteSelectedAnnotation,
deleteSelectedAudio,
+ deleteSelectedCamera,
+ deleteSelectedFillFrame,
+ deleteMultiSelectedItems,
cycleAnnotationsAtCurrentTime,
});
@@ -283,12 +327,16 @@ export function useTimelineEditorRuntime({
setSelectedKeyframeId,
selectAllBlocksActive,
setSelectAllBlocksActive,
+ multiSelectedIds,
+ applyMarqueeSelection,
handleKeyframeMove,
clearSelectedBlocks,
handleSelectZoom,
handleSelectClip,
handleSelectAnnotation,
handleSelectAudio,
+ handleSelectCamera,
+ handleSelectFillFrame,
hasOverlap,
timelineItems,
allRegionSpans,
diff --git a/src/components/video-editor/timeline/hooks/useTimelineKeyboardShortcuts.ts b/src/components/video-editor/timeline/hooks/useTimelineKeyboardShortcuts.ts
index ba1f5673b..46628b051 100644
--- a/src/components/video-editor/timeline/hooks/useTimelineKeyboardShortcuts.ts
+++ b/src/components/video-editor/timeline/hooks/useTimelineKeyboardShortcuts.ts
@@ -15,7 +15,10 @@ interface UseTimelineKeyboardShortcutsParams {
selectedClipId?: string | null;
selectedAnnotationId?: string | null;
selectedAudioId?: string | null;
+ selectedCameraId?: string | null;
+ selectedFillFrameId?: string | null;
selectAllBlocksActive: boolean;
+ multiSelectedCount: number;
addKeyframe: () => void;
handleAddZoom: () => void;
handleSplitClip: () => void;
@@ -25,6 +28,9 @@ interface UseTimelineKeyboardShortcutsParams {
deleteSelectedClip: () => void;
deleteSelectedAnnotation: () => void;
deleteSelectedAudio: () => void;
+ deleteSelectedCamera: () => void;
+ deleteSelectedFillFrame: () => void;
+ deleteMultiSelectedItems: () => void;
cycleAnnotationsAtCurrentTime: (backward?: boolean) => boolean;
}
@@ -40,7 +46,10 @@ export function useTimelineKeyboardShortcuts({
selectedClipId,
selectedAnnotationId,
selectedAudioId,
+ selectedCameraId,
+ selectedFillFrameId,
selectAllBlocksActive,
+ multiSelectedCount,
addKeyframe,
handleAddZoom,
handleSplitClip,
@@ -50,6 +59,9 @@ export function useTimelineKeyboardShortcuts({
deleteSelectedClip,
deleteSelectedAnnotation,
deleteSelectedAudio,
+ deleteSelectedCamera,
+ deleteSelectedFillFrame,
+ deleteMultiSelectedItems,
cycleAnnotationsAtCurrentTime,
}: UseTimelineKeyboardShortcutsParams) {
useEffect(() => {
@@ -97,16 +109,21 @@ export function useTimelineKeyboardShortcuts({
) {
const target = resolveDeleteSelectionTarget({
selectAllBlocksActive,
+ multiSelectedCount,
selectedKeyframeId,
selectedZoomId,
selectedClipId,
selectedAnnotationId,
selectedAudioId,
+ selectedCameraId,
+ selectedFillFrameId,
});
if (target !== "none") {
e.preventDefault();
}
- if (target === "keyframe") {
+ if (target === "multi") {
+ deleteMultiSelectedItems();
+ } else if (target === "keyframe") {
deleteSelectedKeyframe();
} else if (target === "zoom") {
deleteSelectedZoom();
@@ -116,6 +133,10 @@ export function useTimelineKeyboardShortcuts({
deleteSelectedAnnotation();
} else if (target === "audio") {
deleteSelectedAudio();
+ } else if (target === "camera") {
+ deleteSelectedCamera();
+ } else if (target === "fillFrame") {
+ deleteSelectedFillFrame();
}
}
};
@@ -129,9 +150,12 @@ export function useTimelineKeyboardShortcuts({
cycleAnnotationsAtCurrentTime,
deleteSelectedAnnotation,
deleteSelectedAudio,
+ deleteSelectedCamera,
deleteSelectedClip,
+ deleteSelectedFillFrame,
deleteSelectedKeyframe,
deleteSelectedZoom,
+ deleteMultiSelectedItems,
handleAddAnnotation,
handleAddZoom,
handleSplitClip,
@@ -139,10 +163,13 @@ export function useTimelineKeyboardShortcuts({
isMac,
isTimelineFocusedRef,
keyShortcuts,
+ multiSelectedCount,
selectAllBlocksActive,
selectedAnnotationId,
selectedAudioId,
+ selectedCameraId,
selectedClipId,
+ selectedFillFrameId,
selectedKeyframeId,
selectedZoomId,
]);
diff --git a/src/components/video-editor/timeline/hooks/useTimelineSelection.ts b/src/components/video-editor/timeline/hooks/useTimelineSelection.ts
index c7452a693..746f82e4e 100644
--- a/src/components/video-editor/timeline/hooks/useTimelineSelection.ts
+++ b/src/components/video-editor/timeline/hooks/useTimelineSelection.ts
@@ -1,6 +1,7 @@
import { useCallback, useMemo, useState } from "react";
import { v4 as uuidv4 } from "uuid";
import type { TimelineRegion } from "../core/timelineTypes";
+import type { MarqueeSelectedItem } from "./utils/timelineMarqueeUtils";
interface UseTimelineSelectionParams {
totalMs: number;
@@ -13,14 +14,20 @@ interface UseTimelineSelectionParams {
selectedClipId?: string | null;
selectedAnnotationId?: string | null;
selectedAudioId?: string | null;
+ selectedCameraId?: string | null;
+ selectedFillFrameId?: string | null;
onZoomDelete: (id: string) => void;
onClipDelete?: (id: string) => void;
onAnnotationDelete?: (id: string) => void;
onAudioDelete?: (id: string) => void;
+ onCameraDelete?: (id: string) => void;
+ onFillFrameDelete?: (id: string) => void;
onSelectZoom: (id: string | null) => void;
onSelectClip?: (id: string | null) => void;
onSelectAnnotation?: (id: string | null) => void;
onSelectAudio?: (id: string | null) => void;
+ onSelectCamera?: (id: string | null) => void;
+ onSelectFillFrame?: (id: string | null) => void;
}
export function useTimelineSelection({
@@ -32,19 +39,30 @@ export function useTimelineSelection({
selectedClipId,
selectedAnnotationId,
selectedAudioId,
+ selectedCameraId,
+ selectedFillFrameId,
onZoomDelete,
onClipDelete,
onAnnotationDelete,
onAudioDelete,
+ onCameraDelete,
+ onFillFrameDelete,
onSelectZoom,
onSelectClip,
onSelectAnnotation,
onSelectAudio,
+ onSelectCamera,
+ onSelectFillFrame,
}: UseTimelineSelectionParams) {
const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]);
const [selectedKeyframeId, setSelectedKeyframeId] = useState(null);
const [selectAllBlocksActive, setSelectAllBlocksActive] = useState(false);
+ const [multiSelectedItems, setMultiSelectedItems] = useState([]);
const hasAnyZoomBlocks = useMemo(() => zoomRegions.length > 0, [zoomRegions.length]);
+ const multiSelectedIds = useMemo(
+ () => new Set(multiSelectedItems.map((item) => item.id)),
+ [multiSelectedItems],
+ );
const addKeyframe = useCallback(() => {
if (totalMs === 0) return;
@@ -83,6 +101,8 @@ export function useTimelineSelection({
onSelectClip?.(null);
onSelectAnnotation?.(null);
onSelectAudio?.(null);
+ onSelectCamera?.(null);
+ onSelectFillFrame?.(null);
setSelectAllBlocksActive(false);
}, [
selectAllBlocksActive,
@@ -93,6 +113,8 @@ export function useTimelineSelection({
onSelectClip,
onSelectAnnotation,
onSelectAudio,
+ onSelectCamera,
+ onSelectFillFrame,
]);
const deleteSelectedClip = useCallback(() => {
@@ -113,26 +135,101 @@ export function useTimelineSelection({
onSelectAudio(null);
}, [selectedAudioId, onAudioDelete, onSelectAudio]);
+ const deleteSelectedCamera = useCallback(() => {
+ if (!selectedCameraId || !onCameraDelete || !onSelectCamera) return;
+ onCameraDelete(selectedCameraId);
+ onSelectCamera(null);
+ }, [selectedCameraId, onCameraDelete, onSelectCamera]);
+
+ const deleteSelectedFillFrame = useCallback(() => {
+ if (!selectedFillFrameId || !onFillFrameDelete || !onSelectFillFrame) return;
+ onFillFrameDelete(selectedFillFrameId);
+ onSelectFillFrame(null);
+ }, [selectedFillFrameId, onFillFrameDelete, onSelectFillFrame]);
+
+ // Marquee release: replace every selection (single + select-all) with the
+ // box-selected items.
+ const applyMarqueeSelection = useCallback(
+ (items: MarqueeSelectedItem[]) => {
+ onSelectZoom(null);
+ onSelectClip?.(null);
+ onSelectAnnotation?.(null);
+ onSelectAudio?.(null);
+ onSelectCamera?.(null);
+ onSelectFillFrame?.(null);
+ setSelectedKeyframeId(null);
+ setSelectAllBlocksActive(false);
+ setMultiSelectedItems(items);
+ },
+ [
+ onSelectZoom,
+ onSelectClip,
+ onSelectAnnotation,
+ onSelectAudio,
+ onSelectCamera,
+ onSelectFillFrame,
+ ],
+ );
+
+ // Routes every multi-selected item to its kind's existing delete handler.
+ // "speed" chips have no delete handler today and are skipped.
+ const deleteMultiSelectedItems = useCallback(() => {
+ if (multiSelectedItems.length === 0) return;
+ for (const item of multiSelectedItems) {
+ if (item.kind === "zoom") {
+ onZoomDelete(item.id);
+ } else if (item.kind === "camera") {
+ onCameraDelete?.(item.id);
+ } else if (item.kind === "fillFrame") {
+ onFillFrameDelete?.(item.id);
+ } else if (item.kind === "annotation") {
+ onAnnotationDelete?.(item.id);
+ }
+ }
+ setMultiSelectedItems([]);
+ }, [multiSelectedItems, onZoomDelete, onCameraDelete, onFillFrameDelete, onAnnotationDelete]);
+
const clearSelectedBlocks = useCallback(() => {
onSelectZoom(null);
onSelectClip?.(null);
onSelectAnnotation?.(null);
onSelectAudio?.(null);
+ onSelectCamera?.(null);
+ onSelectFillFrame?.(null);
setSelectAllBlocksActive(false);
- }, [onSelectZoom, onSelectClip, onSelectAnnotation, onSelectAudio]);
+ setMultiSelectedItems([]);
+ }, [
+ onSelectZoom,
+ onSelectClip,
+ onSelectAnnotation,
+ onSelectAudio,
+ onSelectCamera,
+ onSelectFillFrame,
+ ]);
const activateSelectAllZooms = useCallback(() => {
onSelectZoom(null);
onSelectClip?.(null);
onSelectAnnotation?.(null);
onSelectAudio?.(null);
+ onSelectCamera?.(null);
+ onSelectFillFrame?.(null);
setSelectedKeyframeId(null);
+ setMultiSelectedItems([]);
setSelectAllBlocksActive(true);
- }, [onSelectZoom, onSelectClip, onSelectAnnotation, onSelectAudio]);
+ }, [
+ onSelectZoom,
+ onSelectClip,
+ onSelectAnnotation,
+ onSelectAudio,
+ onSelectCamera,
+ onSelectFillFrame,
+ ]);
const handleSelectZoom = useCallback(
(id: string | null) => {
setSelectAllBlocksActive(false);
+ setMultiSelectedItems([]);
onSelectZoom(id);
},
[onSelectZoom],
@@ -141,6 +238,7 @@ export function useTimelineSelection({
const handleSelectClip = useCallback(
(id: string | null) => {
setSelectAllBlocksActive(false);
+ setMultiSelectedItems([]);
onSelectClip?.(id);
},
[onSelectClip],
@@ -149,6 +247,7 @@ export function useTimelineSelection({
const handleSelectAnnotation = useCallback(
(id: string | null) => {
setSelectAllBlocksActive(false);
+ setMultiSelectedItems([]);
onSelectAnnotation?.(id);
},
[onSelectAnnotation],
@@ -157,11 +256,30 @@ export function useTimelineSelection({
const handleSelectAudio = useCallback(
(id: string | null) => {
setSelectAllBlocksActive(false);
+ setMultiSelectedItems([]);
onSelectAudio?.(id);
},
[onSelectAudio],
);
+ const handleSelectCamera = useCallback(
+ (id: string | null) => {
+ setSelectAllBlocksActive(false);
+ setMultiSelectedItems([]);
+ onSelectCamera?.(id);
+ },
+ [onSelectCamera],
+ );
+
+ const handleSelectFillFrame = useCallback(
+ (id: string | null) => {
+ setSelectAllBlocksActive(false);
+ setMultiSelectedItems([]);
+ onSelectFillFrame?.(id);
+ },
+ [onSelectFillFrame],
+ );
+
const cycleAnnotationsAtCurrentTime = useCallback(
(backward = false) => {
const overlapping = annotationRegions
@@ -201,11 +319,19 @@ export function useTimelineSelection({
deleteSelectedClip,
deleteSelectedAnnotation,
deleteSelectedAudio,
+ deleteSelectedCamera,
+ deleteSelectedFillFrame,
+ multiSelectedItems,
+ multiSelectedIds,
+ applyMarqueeSelection,
+ deleteMultiSelectedItems,
clearSelectedBlocks,
handleSelectZoom,
handleSelectClip,
handleSelectAnnotation,
handleSelectAudio,
+ handleSelectCamera,
+ handleSelectFillFrame,
cycleAnnotationsAtCurrentTime,
};
}
diff --git a/src/components/video-editor/timeline/hooks/utils/timelineMarqueeUtils.test.ts b/src/components/video-editor/timeline/hooks/utils/timelineMarqueeUtils.test.ts
new file mode 100644
index 000000000..22e3a2c8e
--- /dev/null
+++ b/src/components/video-editor/timeline/hooks/utils/timelineMarqueeUtils.test.ts
@@ -0,0 +1,131 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildMarqueeRect,
+ exceedsMarqueeThreshold,
+ isMarqueeSelectableKind,
+ rectsIntersect,
+ resolveMarqueeSelection,
+} from "./timelineMarqueeUtils";
+
+describe("timelineMarqueeUtils", () => {
+ describe("exceedsMarqueeThreshold", () => {
+ it("treats movement within the threshold as a plain click", () => {
+ expect(exceedsMarqueeThreshold({ x: 10, y: 10 }, { x: 12, y: 12 })).toBe(false);
+ expect(exceedsMarqueeThreshold({ x: 10, y: 10 }, { x: 14, y: 10 })).toBe(false);
+ });
+
+ it("treats movement past the threshold as a marquee drag", () => {
+ expect(exceedsMarqueeThreshold({ x: 10, y: 10 }, { x: 15, y: 10 })).toBe(true);
+ expect(exceedsMarqueeThreshold({ x: 10, y: 10 }, { x: 14, y: 14 })).toBe(true);
+ });
+ });
+
+ describe("buildMarqueeRect", () => {
+ it("normalizes a drag in any direction to a positive rect", () => {
+ expect(buildMarqueeRect({ x: 30, y: 40 }, { x: 10, y: 20 })).toEqual({
+ left: 10,
+ top: 20,
+ width: 20,
+ height: 20,
+ });
+ expect(buildMarqueeRect({ x: 10, y: 20 }, { x: 30, y: 40 })).toEqual({
+ left: 10,
+ top: 20,
+ width: 20,
+ height: 20,
+ });
+ });
+ });
+
+ describe("rectsIntersect", () => {
+ const marquee = { left: 10, top: 10, width: 20, height: 20 };
+
+ it("detects overlapping rects", () => {
+ expect(rectsIntersect({ left: 25, top: 25, width: 20, height: 20 }, marquee)).toBe(
+ true,
+ );
+ expect(rectsIntersect({ left: 0, top: 0, width: 15, height: 15 }, marquee)).toBe(true);
+ });
+
+ it("rejects rects that only touch edges or are disjoint", () => {
+ expect(rectsIntersect({ left: 30, top: 10, width: 10, height: 10 }, marquee)).toBe(
+ false,
+ );
+ expect(rectsIntersect({ left: 100, top: 100, width: 5, height: 5 }, marquee)).toBe(
+ false,
+ );
+ });
+ });
+
+ describe("isMarqueeSelectableKind", () => {
+ it("allows zoom, camera, fillFrame, annotation and speed chips", () => {
+ expect(isMarqueeSelectableKind("zoom")).toBe(true);
+ expect(isMarqueeSelectableKind("camera")).toBe(true);
+ expect(isMarqueeSelectableKind("fillFrame")).toBe(true);
+ expect(isMarqueeSelectableKind("annotation")).toBe(true);
+ expect(isMarqueeSelectableKind("speed")).toBe(true);
+ });
+
+ it("excludes the main clip track and audio waveforms", () => {
+ expect(isMarqueeSelectableKind("clip")).toBe(false);
+ expect(isMarqueeSelectableKind("audio")).toBe(false);
+ expect(isMarqueeSelectableKind("trim")).toBe(false);
+ expect(isMarqueeSelectableKind("")).toBe(false);
+ });
+ });
+
+ describe("resolveMarqueeSelection", () => {
+ const marquee = { left: 0, top: 0, width: 100, height: 100 };
+
+ it("selects every selectable chip intersecting the marquee", () => {
+ const candidates = [
+ { id: "z-1", kind: "zoom", rect: { left: 10, top: 10, width: 20, height: 10 } },
+ { id: "cam-1", kind: "camera", rect: { left: 50, top: 30, width: 20, height: 10 } },
+ {
+ id: "ff-1",
+ kind: "fillFrame",
+ rect: { left: 90, top: 90, width: 20, height: 10 },
+ },
+ {
+ id: "an-1",
+ kind: "annotation",
+ rect: { left: 200, top: 10, width: 20, height: 10 },
+ },
+ ];
+ expect(resolveMarqueeSelection(candidates, marquee)).toEqual([
+ { kind: "zoom", id: "z-1" },
+ { kind: "camera", id: "cam-1" },
+ { kind: "fillFrame", id: "ff-1" },
+ ]);
+ });
+
+ it("filters out clip and audio chips even when they intersect", () => {
+ const candidates = [
+ { id: "c-1", kind: "clip", rect: { left: 10, top: 10, width: 20, height: 10 } },
+ { id: "au-1", kind: "audio", rect: { left: 10, top: 30, width: 20, height: 10 } },
+ { id: "z-1", kind: "zoom", rect: { left: 10, top: 50, width: 20, height: 10 } },
+ ];
+ expect(resolveMarqueeSelection(candidates, marquee)).toEqual([
+ { kind: "zoom", id: "z-1" },
+ ]);
+ });
+
+ it("ignores empty and duplicate ids", () => {
+ const candidates = [
+ { id: "", kind: "zoom", rect: { left: 10, top: 10, width: 20, height: 10 } },
+ { id: "z-1", kind: "zoom", rect: { left: 10, top: 30, width: 20, height: 10 } },
+ { id: "z-1", kind: "zoom", rect: { left: 10, top: 50, width: 20, height: 10 } },
+ ];
+ expect(resolveMarqueeSelection(candidates, marquee)).toEqual([
+ { kind: "zoom", id: "z-1" },
+ ]);
+ });
+
+ it("returns an empty selection when nothing intersects", () => {
+ const candidates = [
+ { id: "z-1", kind: "zoom", rect: { left: 200, top: 200, width: 20, height: 10 } },
+ ];
+ expect(resolveMarqueeSelection(candidates, marquee)).toEqual([]);
+ });
+ });
+});
diff --git a/src/components/video-editor/timeline/hooks/utils/timelineMarqueeUtils.ts b/src/components/video-editor/timeline/hooks/utils/timelineMarqueeUtils.ts
new file mode 100644
index 000000000..d154443c9
--- /dev/null
+++ b/src/components/video-editor/timeline/hooks/utils/timelineMarqueeUtils.ts
@@ -0,0 +1,81 @@
+export const MARQUEE_DRAG_THRESHOLD_PX = 4;
+
+const MARQUEE_SELECTABLE_KINDS = ["zoom", "camera", "fillFrame", "annotation", "speed"] as const;
+
+export type MarqueeSelectableKind = (typeof MARQUEE_SELECTABLE_KINDS)[number];
+
+export interface MarqueeSelectedItem {
+ kind: MarqueeSelectableKind;
+ id: string;
+}
+
+export interface MarqueeRect {
+ left: number;
+ top: number;
+ width: number;
+ height: number;
+}
+
+export interface MarqueePoint {
+ x: number;
+ y: number;
+}
+
+export interface MarqueeCandidateItem {
+ id: string;
+ kind: string;
+ rect: MarqueeRect;
+}
+
+export function isMarqueeSelectableKind(kind: string): kind is MarqueeSelectableKind {
+ return (MARQUEE_SELECTABLE_KINDS as readonly string[]).includes(kind);
+}
+
+// Plain clicks must behave exactly as before; only treat the gesture as a
+// marquee once the pointer travels past the threshold.
+export function exceedsMarqueeThreshold(
+ anchor: MarqueePoint,
+ current: MarqueePoint,
+ thresholdPx = MARQUEE_DRAG_THRESHOLD_PX,
+): boolean {
+ return Math.hypot(current.x - anchor.x, current.y - anchor.y) > thresholdPx;
+}
+
+export function buildMarqueeRect(anchor: MarqueePoint, current: MarqueePoint): MarqueeRect {
+ const left = Math.min(anchor.x, current.x);
+ const top = Math.min(anchor.y, current.y);
+ return {
+ left,
+ top,
+ width: Math.abs(current.x - anchor.x),
+ height: Math.abs(current.y - anchor.y),
+ };
+}
+
+export function rectsIntersect(a: MarqueeRect, b: MarqueeRect): boolean {
+ return (
+ a.left < b.left + b.width &&
+ a.left + a.width > b.left &&
+ a.top < b.top + b.height &&
+ a.top + a.height > b.top
+ );
+}
+
+// Resolves the marquee release into the multi-selection set: every selectable
+// chip whose rendered rect intersects the marquee. Non-selectable kinds (the
+// main clip track and audio waveforms) are filtered out here.
+export function resolveMarqueeSelection(
+ candidates: readonly MarqueeCandidateItem[],
+ marqueeRect: MarqueeRect,
+): MarqueeSelectedItem[] {
+ const selected: MarqueeSelectedItem[] = [];
+ const seenIds = new Set();
+ for (const candidate of candidates) {
+ if (!candidate.id || seenIds.has(candidate.id)) continue;
+ if (!isMarqueeSelectableKind(candidate.kind)) continue;
+ if (!rectsIntersect(candidate.rect, marqueeRect)) continue;
+ seenIds.add(candidate.id);
+ selected.push({ kind: candidate.kind, id: candidate.id });
+ }
+ return selected;
+}
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..ba039a5b0 100644
--- a/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.test.ts
+++ b/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.test.ts
@@ -42,6 +42,45 @@ describe("timelineSelectionUtils", () => {
).toBe("clip");
});
+ it("targets camera selection after the other block types", () => {
+ expect(
+ resolveDeleteSelectionTarget({
+ selectAllBlocksActive: false,
+ selectedKeyframeId: null,
+ selectedZoomId: null,
+ selectedCameraId: "cam-1",
+ }),
+ ).toBe("camera");
+ expect(
+ resolveDeleteSelectionTarget({
+ selectAllBlocksActive: false,
+ selectedKeyframeId: null,
+ selectedZoomId: null,
+ selectedAudioId: "au-1",
+ selectedCameraId: "cam-1",
+ }),
+ ).toBe("audio");
+ });
+
+ it("targets the marquee multi-selection over single selections", () => {
+ expect(
+ resolveDeleteSelectionTarget({
+ selectAllBlocksActive: false,
+ multiSelectedCount: 3,
+ selectedKeyframeId: "kf-1",
+ selectedZoomId: "z-1",
+ }),
+ ).toBe("multi");
+ expect(
+ resolveDeleteSelectionTarget({
+ selectAllBlocksActive: false,
+ multiSelectedCount: 0,
+ selectedKeyframeId: null,
+ selectedZoomId: "z-1",
+ }),
+ ).toBe("zoom");
+ });
+
it("returns none when nothing is selected", () => {
expect(
resolveDeleteSelectionTarget({
diff --git a/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.ts b/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.ts
index af4cb77db..98e074409 100644
--- a/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.ts
+++ b/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.ts
@@ -1,27 +1,45 @@
-export type DeleteSelectionTarget = "keyframe" | "zoom" | "clip" | "annotation" | "audio" | "none";
+export type DeleteSelectionTarget =
+ | "multi"
+ | "keyframe"
+ | "zoom"
+ | "clip"
+ | "annotation"
+ | "audio"
+ | "camera"
+ | "fillFrame"
+ | "none";
interface ResolveDeleteSelectionTargetParams {
selectAllBlocksActive: boolean;
+ multiSelectedCount?: number;
selectedKeyframeId: string | null;
selectedZoomId: string | null;
selectedClipId?: string | null;
selectedAnnotationId?: string | null;
selectedAudioId?: string | null;
+ selectedCameraId?: string | null;
+ selectedFillFrameId?: string | null;
}
export function resolveDeleteSelectionTarget({
selectAllBlocksActive,
+ multiSelectedCount = 0,
selectedKeyframeId,
selectedZoomId,
selectedClipId,
selectedAnnotationId,
selectedAudioId,
+ selectedCameraId,
+ selectedFillFrameId,
}: ResolveDeleteSelectionTargetParams): DeleteSelectionTarget {
if (selectAllBlocksActive) return "zoom";
+ if (multiSelectedCount > 0) return "multi";
if (selectedKeyframeId) return "keyframe";
if (selectedZoomId) return "zoom";
if (selectedClipId) return "clip";
if (selectedAnnotationId) return "annotation";
if (selectedAudioId) return "audio";
+ if (selectedCameraId) return "camera";
+ if (selectedFillFrameId) return "fillFrame";
return "none";
}
diff --git a/src/components/video-editor/timeline/model/timelineModel.test.ts b/src/components/video-editor/timeline/model/timelineModel.test.ts
index 1f20052e4..6dea76413 100644
--- a/src/components/video-editor/timeline/model/timelineModel.test.ts
+++ b/src/components/video-editor/timeline/model/timelineModel.test.ts
@@ -35,10 +35,22 @@ describe("timeline model", () => {
],
clipRegions: [{ id: "c1", startMs: 0, endMs: 4000, speed: 1 }],
annotationRegions: [
- { ...BASE_ANNOTATION, type: "text" as const, content: "Hello timeline", trackIndex: 1 },
+ {
+ ...BASE_ANNOTATION,
+ type: "text" as const,
+ content: "Hello timeline",
+ trackIndex: 1,
+ },
],
audioRegions: [
- { id: "au1", startMs: 500, endMs: 2000, audioPath: "/tmp/foo.mp3", volume: 1, trackIndex: 0 },
+ {
+ id: "au1",
+ startMs: 500,
+ endMs: 2000,
+ audioPath: "/tmp/foo.mp3",
+ volume: 1,
+ trackIndex: 0,
+ },
],
});
@@ -47,6 +59,52 @@ describe("timeline model", () => {
expect(items.find((i) => i.id === "au1")?.label).toBe("foo");
});
+ it("maps camera regions to camera items on the camera row", () => {
+ const items = buildTimelineItems({
+ zoomRegions: [],
+ clipRegions: [],
+ annotationRegions: [],
+ audioRegions: [],
+ cameraRegions: [
+ { id: "cam1", startMs: 1000, endMs: 4000 },
+ { id: "cam2", startMs: 6000, endMs: 9000 },
+ ],
+ });
+
+ expect(items).toHaveLength(2);
+ expect(items[0]).toMatchObject({
+ id: "cam1",
+ rowId: "row-camera",
+ span: { start: 1000, end: 4000 },
+ label: "Camera",
+ variant: "camera",
+ });
+ expect(items[1]).toMatchObject({ id: "cam2", variant: "camera" });
+ });
+
+ it("maps fill-frame regions to fullscreen items on the fill-frame row", () => {
+ const items = buildTimelineItems({
+ zoomRegions: [],
+ clipRegions: [],
+ annotationRegions: [],
+ audioRegions: [],
+ fillFrameRegions: [
+ { id: "ff1", startMs: 1000, endMs: 4000 },
+ { id: "ff2", startMs: 6000, endMs: 9000 },
+ ],
+ });
+
+ expect(items).toHaveLength(2);
+ expect(items[0]).toMatchObject({
+ id: "ff1",
+ rowId: "row-fill-frame",
+ span: { start: 1000, end: 4000 },
+ label: "Fullscreen",
+ variant: "fillFrame",
+ });
+ expect(items[1]).toMatchObject({ id: "ff2", variant: "fillFrame" });
+ });
+
it("exposes clip speed for non-default speed labels", () => {
const items = buildTimelineItems({
zoomRegions: [],
@@ -80,8 +138,18 @@ describe("timeline model", () => {
"Annotation",
);
- expect(getAudioLabel({ id: "1", startMs: 0, endMs: 1, audioPath: "C:\\x\\y\\z.wav", volume: 1 })).toBe("z");
- expect(getAudioLabel({ id: "2", startMs: 0, endMs: 1, audioPath: "", volume: 1 })).toBe("Audio");
+ expect(
+ getAudioLabel({
+ id: "1",
+ startMs: 0,
+ endMs: 1,
+ audioPath: "C:\\x\\y\\z.wav",
+ volume: 1,
+ }),
+ ).toBe("z");
+ expect(getAudioLabel({ id: "2", startMs: 0, endMs: 1, audioPath: "", volume: 1 })).toBe(
+ "Audio",
+ );
});
it("builds row spans for dnd constraints", () => {
@@ -91,17 +159,50 @@ describe("timeline model", () => {
],
clipRegions: [{ id: "c1", startMs: 0, endMs: 4000, speed: 1 }],
audioRegions: [
- { id: "au1", startMs: 500, endMs: 2000, audioPath: "x.wav", volume: 1, trackIndex: 2 },
+ {
+ id: "au1",
+ startMs: 500,
+ endMs: 2000,
+ audioPath: "x.wav",
+ volume: 1,
+ trackIndex: 2,
+ },
],
+ cameraRegions: [{ id: "cam1", startMs: 100, endMs: 600 }],
+ fillFrameRegions: [{ id: "ff1", startMs: 700, endMs: 900 }],
});
- expect(spans.map((s) => s.rowId)).toEqual(["row-zoom", "row-clip", "row-audio-2"]);
+ expect(spans.map((s) => s.rowId)).toEqual([
+ "row-zoom",
+ "row-camera",
+ "row-fill-frame",
+ "row-clip",
+ "row-audio-2",
+ ]);
});
it("keeps items in their domain rows during dnd", () => {
const items = [
- { id: "a1", rowId: "row-annotation-1", span: { start: 0, end: 1 }, label: "A", variant: "annotation" as const },
- { id: "au1", rowId: "row-audio-2", span: { start: 0, end: 1 }, label: "X", variant: "audio" as const },
- { id: "z1", rowId: "row-zoom", span: { start: 0, end: 1 }, label: "Z", variant: "zoom" as const },
+ {
+ id: "a1",
+ rowId: "row-annotation-1",
+ span: { start: 0, end: 1 },
+ label: "A",
+ variant: "annotation" as const,
+ },
+ {
+ id: "au1",
+ rowId: "row-audio-2",
+ span: { start: 0, end: 1 },
+ label: "X",
+ variant: "audio" as const,
+ },
+ {
+ id: "z1",
+ rowId: "row-zoom",
+ span: { start: 0, end: 1 },
+ label: "Z",
+ variant: "zoom" as const,
+ },
];
expect(resolveDropRowId("a1", "row-audio-0", items)).toBe("row-annotation-1");
expect(resolveDropRowId("a1", "row-annotation-3", items)).toBe("row-annotation-3");
diff --git a/src/components/video-editor/timeline/model/timelineModel.ts b/src/components/video-editor/timeline/model/timelineModel.ts
index 60737062b..dd6994545 100644
--- a/src/components/video-editor/timeline/model/timelineModel.ts
+++ b/src/components/video-editor/timeline/model/timelineModel.ts
@@ -1,11 +1,6 @@
import { formatClipSpeedLabel } from "../../clipSpeedChange";
-import type {
- AnnotationRegion,
- AudioRegion,
- ClipRegion,
- ZoomRegion,
-} from "../../types";
-import { CLIP_ROW_ID, ZOOM_ROW_ID } from "../core/constants";
+import type { AnnotationRegion, AudioRegion, ClipRegion, ZoomRegion } from "../../types";
+import { CAMERA_ROW_ID, CLIP_ROW_ID, FILL_FRAME_ROW_ID, ZOOM_ROW_ID } from "../core/constants";
import {
getAnnotationTrackIndex,
getAnnotationTrackRowId,
@@ -14,7 +9,7 @@ import {
isAnnotationTrackRowId,
isAudioTrackRowId,
} from "../core/rows";
-import type { TimelineRegionSpan, TimelineRenderItem } from "../core/timelineTypes";
+import type { TimelineRegion, TimelineRegionSpan, TimelineRenderItem } from "../core/timelineTypes";
export function getAnnotationLabel(region: AnnotationRegion): string {
if (region.type === "text") {
@@ -28,7 +23,12 @@ export function getAnnotationLabel(region: AnnotationRegion): string {
}
export function getAudioLabel(region: AudioRegion): string {
- return region.audioPath.split(/[\\/]/).pop()?.replace(/\.[^.]+$/, "") || "Audio";
+ return (
+ region.audioPath
+ .split(/[\\/]/)
+ .pop()
+ ?.replace(/\.[^.]+$/, "") || "Audio"
+ );
}
export function buildTimelineItems(params: {
@@ -36,8 +36,17 @@ export function buildTimelineItems(params: {
clipRegions: ClipRegion[];
annotationRegions: AnnotationRegion[];
audioRegions: AudioRegion[];
+ cameraRegions?: TimelineRegion[];
+ fillFrameRegions?: TimelineRegion[];
}): TimelineRenderItem[] {
- const { zoomRegions, clipRegions, annotationRegions, audioRegions } = params;
+ const {
+ zoomRegions,
+ clipRegions,
+ annotationRegions,
+ audioRegions,
+ cameraRegions = [],
+ fillFrameRegions = [],
+ } = params;
const zooms: TimelineRenderItem[] = zoomRegions.map((region, index) => ({
id: region.id,
rowId: ZOOM_ROW_ID,
@@ -48,6 +57,22 @@ export function buildTimelineItems(params: {
variant: "zoom",
}));
+ const cameras: TimelineRenderItem[] = cameraRegions.map((region) => ({
+ id: region.id,
+ rowId: CAMERA_ROW_ID,
+ span: { start: region.startMs, end: region.endMs },
+ label: "Camera",
+ variant: "camera",
+ }));
+
+ const fillFrames: TimelineRenderItem[] = fillFrameRegions.map((region) => ({
+ id: region.id,
+ rowId: FILL_FRAME_ROW_ID,
+ span: { start: region.startMs, end: region.endMs },
+ label: "Fullscreen",
+ variant: "fillFrame",
+ }));
+
const clips: TimelineRenderItem[] = clipRegions.map((region, index) => {
const displayDurationMs = Math.max(0, region.endMs - region.startMs);
const speed = Number.isFinite(region.speed) && region.speed > 0 ? region.speed : 1;
@@ -86,21 +111,41 @@ export function buildTimelineItems(params: {
variant: "audio",
}));
- return [...zooms, ...clips, ...annotations, ...audios];
+ return [...zooms, ...cameras, ...fillFrames, ...clips, ...annotations, ...audios];
}
export function buildAllRegionSpans(params: {
zoomRegions: ZoomRegion[];
clipRegions: ClipRegion[];
audioRegions: AudioRegion[];
+ cameraRegions?: TimelineRegion[];
+ fillFrameRegions?: TimelineRegion[];
}): TimelineRegionSpan[] {
- const { zoomRegions, clipRegions, audioRegions } = params;
+ const {
+ zoomRegions,
+ clipRegions,
+ audioRegions,
+ cameraRegions = [],
+ fillFrameRegions = [],
+ } = params;
const zooms = zoomRegions.map((r) => ({
id: r.id,
start: r.startMs,
end: r.endMs,
rowId: ZOOM_ROW_ID,
}));
+ const cameras = cameraRegions.map((r) => ({
+ id: r.id,
+ start: r.startMs,
+ end: r.endMs,
+ rowId: CAMERA_ROW_ID,
+ }));
+ const fillFrames = fillFrameRegions.map((r) => ({
+ id: r.id,
+ start: r.startMs,
+ end: r.endMs,
+ rowId: FILL_FRAME_ROW_ID,
+ }));
const clips = clipRegions.map((r) => ({
id: r.id,
start: r.startMs,
@@ -113,7 +158,7 @@ export function buildAllRegionSpans(params: {
end: r.endMs,
rowId: getAudioTrackRowId(r.trackIndex ?? 0),
}));
- return [...zooms, ...clips, ...audios];
+ return [...zooms, ...cameras, ...fillFrames, ...clips, ...audios];
}
export function resolveDropRowId(
diff --git a/src/components/video-editor/timeline/timelineLayout.test.ts b/src/components/video-editor/timeline/timelineLayout.test.ts
index ba1756326..fcddef373 100644
--- a/src/components/video-editor/timeline/timelineLayout.test.ts
+++ b/src/components/video-editor/timeline/timelineLayout.test.ts
@@ -1,11 +1,14 @@
import { describe, expect, it } from "vitest";
+import { CLIP_ROW_ID, ZOOM_ROW_ID } from "./core/constants";
+import { getAnnotationTrackRowId, getAudioTrackRowId } from "./core/rows";
import {
+ countTimelineRows,
getTimelineContentMinHeightPx,
+ getTimelinePreferredHeightPx,
getTimelineRowsMinHeightPx,
- getTimelineViewportStretchFactor,
TIMELINE_AXIS_HEIGHT_PX,
+ TIMELINE_COMFORTABLE_ROW_HEIGHT_PX,
TIMELINE_ROW_MIN_HEIGHT_PX,
- TIMELINE_VISIBLE_ROW_COUNT,
} from "./timelineLayout";
describe("timelineLayout", () => {
@@ -31,11 +34,64 @@ describe("timelineLayout", () => {
);
});
- it("stretches content height to keep a two-row viewport", () => {
- expect(TIMELINE_VISIBLE_ROW_COUNT).toBe(2);
- expect(getTimelineViewportStretchFactor(2)).toBe(1);
- expect(getTimelineViewportStretchFactor(4)).toBe(2);
- expect(getTimelineViewportStretchFactor(5)).toBe(2.5);
- expect(getTimelineViewportStretchFactor(0)).toBe(1);
+ it("prefers a comfortable height per visible row", () => {
+ expect(getTimelinePreferredHeightPx(2)).toBe(
+ TIMELINE_AXIS_HEIGHT_PX + 2 * TIMELINE_COMFORTABLE_ROW_HEIGHT_PX,
+ );
+ expect(getTimelinePreferredHeightPx(3)).toBe(
+ TIMELINE_AXIS_HEIGHT_PX + 3 * TIMELINE_COMFORTABLE_ROW_HEIGHT_PX,
+ );
+ expect(getTimelinePreferredHeightPx(Number.NaN)).toBe(TIMELINE_AXIS_HEIGHT_PX);
+ });
+
+ describe("countTimelineRows", () => {
+ const baseOptions = {
+ showCameraTrack: false,
+ showFillFrameTrack: false,
+ showSourceAudioTrack: false,
+ sourceAudioTrackCount: 0,
+ };
+
+ it("always counts the clip and zoom rows", () => {
+ expect(countTimelineRows([], baseOptions)).toBe(2);
+ });
+
+ it("adds the camera row when visible", () => {
+ expect(countTimelineRows([], { ...baseOptions, showCameraTrack: true })).toBe(3);
+ });
+
+ it("adds the fill-frame row when visible", () => {
+ expect(countTimelineRows([], { ...baseOptions, showFillFrameTrack: true })).toBe(3);
+ expect(
+ countTimelineRows([], {
+ ...baseOptions,
+ showCameraTrack: true,
+ showFillFrameTrack: true,
+ }),
+ ).toBe(4);
+ });
+
+ it("adds one row per visible source-audio track", () => {
+ expect(
+ countTimelineRows([], {
+ ...baseOptions,
+ showSourceAudioTrack: true,
+ sourceAudioTrackCount: 2,
+ }),
+ ).toBe(4);
+ expect(countTimelineRows([], { ...baseOptions, sourceAudioTrackCount: 2 })).toBe(2);
+ });
+
+ it("counts distinct annotation and audio track rows from items", () => {
+ const items = [
+ { rowId: CLIP_ROW_ID },
+ { rowId: ZOOM_ROW_ID },
+ { rowId: getAnnotationTrackRowId(0) },
+ { rowId: getAnnotationTrackRowId(0) },
+ { rowId: getAnnotationTrackRowId(1) },
+ { rowId: getAudioTrackRowId(0) },
+ ];
+ expect(countTimelineRows(items, baseOptions)).toBe(5);
+ });
});
});
diff --git a/src/components/video-editor/timeline/timelineLayout.ts b/src/components/video-editor/timeline/timelineLayout.ts
index 0f54c4b42..3670c0fce 100644
--- a/src/components/video-editor/timeline/timelineLayout.ts
+++ b/src/components/video-editor/timeline/timelineLayout.ts
@@ -1,6 +1,11 @@
+import { isAnnotationTrackRowId, isAudioTrackRowId } from "./core/rows";
+
export const TIMELINE_AXIS_HEIGHT_PX = 32;
export const TIMELINE_ROW_MIN_HEIGHT_PX = 28;
-export const TIMELINE_VISIBLE_ROW_COUNT = 2;
+// Per-row height the timeline panel asks for so every visible track renders
+// comfortably. Condensed from the historical 64px rows so empty tracks no
+// longer reserve tall bands now that the row hint texts are gone.
+export const TIMELINE_COMFORTABLE_ROW_HEIGHT_PX = 48;
function normalizeRowCount(rowCount: number) {
if (!Number.isFinite(rowCount)) {
@@ -18,12 +23,41 @@ export function getTimelineContentMinHeightPx(rowCount: number) {
return TIMELINE_AXIS_HEIGHT_PX + getTimelineRowsMinHeightPx(rowCount);
}
-export function getTimelineViewportStretchFactor(rowCount: number) {
- const normalizedRowCount = normalizeRowCount(rowCount);
+export function getTimelinePreferredHeightPx(rowCount: number) {
+ return (
+ TIMELINE_AXIS_HEIGHT_PX + normalizeRowCount(rowCount) * TIMELINE_COMFORTABLE_ROW_HEIGHT_PX
+ );
+}
- if (normalizedRowCount <= 0) {
- return 1;
- }
+export interface CountTimelineRowsOptions {
+ showCameraTrack: boolean;
+ showFillFrameTrack: boolean;
+ showSourceAudioTrack: boolean;
+ sourceAudioTrackCount: number;
+}
- return Math.max(1, normalizedRowCount / TIMELINE_VISIBLE_ROW_COUNT);
+// Counts the rows the timeline canvas renders: clip + zoom (always), plus the
+// camera row, fill-frame row, source-audio rows, and one row per distinct
+// annotation/audio track.
+export function countTimelineRows(
+ items: readonly { rowId: string }[],
+ {
+ showCameraTrack,
+ showFillFrameTrack,
+ showSourceAudioTrack,
+ sourceAudioTrackCount,
+ }: CountTimelineRowsOptions,
+) {
+ const annotationRowIds = new Set();
+ const audioRowIds = new Set();
+ for (const item of items) {
+ if (isAnnotationTrackRowId(item.rowId)) annotationRowIds.add(item.rowId);
+ if (isAudioTrackRowId(item.rowId)) audioRowIds.add(item.rowId);
+ }
+ const sourceAudioRows = showSourceAudioTrack ? Math.max(0, sourceAudioTrackCount) : 0;
+ const cameraRows = showCameraTrack ? 1 : 0;
+ const fillFrameRows = showFillFrameTrack ? 1 : 0;
+ return (
+ 2 + cameraRows + fillFrameRows + sourceAudioRows + annotationRowIds.size + audioRowIds.size
+ );
}
diff --git a/src/components/video-editor/timelineDisplayMapping.test.ts b/src/components/video-editor/timelineDisplayMapping.test.ts
new file mode 100644
index 000000000..3165a0d45
--- /dev/null
+++ b/src/components/video-editor/timelineDisplayMapping.test.ts
@@ -0,0 +1,313 @@
+import { describe, expect, it } from "vitest";
+import {
+ clipsToDisplay,
+ displayMsToTimeline,
+ gapAwareSourceToTimelineMs,
+ gapAwareTimelineToSourceMs,
+ getDisplayDurationMs,
+ msToDisplay,
+ msToSource,
+ regionToDisplay,
+ spanToSource,
+ spanToTimeline,
+ timelineMsToDisplay,
+ timelineRegionToDisplay,
+} from "./timelineDisplayMapping";
+import type { ClipRegion } from "./types";
+
+// Two speed-1 clips with a 3s source gap between them.
+const gappedClips: ClipRegion[] = [
+ { id: "a", startMs: 0, endMs: 2000, speed: 1 },
+ { id: "b", startMs: 5000, endMs: 10000, speed: 1 },
+];
+
+// A 2x clip (source [0, 2000], timeline [0, 1000]) then a gap, then a 1x clip.
+const speedClips: ClipRegion[] = [
+ { id: "c", startMs: 0, endMs: 1000, speed: 2 },
+ { id: "d", startMs: 4000, endMs: 6000, speed: 1 },
+];
+
+describe("clipsToDisplay", () => {
+ it("packs clips contiguously preserving display durations", () => {
+ const display = clipsToDisplay(gappedClips);
+ expect(
+ display.map((clip) => ({ id: clip.id, startMs: clip.startMs, endMs: clip.endMs })),
+ ).toEqual([
+ { id: "a", startMs: 0, endMs: 2000 },
+ { id: "b", startMs: 2000, endMs: 7000 },
+ ]);
+ });
+
+ it("keeps speed-compressed display durations and carries source spans", () => {
+ const display = clipsToDisplay(speedClips);
+ expect(display[0]).toMatchObject({
+ id: "c",
+ startMs: 0,
+ endMs: 1000,
+ speed: 2,
+ sourceStartMs: 0,
+ sourceEndMs: 2000,
+ });
+ expect(display[1]).toMatchObject({
+ id: "d",
+ startMs: 1000,
+ endMs: 3000,
+ speed: 1,
+ sourceStartMs: 4000,
+ sourceEndMs: 6000,
+ });
+ });
+
+ it("sorts unsorted input by start time", () => {
+ const display = clipsToDisplay([gappedClips[1], gappedClips[0]]);
+ expect(display.map((clip) => clip.id)).toEqual(["a", "b"]);
+ expect(display[0].startMs).toBe(0);
+ expect(display[1].startMs).toBe(2000);
+ });
+
+ it("preserves clip metadata", () => {
+ const display = clipsToDisplay([
+ { id: "a", startMs: 5000, endMs: 10000, speed: 1, muted: true, showSourceAudio: true },
+ ]);
+ expect(display[0]).toMatchObject({
+ id: "a",
+ startMs: 0,
+ endMs: 5000,
+ muted: true,
+ showSourceAudio: true,
+ });
+ });
+
+ it("returns an empty array for no clips", () => {
+ expect(clipsToDisplay([])).toEqual([]);
+ });
+});
+
+describe("getDisplayDurationMs", () => {
+ it("sums clip display durations", () => {
+ expect(getDisplayDurationMs(gappedClips)).toBe(7000);
+ expect(getDisplayDurationMs(speedClips)).toBe(3000);
+ });
+
+ it("returns 0 for no clips", () => {
+ expect(getDisplayDurationMs([])).toBe(0);
+ });
+});
+
+describe("timelineMsToDisplay / displayMsToTimeline", () => {
+ it("collapses gaps", () => {
+ expect(timelineMsToDisplay(1000, gappedClips)).toBe(1000);
+ expect(timelineMsToDisplay(6000, gappedClips)).toBe(3000);
+ expect(displayMsToTimeline(3000, gappedClips)).toBe(6000);
+ });
+
+ it("clamps times inside a gap to the preceding boundary", () => {
+ expect(timelineMsToDisplay(3500, gappedClips)).toBe(2000);
+ });
+
+ it("clamps times beyond the last clip", () => {
+ expect(timelineMsToDisplay(12000, gappedClips)).toBe(7000);
+ expect(displayMsToTimeline(9000, gappedClips)).toBe(10000);
+ });
+
+ it("round-trips points inside clips", () => {
+ for (const timelineMs of [0, 500, 1999, 5000, 7500, 10000]) {
+ expect(
+ displayMsToTimeline(timelineMsToDisplay(timelineMs, gappedClips), gappedClips),
+ ).toBe(timelineMs);
+ }
+ });
+
+ it("is the identity with no clips", () => {
+ expect(timelineMsToDisplay(1234, [])).toBe(1234);
+ expect(displayMsToTimeline(1234, [])).toBe(1234);
+ });
+
+ it("works with timeline-space gaps left by speed clips", () => {
+ expect(timelineMsToDisplay(500, speedClips)).toBe(500);
+ expect(timelineMsToDisplay(4500, speedClips)).toBe(1500);
+ expect(displayMsToTimeline(1500, speedClips)).toBe(4500);
+ });
+});
+
+describe("msToDisplay / msToSource", () => {
+ it("maps source times into the collapsed display", () => {
+ expect(msToDisplay(1000, gappedClips)).toBe(1000);
+ expect(msToDisplay(6000, gappedClips)).toBe(3000);
+ });
+
+ it("maps display times back to source times", () => {
+ expect(msToSource(1000, gappedClips)).toBe(1000);
+ expect(msToSource(3000, gappedClips)).toBe(6000);
+ });
+
+ it("round-trips source points inside clips", () => {
+ for (const sourceMs of [0, 1500, 5000, 8000, 10000]) {
+ expect(msToSource(msToDisplay(sourceMs, gappedClips), gappedClips)).toBe(sourceMs);
+ }
+ });
+
+ it("applies clip speed when mapping", () => {
+ // Source 1000 inside the 2x clip sits at display 500.
+ expect(msToDisplay(1000, speedClips)).toBe(500);
+ expect(msToSource(500, speedClips)).toBe(1000);
+ // Source 5000 inside the 1x clip sits at display 2000.
+ expect(msToDisplay(5000, speedClips)).toBe(2000);
+ expect(msToSource(2000, speedClips)).toBe(5000);
+ });
+
+ it("clamps source times inside a gap to a clip boundary", () => {
+ expect(msToDisplay(2500, gappedClips)).toBe(2000);
+ });
+
+ it("is the identity with no clips", () => {
+ expect(msToDisplay(777, [])).toBe(777);
+ expect(msToSource(777, [])).toBe(777);
+ });
+});
+
+describe("regionToDisplay / spanToSource", () => {
+ it("collapses a source region spanning a gap", () => {
+ const region = { id: "z", startMs: 1500, endMs: 5500, depth: 2 };
+ const display = regionToDisplay(region, gappedClips);
+ expect(display).toEqual({ id: "z", startMs: 1500, endMs: 2500, depth: 2 });
+ });
+
+ it("maps display spans back to source spans across the gap", () => {
+ expect(spanToSource({ start: 1500, end: 2500 }, gappedClips)).toEqual({
+ start: 1500,
+ end: 5500,
+ });
+ });
+
+ it("round-trips regions inside clips", () => {
+ const region = { id: "z", startMs: 5500, endMs: 7000 };
+ const display = regionToDisplay(region, gappedClips);
+ expect(display).toEqual({ id: "z", startMs: 2500, endMs: 4000 });
+ expect(spanToSource({ start: display.startMs, end: display.endMs }, gappedClips)).toEqual({
+ start: 5500,
+ end: 7000,
+ });
+ });
+
+ it("passes through unchanged with no clips", () => {
+ const region = { id: "z", startMs: 100, endMs: 200 };
+ expect(regionToDisplay(region, [])).toEqual(region);
+ });
+});
+
+describe("gapAwareSourceToTimelineMs / gapAwareTimelineToSourceMs (magnet off)", () => {
+ // A clip that starts after a leading gap.
+ const leadingGapClips: ClipRegion[] = [{ id: "e", startMs: 3000, endMs: 5000, speed: 1 }];
+ // A single 2x clip (source [0, 2000], timeline [0, 1000]) with a trailing
+ // gap up to a 3000ms source duration; the ruler runs to
+ // getTimelineDurationMs = max(3000, 1000) = 3000.
+ const trailingSpeedClips: ClipRegion[] = [{ id: "f", startMs: 0, endMs: 1000, speed: 2 }];
+
+ it("matches the clip mapping for source times inside clips", () => {
+ expect(gapAwareSourceToTimelineMs(1000, gappedClips)).toBe(1000);
+ expect(gapAwareSourceToTimelineMs(6000, gappedClips)).toBe(6000);
+ // Source 1000 inside the 2x clip sits at timeline 500.
+ expect(gapAwareSourceToTimelineMs(1000, speedClips)).toBe(500);
+ expect(gapAwareSourceToTimelineMs(5000, speedClips)).toBe(5000);
+ });
+
+ it("advances through an inter-clip gap instead of clamping", () => {
+ // Source gap [2000, 5000] renders between the clips at timeline
+ // [2000, 5000] (speed-1 neighbors), so the mapping is the identity.
+ expect(gapAwareSourceToTimelineMs(3500, gappedClips)).toBe(3500);
+ expect(gapAwareTimelineToSourceMs(3500, gappedClips)).toBe(3500);
+ });
+
+ it("is continuous at gap entry and exit boundaries", () => {
+ // Gap entry == previous clip's rendered end.
+ expect(gapAwareSourceToTimelineMs(2000, gappedClips)).toBe(2000);
+ // Gap exit == next clip's rendered start.
+ expect(gapAwareSourceToTimelineMs(5000, gappedClips)).toBe(5000);
+ });
+
+ it("scales a gap between speed clips to its rendered width", () => {
+ // speedClips: source gap [2000, 4000] (width 2000) renders between the
+ // 2x clip's end (timeline 1000) and the next clip's start (timeline
+ // 4000), i.e. visual width 3000.
+ expect(gapAwareSourceToTimelineMs(2000, speedClips)).toBe(1000); // entry == rendered end of clip c
+ expect(gapAwareSourceToTimelineMs(4000, speedClips)).toBe(4000); // exit == rendered start of clip d
+ expect(gapAwareSourceToTimelineMs(3000, speedClips)).toBe(2500); // midpoint maps to visual midpoint
+ expect(gapAwareTimelineToSourceMs(2500, speedClips)).toBe(3000);
+ });
+
+ it("round-trips source times inside gaps", () => {
+ for (const sourceMs of [2000, 2500, 3000, 3500, 4000]) {
+ expect(
+ gapAwareTimelineToSourceMs(
+ gapAwareSourceToTimelineMs(sourceMs, speedClips),
+ speedClips,
+ ),
+ ).toBe(sourceMs);
+ }
+ for (const sourceMs of [2000, 3100, 4999]) {
+ expect(
+ gapAwareTimelineToSourceMs(
+ gapAwareSourceToTimelineMs(sourceMs, gappedClips),
+ gappedClips,
+ ),
+ ).toBe(sourceMs);
+ }
+ });
+
+ it("traverses a leading gap as the identity", () => {
+ expect(gapAwareSourceToTimelineMs(0, leadingGapClips)).toBe(0);
+ expect(gapAwareSourceToTimelineMs(1500, leadingGapClips)).toBe(1500);
+ expect(gapAwareSourceToTimelineMs(3000, leadingGapClips)).toBe(3000);
+ expect(gapAwareTimelineToSourceMs(1500, leadingGapClips)).toBe(1500);
+ });
+
+ it("scales a trailing gap to the ruler end when the source duration is known", () => {
+ // Trailing source gap [2000, 3000] renders from the clip's end (timeline
+ // 1000) to the ruler end (timeline 3000).
+ expect(gapAwareSourceToTimelineMs(2000, trailingSpeedClips, 3000)).toBe(1000);
+ expect(gapAwareSourceToTimelineMs(2500, trailingSpeedClips, 3000)).toBe(2000);
+ expect(gapAwareSourceToTimelineMs(3000, trailingSpeedClips, 3000)).toBe(3000);
+ expect(gapAwareTimelineToSourceMs(2000, trailingSpeedClips, 3000)).toBe(2500);
+ expect(gapAwareTimelineToSourceMs(3000, trailingSpeedClips, 3000)).toBe(3000);
+ });
+
+ it("offsets a trailing gap from the rendered clip end without a source duration", () => {
+ // gappedClips end at source == timeline 10000, so the trailing gap is an
+ // identity offset.
+ expect(gapAwareSourceToTimelineMs(11000, gappedClips)).toBe(11000);
+ expect(gapAwareTimelineToSourceMs(11000, gappedClips)).toBe(11000);
+ });
+
+ it("clamps beyond the known durations", () => {
+ expect(gapAwareSourceToTimelineMs(9999, trailingSpeedClips, 3000)).toBe(3000);
+ expect(gapAwareTimelineToSourceMs(9999, trailingSpeedClips, 3000)).toBe(3000);
+ });
+
+ it("is the identity with no clips", () => {
+ expect(gapAwareSourceToTimelineMs(1234, [])).toBe(1234);
+ expect(gapAwareTimelineToSourceMs(1234, [])).toBe(1234);
+ });
+});
+
+describe("timelineRegionToDisplay / spanToTimeline", () => {
+ it("shifts timeline-space regions without rescaling for speed", () => {
+ // Timeline-space region inside the second clip (timeline [4000, 6000]).
+ const region = { id: "z", startMs: 4200, endMs: 5200 };
+ const display = timelineRegionToDisplay(region, speedClips);
+ expect(display).toEqual({ id: "z", startMs: 1200, endMs: 2200 });
+ expect(spanToTimeline({ start: 1200, end: 2200 }, speedClips)).toEqual({
+ start: 4200,
+ end: 5200,
+ });
+ });
+
+ it("collapses timeline regions spanning a gap", () => {
+ const region = { id: "z", startMs: 1500, endMs: 5500 };
+ expect(timelineRegionToDisplay(region, gappedClips)).toEqual({
+ id: "z",
+ startMs: 1500,
+ endMs: 2500,
+ });
+ });
+});
diff --git a/src/components/video-editor/timelineDisplayMapping.ts b/src/components/video-editor/timelineDisplayMapping.ts
new file mode 100644
index 000000000..d9846bf7f
--- /dev/null
+++ b/src/components/video-editor/timelineDisplayMapping.ts
@@ -0,0 +1,330 @@
+import {
+ type ClipRegion,
+ getClipSourceEndMs,
+ getTimelineDurationMs,
+ mapSourceTimeToTimelineTime,
+ mapTimelineTimeToSourceTime,
+ sortClipRegions,
+} from "./types";
+
+/**
+ * Magnet-ON display mapping.
+ *
+ * Spaces:
+ * - SOURCE space: raw media time (annotation and camera regions, video element
+ * currentTime).
+ * - TIMELINE space: clip-anchored time where each clip starts at its source
+ * startMs but runs at displayDuration = sourceDuration / speed (clip spans,
+ * zoom and audio regions, timelinePlayheadTime). Gaps left by deleted clips
+ * exist in both source and timeline space.
+ * - DISPLAY space: timeline space with the inter-clip gaps collapsed, so clips
+ * pack contiguously from 0. This is what the timeline UI shows when the
+ * magnet is on.
+ *
+ * All functions are pure and treat an empty clip list as the identity mapping.
+ */
+
+export interface DisplayClipRegion extends ClipRegion {
+ /** Original source-space span, carried for reverse mapping / debugging. */
+ sourceStartMs: number;
+ sourceEndMs: number;
+}
+
+interface DisplaySegment {
+ clip: ClipRegion;
+ timelineStartMs: number;
+ timelineEndMs: number;
+ displayStartMs: number;
+ displayEndMs: number;
+}
+
+function buildDisplaySegments(clips: ClipRegion[]): DisplaySegment[] {
+ const segments: DisplaySegment[] = [];
+ let displayCursorMs = 0;
+
+ for (const clip of sortClipRegions(clips)) {
+ const displayDurationMs = Math.max(0, Math.round(clip.endMs) - Math.round(clip.startMs));
+ segments.push({
+ clip,
+ timelineStartMs: Math.round(clip.startMs),
+ timelineEndMs: Math.round(clip.startMs) + displayDurationMs,
+ displayStartMs: displayCursorMs,
+ displayEndMs: displayCursorMs + displayDurationMs,
+ });
+ displayCursorMs += displayDurationMs;
+ }
+
+ return segments;
+}
+
+/** Packs clips contiguously from 0, preserving order, duration and metadata. */
+export function clipsToDisplay(clips: ClipRegion[]): DisplayClipRegion[] {
+ return buildDisplaySegments(clips).map((segment) => ({
+ ...segment.clip,
+ startMs: segment.displayStartMs,
+ endMs: segment.displayEndMs,
+ sourceStartMs: segment.clip.startMs,
+ sourceEndMs: getClipSourceEndMs(segment.clip),
+ }));
+}
+
+/** Total duration of the collapsed display (sum of clip display durations). */
+export function getDisplayDurationMs(clips: ClipRegion[]): number {
+ const segments = buildDisplaySegments(clips);
+ return segments.length > 0 ? segments[segments.length - 1].displayEndMs : 0;
+}
+
+/**
+ * Maps a TIMELINE-space time into DISPLAY space. Times inside a gap clamp to
+ * the preceding clip boundary; times beyond the last clip clamp to the
+ * display end.
+ */
+export function timelineMsToDisplay(timelineMs: number, clips: ClipRegion[]): number {
+ const roundedMs = Math.round(timelineMs);
+ const segments = buildDisplaySegments(clips);
+ if (segments.length === 0) {
+ return roundedMs;
+ }
+
+ for (const segment of segments) {
+ if (roundedMs < segment.timelineStartMs) {
+ // Inside the gap before this clip: clamp to the preceding boundary,
+ // which is exactly this clip's display start.
+ return segment.displayStartMs;
+ }
+ if (roundedMs <= segment.timelineEndMs) {
+ return segment.displayStartMs + (roundedMs - segment.timelineStartMs);
+ }
+ }
+
+ return segments[segments.length - 1].displayEndMs;
+}
+
+/**
+ * Maps a DISPLAY-space time back into TIMELINE space. Times beyond the display
+ * end clamp to the last clip's timeline end.
+ */
+export function displayMsToTimeline(displayMs: number, clips: ClipRegion[]): number {
+ const roundedMs = Math.round(displayMs);
+ const segments = buildDisplaySegments(clips);
+ if (segments.length === 0) {
+ return roundedMs;
+ }
+
+ for (const segment of segments) {
+ // Strict inequality: a display time sitting exactly on a clip boundary
+ // belongs to the NEXT clip's start (display space is contiguous), so the
+ // round-trip of a clip start returns that clip's own timeline start.
+ if (roundedMs < segment.displayEndMs) {
+ const offsetMs = Math.max(0, roundedMs - segment.displayStartMs);
+ return segment.timelineStartMs + offsetMs;
+ }
+ }
+
+ return segments[segments.length - 1].timelineEndMs;
+}
+
+/** Maps a SOURCE-space time into DISPLAY space (speed-aware). */
+export function msToDisplay(sourceMs: number, clips: ClipRegion[]): number {
+ return timelineMsToDisplay(mapSourceTimeToTimelineTime(sourceMs, clips), clips);
+}
+
+/** Maps a DISPLAY-space time back into SOURCE space (speed-aware). */
+export function msToSource(displayMs: number, clips: ClipRegion[]): number {
+ return mapTimelineTimeToSourceTime(displayMsToTimeline(displayMs, clips), clips);
+}
+
+/** Maps a SOURCE-space region into DISPLAY space, preserving other fields. */
+export function regionToDisplay(
+ region: T,
+ clips: ClipRegion[],
+): T {
+ return {
+ ...region,
+ startMs: msToDisplay(region.startMs, clips),
+ endMs: msToDisplay(region.endMs, clips),
+ };
+}
+
+/** Maps a DISPLAY-space span back into SOURCE space. */
+export function spanToSource(
+ span: { start: number; end: number },
+ clips: ClipRegion[],
+): { start: number; end: number } {
+ return {
+ start: msToSource(span.start, clips),
+ end: msToSource(span.end, clips),
+ };
+}
+
+// --- Magnet-OFF gap-aware mapping ---
+//
+// With the magnet off the timeline renders clips at their TIMELINE-space
+// anchors ([startMs, endMs], speed-adjusted widths) with the inter-clip gaps
+// visible, and trimmed source ranges play back as black time. The plain
+// mapSourceTimeToTimelineTime/mapTimelineTimeToSourceTime pair clamps in-gap
+// times to the nearest clip boundary, which parks the playhead during a gap
+// and makes gap positions unreachable by seeking. The gap-aware pair instead
+// treats gaps as first-class: a SOURCE-space gap [sourceEnd(i), start(i+1)]
+// maps linearly onto its rendered TIMELINE-space span [endMs(i), start(i+1)],
+// so the playhead enters the gap at the previous clip's rendered end, leaves
+// it at the next clip's rendered start, and moves linearly in between (an
+// identity offset when both neighbors run at speed 1).
+
+interface GapAwareSegment {
+ sourceStartMs: number;
+ sourceEndMs: number;
+ timelineStartMs: number;
+ timelineEndMs: number;
+ speed: number;
+}
+
+function buildGapAwareSegments(clips: ClipRegion[]): GapAwareSegment[] {
+ return sortClipRegions(clips).map((clip) => ({
+ sourceStartMs: Math.round(clip.startMs),
+ sourceEndMs: getClipSourceEndMs(clip),
+ timelineStartMs: Math.round(clip.startMs),
+ timelineEndMs: Math.round(clip.endMs),
+ speed: Number.isFinite(clip.speed) && clip.speed > 0 ? clip.speed : 1,
+ }));
+}
+
+/** Linearly maps a point from one span onto another, clamping degenerate spans. */
+function lerpSpan(
+ value: number,
+ fromStart: number,
+ fromEnd: number,
+ toStart: number,
+ toEnd: number,
+): number {
+ const fromWidth = fromEnd - fromStart;
+ if (fromWidth <= 0) {
+ return Math.round(toEnd);
+ }
+ const ratio = Math.min(1, Math.max(0, (value - fromStart) / fromWidth));
+ return Math.round(toStart + ratio * (toEnd - toStart));
+}
+
+/**
+ * Like mapSourceTimeToTimelineTime, but SOURCE times inside a gap advance
+ * through the gap's rendered span instead of clamping to a clip boundary.
+ * `sourceDurationMs` (when known) scales the trailing gap onto the ruler end
+ * (getTimelineDurationMs); without it the trailing gap is a plain offset from
+ * the last clip's rendered end.
+ */
+export function gapAwareSourceToTimelineMs(
+ sourceMs: number,
+ clips: ClipRegion[],
+ sourceDurationMs?: number,
+): number {
+ const roundedMs = Math.round(sourceMs);
+ const segments = buildGapAwareSegments(clips);
+ if (segments.length === 0) {
+ return roundedMs;
+ }
+
+ let previousSourceEndMs = 0;
+ let previousTimelineEndMs = 0;
+ for (const segment of segments) {
+ if (roundedMs < segment.sourceStartMs) {
+ // Inside the gap before this clip (leading or inter-clip).
+ return lerpSpan(
+ roundedMs,
+ previousSourceEndMs,
+ segment.sourceStartMs,
+ previousTimelineEndMs,
+ segment.timelineStartMs,
+ );
+ }
+ if (roundedMs <= segment.sourceEndMs) {
+ return Math.round(
+ segment.timelineStartMs + (roundedMs - segment.sourceStartMs) / segment.speed,
+ );
+ }
+ previousSourceEndMs = segment.sourceEndMs;
+ previousTimelineEndMs = segment.timelineEndMs;
+ }
+
+ // Trailing gap after the last clip.
+ if (sourceDurationMs !== undefined && sourceDurationMs > previousSourceEndMs) {
+ return lerpSpan(
+ roundedMs,
+ previousSourceEndMs,
+ Math.round(sourceDurationMs),
+ previousTimelineEndMs,
+ getTimelineDurationMs(clips, sourceDurationMs),
+ );
+ }
+ return previousTimelineEndMs + (roundedMs - previousSourceEndMs);
+}
+
+/**
+ * Inverse of gapAwareSourceToTimelineMs: TIMELINE times inside a rendered gap
+ * span map into the trimmed SOURCE range instead of clamping to a boundary.
+ */
+export function gapAwareTimelineToSourceMs(
+ timelineMs: number,
+ clips: ClipRegion[],
+ sourceDurationMs?: number,
+): number {
+ const roundedMs = Math.round(timelineMs);
+ const segments = buildGapAwareSegments(clips);
+ if (segments.length === 0) {
+ return roundedMs;
+ }
+
+ let previousSourceEndMs = 0;
+ let previousTimelineEndMs = 0;
+ for (const segment of segments) {
+ if (roundedMs < segment.timelineStartMs) {
+ return lerpSpan(
+ roundedMs,
+ previousTimelineEndMs,
+ segment.timelineStartMs,
+ previousSourceEndMs,
+ segment.sourceStartMs,
+ );
+ }
+ if (roundedMs <= segment.timelineEndMs) {
+ return Math.round(
+ segment.sourceStartMs + (roundedMs - segment.timelineStartMs) * segment.speed,
+ );
+ }
+ previousSourceEndMs = segment.sourceEndMs;
+ previousTimelineEndMs = segment.timelineEndMs;
+ }
+
+ if (sourceDurationMs !== undefined && sourceDurationMs > previousSourceEndMs) {
+ return lerpSpan(
+ roundedMs,
+ previousTimelineEndMs,
+ getTimelineDurationMs(clips, sourceDurationMs),
+ previousSourceEndMs,
+ Math.round(sourceDurationMs),
+ );
+ }
+ return previousSourceEndMs + (roundedMs - previousTimelineEndMs);
+}
+
+/** Maps a TIMELINE-space region into DISPLAY space, preserving other fields. */
+export function timelineRegionToDisplay(
+ region: T,
+ clips: ClipRegion[],
+): T {
+ return {
+ ...region,
+ startMs: timelineMsToDisplay(region.startMs, clips),
+ endMs: timelineMsToDisplay(region.endMs, clips),
+ };
+}
+
+/** Maps a DISPLAY-space span back into TIMELINE space. */
+export function spanToTimeline(
+ span: { start: number; end: number },
+ clips: ClipRegion[],
+): { start: number; end: number } {
+ return {
+ start: displayMsToTimeline(span.start, clips),
+ end: displayMsToTimeline(span.end, clips),
+ };
+}
diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts
index 9bc401d5b..7e53b9d05 100644
--- a/src/components/video-editor/types.ts
+++ b/src/components/video-editor/types.ts
@@ -125,6 +125,63 @@ export type WebcamPositionPreset =
| "bottom-center"
| "custom";
+export interface WebcamGreenscreenSettings {
+ enabled: boolean;
+ /** Image composited behind the keyed facecam; project-assets path. */
+ backgroundImagePath: string | null;
+ /** 0..1 chroma tolerance */
+ keyStrength: number;
+ /** 0..1 edge transition softness */
+ edgeSoftness: number;
+ /** Hex key color, eyedropper-pickable; lighting shifts real screens far from pure green. */
+ keyColor: string;
+ /** Optional second key color for unevenly lit screens; null = unused. */
+ keyColor2: string | null;
+ /** Protect (holdout) color: pixels near it are never keyed; null = unused. */
+ protectColor: string | null;
+}
+
+/**
+ * One anchor of the pen mask path in normalized source coordinates. The
+ * optional in/out bezier handles are ABSOLUTE normalized coordinates; a point
+ * without handles behaves as a corner point (segments touching it use the
+ * anchor itself as the control point, so the path degenerates to a straight
+ * line when both segment ends are corner points).
+ */
+export interface WebcamMaskPoint {
+ x: number;
+ y: number;
+ inX?: number;
+ inY?: number;
+ outX?: number;
+ outY?: number;
+}
+
+export interface WebcamMaskSettings {
+ enabled: boolean;
+ /** Which keep-shape the mask uses: the draggable box or the pen path. */
+ shape: "rect" | "polygon";
+ /** Normalized "keep" region; everything outside is treated as keyed out. */
+ rect: CropRegion;
+ /** 0..1 relative to the rect's shorter half-extent */
+ cornerRadius: number;
+ /** 0..1 edge softness band */
+ feather: number;
+ /** normalized source-coordinate bezier anchors; meaningful when shape === "polygon" (>= 3 points) */
+ points: WebcamMaskPoint[];
+}
+
+export interface WebcamColorSettings {
+ /** all -1..1, 0 = neutral */
+ brightness: number;
+ contrast: number;
+ highlights: number;
+ shadows: number;
+ /** warm (+) / cool (-) white-balance shift */
+ temperature: number;
+ saturation: number;
+}
+
export interface WebcamOverlaySettings {
enabled: boolean;
sourcePath: string | null;
@@ -140,6 +197,9 @@ export interface WebcamOverlaySettings {
cornerRadius: number;
shadow: number;
margin: number;
+ greenscreen?: WebcamGreenscreenSettings;
+ mask?: WebcamMaskSettings;
+ color?: WebcamColorSettings;
}
export const DEFAULT_CURSOR_SIZE = 3.0;
@@ -187,6 +247,36 @@ export const DEFAULT_WEBCAM_POSITION_X = 1;
export const DEFAULT_WEBCAM_POSITION_Y = 1;
export const DEFAULT_WEBCAM_TIME_OFFSET_MS = 0;
+export const DEFAULT_WEBCAM_KEY_COLOR = "#00cc00";
+
+export const DEFAULT_WEBCAM_GREENSCREEN: WebcamGreenscreenSettings = {
+ enabled: false,
+ backgroundImagePath: null,
+ keyStrength: 0.5,
+ edgeSoftness: 0.35,
+ keyColor: DEFAULT_WEBCAM_KEY_COLOR,
+ keyColor2: null,
+ protectColor: null,
+};
+
+export const DEFAULT_WEBCAM_MASK: WebcamMaskSettings = {
+ enabled: false,
+ shape: "rect",
+ rect: { x: 0, y: 0, width: 1, height: 1 },
+ cornerRadius: 0,
+ feather: 0.2,
+ points: [],
+};
+
+export const DEFAULT_WEBCAM_COLOR: WebcamColorSettings = {
+ brightness: 0,
+ contrast: 0,
+ highlights: 0,
+ shadows: 0,
+ temperature: 0,
+ saturation: 0,
+};
+
export const DEFAULT_WEBCAM_OVERLAY: WebcamOverlaySettings = {
enabled: false,
sourcePath: null,
@@ -202,6 +292,9 @@ export const DEFAULT_WEBCAM_OVERLAY: WebcamOverlaySettings = {
cornerRadius: DEFAULT_WEBCAM_CORNER_RADIUS,
shadow: DEFAULT_WEBCAM_SHADOW,
margin: DEFAULT_WEBCAM_MARGIN,
+ greenscreen: DEFAULT_WEBCAM_GREENSCREEN,
+ mask: DEFAULT_WEBCAM_MASK,
+ color: DEFAULT_WEBCAM_COLOR,
};
export interface TrimRegion {
diff --git a/src/components/video-editor/videoPlayback/layoutUtils.test.ts b/src/components/video-editor/videoPlayback/layoutUtils.test.ts
index 39d21f038..78023c659 100644
--- a/src/components/video-editor/videoPlayback/layoutUtils.test.ts
+++ b/src/components/video-editor/videoPlayback/layoutUtils.test.ts
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
-import { scalePreviewBorderRadius } from "./layoutUtils";
+import {
+ computePaddedLayout,
+ type PaddedLayoutResult,
+ scalePreviewBorderRadius,
+} from "./layoutUtils";
describe("scalePreviewBorderRadius", () => {
it("matches export scaling against the logical preview size", () => {
@@ -13,4 +17,76 @@ describe("scalePreviewBorderRadius", () => {
expect(scalePreviewBorderRadius(960, 0, 16)).toBe(0);
expect(scalePreviewBorderRadius(960, 540, -8)).toBe(0);
});
-});
\ No newline at end of file
+});
+
+describe("computePaddedLayout fillFrameProgress", () => {
+ // 16:10 video inside a 16:9 canvas.
+ const baseParams = {
+ width: 1920,
+ height: 1080,
+ padding: 10,
+ frameInsets: null,
+ cropRegion: { x: 0, y: 0, width: 1, height: 1 },
+ videoWidth: 1920,
+ videoHeight: 1200,
+ };
+
+ it("matches the current framed result exactly at progress 0 (and when omitted)", () => {
+ const framed = computePaddedLayout(baseParams);
+ expect(computePaddedLayout({ ...baseParams, fillFrameProgress: 0 })).toEqual(framed);
+
+ // Framed regression: padding 10 -> 2% per side, contain fit on the height axis.
+ const availableW = 1920 * 0.96;
+ const availableH = 1080 * 0.96;
+ expect(framed.scale).toBeCloseTo(Math.min(availableW / 1920, availableH / 1200), 9);
+ });
+
+ it("covers the canvas at progress 1: scale on the width axis, height overflows", () => {
+ const cover = computePaddedLayout({ ...baseParams, fillFrameProgress: 1 });
+ // cover scale = max(1920/1920, 1080/1200) = 1 -> width axis matches.
+ expect(cover.scale).toBeCloseTo(1, 9);
+ expect(cover.fullVideoDisplayWidth).toBeCloseTo(1920, 9);
+ expect(cover.fullVideoDisplayHeight).toBeCloseTo(1200, 9);
+ // Height overflows the 1080 canvas, centered (cropped top/bottom).
+ expect(cover.centerOffsetY).toBeCloseTo((1080 - 1200) / 2, 9);
+ expect(cover.centerOffsetX).toBeCloseTo(0, 9);
+ // Padding is ignored at full progress.
+ expect(cover).toEqual(
+ computePaddedLayout({ ...baseParams, padding: 0, fillFrameProgress: 1 }),
+ );
+ });
+
+ it("ignores frame insets at progress 1", () => {
+ const withInsets = computePaddedLayout({
+ ...baseParams,
+ frameInsets: { top: 0.1, right: 0.1, bottom: 0.1, left: 0.1 },
+ fillFrameProgress: 1,
+ });
+ expect(withInsets).toEqual(computePaddedLayout({ ...baseParams, fillFrameProgress: 1 }));
+ });
+
+ it("lerps every numeric field strictly between framed and cover at the midpoint", () => {
+ const framed = computePaddedLayout(baseParams);
+ const cover = computePaddedLayout({ ...baseParams, fillFrameProgress: 1 });
+ const mid = computePaddedLayout({ ...baseParams, fillFrameProgress: 0.5 });
+
+ for (const key of Object.keys(framed) as Array) {
+ expect(mid[key]).toBeCloseTo((framed[key] + cover[key]) / 2, 9);
+ if (framed[key] !== cover[key]) {
+ const low = Math.min(framed[key], cover[key]);
+ const high = Math.max(framed[key], cover[key]);
+ expect(mid[key]).toBeGreaterThan(low);
+ expect(mid[key]).toBeLessThan(high);
+ }
+ }
+ });
+
+ it("clamps progress to 0..1", () => {
+ expect(computePaddedLayout({ ...baseParams, fillFrameProgress: -0.5 })).toEqual(
+ computePaddedLayout(baseParams),
+ );
+ expect(computePaddedLayout({ ...baseParams, fillFrameProgress: 1.5 })).toEqual(
+ computePaddedLayout({ ...baseParams, fillFrameProgress: 1 }),
+ );
+ });
+});
diff --git a/src/components/video-editor/videoPlayback/layoutUtils.ts b/src/components/video-editor/videoPlayback/layoutUtils.ts
index 3cce0cd3b..838cb8939 100644
--- a/src/components/video-editor/videoPlayback/layoutUtils.ts
+++ b/src/components/video-editor/videoPlayback/layoutUtils.ts
@@ -6,11 +6,7 @@ export const PADDING_SCALE_FACTOR = 0.2;
export const BASE_PREVIEW_WIDTH = 1920;
export const BASE_PREVIEW_HEIGHT = 1080;
-export function scalePreviewBorderRadius(
- width: number,
- height: number,
- borderRadius = 0,
-): number {
+export function scalePreviewBorderRadius(width: number, height: number, borderRadius = 0): number {
if (width <= 0 || height <= 0) {
return 0;
}
@@ -23,12 +19,7 @@ export function isZeroPadding(padding: Padding | number): boolean {
if (typeof padding === "number") {
return padding === 0;
}
- return (
- padding.top === 0 &&
- padding.bottom === 0 &&
- padding.left === 0 &&
- padding.right === 0
- );
+ return padding.top === 0 && padding.bottom === 0 && padding.left === 0 && padding.right === 0;
}
export interface PaddedLayoutResult {
@@ -47,16 +38,24 @@ export interface PaddedLayoutResult {
cropStartY: number;
}
-export function computePaddedLayout(params: {
+interface LayoutGeometryParams {
width: number;
height: number;
- padding: Padding | number;
- frameInsets?: { top: number; right: number; bottom: number; left: number } | null;
cropRegion: CropRegion;
videoWidth: number;
videoHeight: number;
-}): PaddedLayoutResult {
- const { width, height, padding, frameInsets, cropRegion, videoWidth, videoHeight } = params;
+}
+
+function computeLayoutVariant(
+ params: LayoutGeometryParams,
+ options: {
+ padding: Padding | number;
+ frameInsets: { top: number; right: number; bottom: number; left: number } | null;
+ fit: "contain" | "cover";
+ },
+): PaddedLayoutResult {
+ const { width, height, cropRegion, videoWidth, videoHeight } = params;
+ const { padding, frameInsets, fit } = options;
// Apply asymmetrical padding
const p =
@@ -89,10 +88,9 @@ export function computePaddedLayout(params: {
const fullFrameVideoW = croppedVideoWidth / screenFracW;
const fullFrameVideoH = croppedVideoHeight / screenFracH;
- const scale = Math.min(
- fullFrameVideoW > 0 ? maxDisplayWidth / fullFrameVideoW : 0,
- fullFrameVideoH > 0 ? maxDisplayHeight / fullFrameVideoH : 0,
- );
+ const scaleW = fullFrameVideoW > 0 ? maxDisplayWidth / fullFrameVideoW : 0;
+ const scaleH = fullFrameVideoH > 0 ? maxDisplayHeight / fullFrameVideoH : 0;
+ const scale = fit === "cover" ? Math.max(scaleW, scaleH) : Math.min(scaleW, scaleH);
const fullVideoDisplayWidth = videoWidth * scale;
const fullVideoDisplayHeight = videoHeight * scale;
@@ -108,12 +106,8 @@ export function computePaddedLayout(params: {
const frameCenterX = availableCenterX - fullFrameDisplayW / 2;
const frameCenterY = availableCenterY - fullFrameDisplayH / 2;
- const centerOffsetX = insets
- ? frameCenterX + insets.left * fullFrameDisplayW
- : frameCenterX;
- const centerOffsetY = insets
- ? frameCenterY + insets.top * fullFrameDisplayH
- : frameCenterY;
+ const centerOffsetX = insets ? frameCenterX + insets.left * fullFrameDisplayW : frameCenterX;
+ const centerOffsetY = insets ? frameCenterY + insets.top * fullFrameDisplayH : frameCenterY;
const spriteX = centerOffsetX - crop.x * fullVideoDisplayWidth;
const spriteY = centerOffsetY - crop.y * fullVideoDisplayHeight;
@@ -135,6 +129,60 @@ export function computePaddedLayout(params: {
};
}
+/**
+ * Computes the framed (padded, contain-fit) layout. `fillFrameProgress`
+ * animates toward a cover layout (no padding/insets, video covers the canvas,
+ * overflow cropped): 0 is the framed result, 1 is the cover result, and
+ * in-between values linearly interpolate every field. The caller passes
+ * already-eased progress.
+ */
+export function computePaddedLayout(params: {
+ width: number;
+ height: number;
+ padding: Padding | number;
+ frameInsets?: { top: number; right: number; bottom: number; left: number } | null;
+ cropRegion: CropRegion;
+ videoWidth: number;
+ videoHeight: number;
+ fillFrameProgress?: number;
+}): PaddedLayoutResult {
+ const rawProgress = params.fillFrameProgress;
+ const progress = Number.isFinite(rawProgress)
+ ? Math.min(1, Math.max(0, rawProgress as number))
+ : 0;
+
+ const framed = computeLayoutVariant(params, {
+ padding: params.padding,
+ frameInsets: params.frameInsets ?? null,
+ fit: "contain",
+ });
+ if (progress <= 0) return framed;
+
+ const cover = computeLayoutVariant(params, {
+ padding: 0,
+ frameInsets: null,
+ fit: "cover",
+ });
+ if (progress >= 1) return cover;
+
+ const lerp = (a: number, b: number) => a + (b - a) * progress;
+ return {
+ scale: lerp(framed.scale, cover.scale),
+ centerOffsetX: lerp(framed.centerOffsetX, cover.centerOffsetX),
+ centerOffsetY: lerp(framed.centerOffsetY, cover.centerOffsetY),
+ spriteX: lerp(framed.spriteX, cover.spriteX),
+ spriteY: lerp(framed.spriteY, cover.spriteY),
+ fullFrameDisplayW: lerp(framed.fullFrameDisplayW, cover.fullFrameDisplayW),
+ fullFrameDisplayH: lerp(framed.fullFrameDisplayH, cover.fullFrameDisplayH),
+ fullVideoDisplayWidth: lerp(framed.fullVideoDisplayWidth, cover.fullVideoDisplayWidth),
+ fullVideoDisplayHeight: lerp(framed.fullVideoDisplayHeight, cover.fullVideoDisplayHeight),
+ croppedDisplayWidth: lerp(framed.croppedDisplayWidth, cover.croppedDisplayWidth),
+ croppedDisplayHeight: lerp(framed.croppedDisplayHeight, cover.croppedDisplayHeight),
+ cropStartX: lerp(framed.cropStartX, cover.cropStartX),
+ cropStartY: lerp(framed.cropStartY, cover.cropStartY),
+ };
+}
+
interface LayoutParams {
container: HTMLDivElement;
app: Application;
@@ -147,6 +195,8 @@ interface LayoutParams {
padding?: Padding | number;
/** Screen insets from the active device frame, used to scale/center the full frame */
frameInsets?: { top: number; right: number; bottom: number; left: number } | null;
+ /** Eased 0..1 fill-frame progress; 1 means the video covers the canvas. */
+ fillFrameProgress?: number;
}
interface LayoutResult {
@@ -176,6 +226,7 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
borderRadius = 0,
padding = 0,
frameInsets,
+ fillFrameProgress = 0,
} = params;
const videoWidth = lockedVideoDimensions?.width || videoElement.videoWidth;
@@ -205,6 +256,7 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
cropRegion: crop,
videoWidth,
videoHeight,
+ fillFrameProgress,
});
videoSprite.scale.set(layout.scale);
@@ -216,7 +268,9 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
y: layout.centerOffsetY,
width: layout.croppedDisplayWidth,
height: layout.croppedDisplayHeight,
- radius: scalePreviewBorderRadius(width, height, borderRadius),
+ radius:
+ scalePreviewBorderRadius(width, height, borderRadius) *
+ (1 - Math.min(1, Math.max(0, fillFrameProgress))),
});
maskGraphics.fill({ color: 0xffffff });
diff --git a/src/components/video-editor/videoPlayback/useProcessedWebcamPreview.ts b/src/components/video-editor/videoPlayback/useProcessedWebcamPreview.ts
new file mode 100644
index 000000000..da8e29fb5
--- /dev/null
+++ b/src/components/video-editor/videoPlayback/useProcessedWebcamPreview.ts
@@ -0,0 +1,192 @@
+import { type RefObject, useEffect, useMemo, useRef } from "react";
+import {
+ isProcessingActive,
+ resolveProcessingSettings,
+ WebcamProcessor,
+} from "@/lib/webcamProcessing/webcamProcessor";
+import type { WebcamOverlaySettings } from "../types";
+
+/**
+ * Drives the processed (greenscreen/mask/color) webcam preview canvas in the
+ * editor. The hidden