1614 lines
49 KiB
TypeScript
1614 lines
49 KiB
TypeScript
import type { Range, Span } from "dnd-timeline";
|
|
import { useTimelineContext } from "dnd-timeline";
|
|
import {
|
|
Check,
|
|
ChevronDown,
|
|
Gauge,
|
|
MessageSquare,
|
|
Plus,
|
|
Scissors,
|
|
WandSparkles,
|
|
ZoomIn,
|
|
} from "lucide-react";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { toast } from "sonner";
|
|
import { v4 as uuidv4 } from "uuid";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { useScopedT } from "@/contexts/I18nContext";
|
|
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
|
import { matchesShortcut } from "@/lib/shortcuts";
|
|
import { cn } from "@/lib/utils";
|
|
import { ASPECT_RATIOS, type AspectRatio, getAspectRatioLabel } from "@/utils/aspectRatioUtils";
|
|
import { formatShortcut } from "@/utils/platformUtils";
|
|
import type {
|
|
AnnotationRegion,
|
|
CursorTelemetryPoint,
|
|
SpeedRegion,
|
|
TrimRegion,
|
|
ZoomFocus,
|
|
ZoomRegion,
|
|
} from "../types";
|
|
import Item from "./Item";
|
|
import KeyframeMarkers from "./KeyframeMarkers";
|
|
import Row from "./Row";
|
|
import TimelineWrapper from "./TimelineWrapper";
|
|
import { detectZoomDwellCandidates, normalizeCursorTelemetry } from "./zoomSuggestionUtils";
|
|
|
|
const ZOOM_ROW_ID = "row-zoom";
|
|
const TRIM_ROW_ID = "row-trim";
|
|
const ANNOTATION_ROW_ID = "row-annotation";
|
|
const BLUR_ROW_ID = "row-blur";
|
|
const SPEED_ROW_ID = "row-speed";
|
|
const FALLBACK_RANGE_MS = 1000;
|
|
const TARGET_MARKER_COUNT = 12;
|
|
const SUGGESTION_SPACING_MS = 1800;
|
|
|
|
interface TimelineEditorProps {
|
|
videoDuration: number;
|
|
hasVideoSource?: boolean;
|
|
currentTime: number;
|
|
onSeek?: (time: number) => void;
|
|
cursorTelemetry?: CursorTelemetryPoint[];
|
|
zoomRegions: ZoomRegion[];
|
|
onZoomAdded: (span: Span) => void;
|
|
onZoomSuggested?: (span: Span, focus: ZoomFocus) => void;
|
|
onZoomSpanChange: (id: string, span: Span) => void;
|
|
onZoomDelete: (id: string) => void;
|
|
selectedZoomId: string | null;
|
|
onSelectZoom: (id: string | null) => void;
|
|
trimRegions?: TrimRegion[];
|
|
onTrimAdded?: (span: Span) => void;
|
|
onTrimSpanChange?: (id: string, span: Span) => void;
|
|
onTrimDelete?: (id: string) => void;
|
|
selectedTrimId?: string | null;
|
|
onSelectTrim?: (id: string | null) => void;
|
|
annotationRegions?: AnnotationRegion[];
|
|
onAnnotationAdded?: (span: Span) => void;
|
|
onAnnotationSpanChange?: (id: string, span: Span) => void;
|
|
onAnnotationDelete?: (id: string) => void;
|
|
selectedAnnotationId?: string | null;
|
|
onSelectAnnotation?: (id: string | null) => void;
|
|
blurRegions?: AnnotationRegion[];
|
|
onBlurAdded?: (span: Span) => void;
|
|
onBlurSpanChange?: (id: string, span: Span) => void;
|
|
onBlurDelete?: (id: string) => void;
|
|
selectedBlurId?: string | null;
|
|
onSelectBlur?: (id: string | null) => void;
|
|
speedRegions?: SpeedRegion[];
|
|
onSpeedAdded?: (span: Span) => void;
|
|
onSpeedSpanChange?: (id: string, span: Span) => void;
|
|
onSpeedDelete?: (id: string) => void;
|
|
selectedSpeedId?: string | null;
|
|
onSelectSpeed?: (id: string | null) => void;
|
|
aspectRatio: AspectRatio;
|
|
onAspectRatioChange: (aspectRatio: AspectRatio) => void;
|
|
}
|
|
|
|
interface TimelineScaleConfig {
|
|
minItemDurationMs: number;
|
|
defaultItemDurationMs: number;
|
|
minVisibleRangeMs: number;
|
|
}
|
|
|
|
interface TimelineRenderItem {
|
|
id: string;
|
|
rowId: string;
|
|
span: Span;
|
|
label: string;
|
|
zoomDepth?: number;
|
|
zoomCustomScale?: number;
|
|
speedValue?: number;
|
|
isAutoFocus?: boolean;
|
|
variant: "zoom" | "trim" | "annotation" | "speed" | "blur";
|
|
}
|
|
|
|
const SCALE_CANDIDATES = [
|
|
{ intervalSeconds: 0.05, gridSeconds: 0.01 },
|
|
{ intervalSeconds: 0.1, gridSeconds: 0.02 },
|
|
{ intervalSeconds: 0.25, gridSeconds: 0.05 },
|
|
{ intervalSeconds: 0.5, gridSeconds: 0.1 },
|
|
{ intervalSeconds: 1, gridSeconds: 0.25 },
|
|
{ intervalSeconds: 2, gridSeconds: 0.5 },
|
|
{ intervalSeconds: 5, gridSeconds: 1 },
|
|
{ intervalSeconds: 10, gridSeconds: 2 },
|
|
{ intervalSeconds: 15, gridSeconds: 3 },
|
|
{ intervalSeconds: 30, gridSeconds: 5 },
|
|
{ intervalSeconds: 60, gridSeconds: 10 },
|
|
{ intervalSeconds: 120, gridSeconds: 20 },
|
|
{ intervalSeconds: 300, gridSeconds: 30 },
|
|
{ intervalSeconds: 600, gridSeconds: 60 },
|
|
{ intervalSeconds: 900, gridSeconds: 120 },
|
|
{ intervalSeconds: 1800, gridSeconds: 180 },
|
|
{ intervalSeconds: 3600, gridSeconds: 300 },
|
|
];
|
|
|
|
/**
|
|
* Picks the best axis interval for the currently visible time range.
|
|
* Called dynamically — re-runs on every zoom change so the axis always
|
|
* shows a meaningful density of markers regardless of video length.
|
|
*/
|
|
function calculateAxisScale(visibleRangeMs: number): { intervalMs: number; gridMs: number } {
|
|
const visibleSeconds = visibleRangeMs / 1000;
|
|
const candidate =
|
|
SCALE_CANDIDATES.find((c) => {
|
|
if (visibleSeconds <= 0) return true;
|
|
return visibleSeconds / c.intervalSeconds <= TARGET_MARKER_COUNT;
|
|
}) ?? SCALE_CANDIDATES[SCALE_CANDIDATES.length - 1];
|
|
return {
|
|
intervalMs: Math.round(candidate.intervalSeconds * 1000),
|
|
gridMs: Math.round(candidate.gridSeconds * 1000),
|
|
};
|
|
}
|
|
|
|
function calculateTimelineScale(durationSeconds: number): TimelineScaleConfig {
|
|
const totalMs = Math.max(0, Math.round(durationSeconds * 1000));
|
|
|
|
// Minimum item duration: fixed at 100ms (0.1s).
|
|
// Allows precise cuts while remaining interactive.
|
|
const minItemDurationMs = 100;
|
|
|
|
// Default placement size: 5% of video duration, clamped between 1s and 30s.
|
|
const defaultItemDurationMs =
|
|
totalMs > 0
|
|
? Math.max(minItemDurationMs, Math.min(Math.round(totalMs * 0.05), 30000))
|
|
: Math.max(minItemDurationMs, 1000);
|
|
|
|
// Minimum visible range: 300ms — allows comfortably viewing 0.1s items.
|
|
// Axis markers adapt dynamically via calculateAxisScale, so there is no
|
|
// upper constraint on how far the user can zoom in.
|
|
const minVisibleRangeMs = 300;
|
|
|
|
return {
|
|
minItemDurationMs,
|
|
defaultItemDurationMs,
|
|
minVisibleRangeMs,
|
|
};
|
|
}
|
|
|
|
function createInitialRange(totalMs: number): Range {
|
|
if (totalMs > 0) {
|
|
return { start: 0, end: totalMs };
|
|
}
|
|
|
|
return { start: 0, end: FALLBACK_RANGE_MS };
|
|
}
|
|
|
|
function clampVisibleRange(candidate: Range, totalMs: number): Range {
|
|
if (totalMs <= 0) {
|
|
return candidate;
|
|
}
|
|
|
|
const span = Math.max(candidate.end - candidate.start, 1);
|
|
|
|
if (span >= totalMs) {
|
|
return { start: 0, end: totalMs };
|
|
}
|
|
|
|
const start = Math.max(0, Math.min(candidate.start, totalMs - span));
|
|
return { start, end: start + span };
|
|
}
|
|
|
|
function normalizeWheelDelta(delta: number, deltaMode: number, pageSizePx: number): number {
|
|
if (deltaMode === WheelEvent.DOM_DELTA_LINE) {
|
|
return delta * 16;
|
|
}
|
|
|
|
if (deltaMode === WheelEvent.DOM_DELTA_PAGE) {
|
|
return delta * pageSizePx;
|
|
}
|
|
|
|
return delta;
|
|
}
|
|
|
|
function formatTimeLabel(milliseconds: number, intervalMs: number) {
|
|
const totalSeconds = milliseconds / 1000;
|
|
const hours = Math.floor(totalSeconds / 3600);
|
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
const seconds = totalSeconds % 60;
|
|
|
|
const fractionalDigits = intervalMs < 250 ? 2 : intervalMs < 1000 ? 1 : 0;
|
|
|
|
if (hours > 0) {
|
|
const minutesString = minutes.toString().padStart(2, "0");
|
|
const secondsString = Math.floor(seconds).toString().padStart(2, "0");
|
|
return `${hours}:${minutesString}:${secondsString}`;
|
|
}
|
|
|
|
if (fractionalDigits > 0) {
|
|
const secondsWithFraction = seconds.toFixed(fractionalDigits);
|
|
const [wholeSeconds, fraction] = secondsWithFraction.split(".");
|
|
return `${minutes}:${wholeSeconds.padStart(2, "0")}.${fraction}`;
|
|
}
|
|
|
|
return `${minutes}:${Math.floor(seconds).toString().padStart(2, "0")}`;
|
|
}
|
|
|
|
function formatPlayheadTime(ms: number): string {
|
|
const s = ms / 1000;
|
|
const min = Math.floor(s / 60);
|
|
const sec = s % 60;
|
|
if (min > 0) return `${min}:${sec.toFixed(1).padStart(4, "0")}`;
|
|
return `${sec.toFixed(1)}s`;
|
|
}
|
|
|
|
function PlaybackCursor({
|
|
currentTimeMs,
|
|
videoDurationMs,
|
|
onSeek,
|
|
onRangeChange,
|
|
timelineRef,
|
|
keyframes = [],
|
|
}: {
|
|
currentTimeMs: number;
|
|
videoDurationMs: number;
|
|
onSeek?: (time: number) => void;
|
|
onRangeChange?: (updater: (previous: Range) => Range) => void;
|
|
timelineRef: React.RefObject<HTMLDivElement>;
|
|
keyframes?: { id: string; time: number }[];
|
|
}) {
|
|
const { sidebarWidth, direction, range, valueToPixels, pixelsToValue } = useTimelineContext();
|
|
const sideProperty = direction === "rtl" ? "right" : "left";
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const [dragPreviewTimeMs, setDragPreviewTimeMs] = useState<number | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!isDragging) return;
|
|
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
if (!timelineRef.current || !onSeek) return;
|
|
|
|
const rect = timelineRef.current.getBoundingClientRect();
|
|
const clickX = e.clientX - rect.left - sidebarWidth;
|
|
const contentWidth = Math.max(rect.width - sidebarWidth, 1);
|
|
|
|
// Allow dragging outside to 0 or max, but clamp the value
|
|
const relativeMs = pixelsToValue(clickX);
|
|
let absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs));
|
|
|
|
// Snap to nearby keyframe if within threshold (150ms)
|
|
const snapThresholdMs = 150;
|
|
const nearbyKeyframe = keyframes.find(
|
|
(kf) =>
|
|
Math.abs(kf.time - absoluteMs) <= snapThresholdMs &&
|
|
kf.time >= range.start &&
|
|
kf.time <= range.end,
|
|
);
|
|
|
|
if (nearbyKeyframe) {
|
|
absoluteMs = nearbyKeyframe.time;
|
|
}
|
|
|
|
setDragPreviewTimeMs(absoluteMs);
|
|
|
|
const visibleMs = range.end - range.start;
|
|
if (onRangeChange && visibleMs > 0 && videoDurationMs > visibleMs) {
|
|
const msPerPixel = visibleMs / contentWidth;
|
|
const overflowLeftPx = Math.max(0, -clickX);
|
|
const overflowRightPx = Math.max(0, clickX - contentWidth);
|
|
|
|
if (overflowLeftPx > 0 && range.start > 0) {
|
|
const shiftMs = overflowLeftPx * msPerPixel;
|
|
onRangeChange((previous) => {
|
|
const nextRange = clampVisibleRange(
|
|
{
|
|
start: previous.start - shiftMs,
|
|
end: previous.end - shiftMs,
|
|
},
|
|
videoDurationMs,
|
|
);
|
|
return nextRange.start === previous.start && nextRange.end === previous.end
|
|
? previous
|
|
: nextRange;
|
|
});
|
|
} else if (overflowRightPx > 0 && range.end < videoDurationMs) {
|
|
const shiftMs = overflowRightPx * msPerPixel;
|
|
onRangeChange((previous) => {
|
|
const nextRange = clampVisibleRange(
|
|
{
|
|
start: previous.start + shiftMs,
|
|
end: previous.end + shiftMs,
|
|
},
|
|
videoDurationMs,
|
|
);
|
|
return nextRange.start === previous.start && nextRange.end === previous.end
|
|
? previous
|
|
: nextRange;
|
|
});
|
|
}
|
|
}
|
|
|
|
onSeek(absoluteMs / 1000);
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
setIsDragging(false);
|
|
setDragPreviewTimeMs(null);
|
|
document.body.style.cursor = "";
|
|
};
|
|
|
|
window.addEventListener("mousemove", handleMouseMove);
|
|
window.addEventListener("mouseup", handleMouseUp);
|
|
document.body.style.cursor = "ew-resize";
|
|
|
|
return () => {
|
|
window.removeEventListener("mousemove", handleMouseMove);
|
|
window.removeEventListener("mouseup", handleMouseUp);
|
|
document.body.style.cursor = "";
|
|
};
|
|
}, [
|
|
isDragging,
|
|
onSeek,
|
|
onRangeChange,
|
|
timelineRef,
|
|
sidebarWidth,
|
|
range.start,
|
|
range.end,
|
|
videoDurationMs,
|
|
pixelsToValue,
|
|
keyframes,
|
|
]);
|
|
|
|
const displayTimeMs =
|
|
isDragging && dragPreviewTimeMs !== null ? dragPreviewTimeMs : currentTimeMs;
|
|
|
|
if (videoDurationMs <= 0 || displayTimeMs < 0) {
|
|
return null;
|
|
}
|
|
|
|
const clampedTime = Math.min(displayTimeMs, videoDurationMs);
|
|
|
|
if (clampedTime < range.start || clampedTime > range.end) {
|
|
return null;
|
|
}
|
|
|
|
const offset = valueToPixels(clampedTime - range.start);
|
|
|
|
return (
|
|
<div
|
|
className="absolute top-0 bottom-0 z-50 group/cursor"
|
|
style={{
|
|
[sideProperty === "right" ? "marginRight" : "marginLeft"]: `${sidebarWidth - 1}px`,
|
|
pointerEvents: "none", // Allow clicks to pass through to timeline, but we'll enable pointer events on the handle
|
|
}}
|
|
>
|
|
<div
|
|
className="absolute top-0 bottom-0 w-[2px] bg-[#6C55FF] shadow-[0_0_18px_rgba(108,85,255,0.68)] cursor-ew-resize pointer-events-auto hover:shadow-[0_0_24px_rgba(108,85,255,0.85)] transition-shadow"
|
|
style={{
|
|
[sideProperty]: `${offset}px`,
|
|
}}
|
|
onMouseDown={(e) => {
|
|
e.stopPropagation(); // Prevent timeline click
|
|
setDragPreviewTimeMs(currentTimeMs);
|
|
setIsDragging(true);
|
|
}}
|
|
>
|
|
<div
|
|
className="absolute -top-2 left-1/2 -translate-x-1/2 hover:scale-110 transition-transform"
|
|
style={{ width: "20px", height: "20px" }}
|
|
>
|
|
<div className="w-4 h-4 mx-auto mt-[2px] bg-[#6C55FF] rotate-45 rounded-[5px] shadow-lg shadow-[#6C55FF]/30 border border-white/30" />
|
|
</div>
|
|
{isDragging && (
|
|
<div className="absolute -top-6 left-1/2 -translate-x-1/2 px-1.5 py-0.5 rounded bg-black/80 text-[10px] text-white/90 font-medium tabular-nums whitespace-nowrap border border-white/10 shadow-lg pointer-events-none">
|
|
{formatPlayheadTime(clampedTime)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TimelineAxis({
|
|
videoDurationMs,
|
|
currentTimeMs,
|
|
}: {
|
|
videoDurationMs: number;
|
|
currentTimeMs: number;
|
|
}) {
|
|
const { sidebarWidth, direction, range, valueToPixels } = useTimelineContext();
|
|
const sideProperty = direction === "rtl" ? "right" : "left";
|
|
|
|
// Recompute axis scale dynamically on every zoom change.
|
|
const { intervalMs } = useMemo(
|
|
() => calculateAxisScale(range.end - range.start),
|
|
[range.end, range.start],
|
|
);
|
|
|
|
const markers = useMemo(() => {
|
|
if (intervalMs <= 0) {
|
|
return { markers: [], minorTicks: [] };
|
|
}
|
|
|
|
const maxTime = videoDurationMs > 0 ? videoDurationMs : range.end;
|
|
const visibleStart = Math.max(0, Math.min(range.start, maxTime));
|
|
const visibleEnd = Math.min(range.end, maxTime);
|
|
const markerTimes = new Set<number>();
|
|
|
|
const firstMarker = Math.ceil(visibleStart / intervalMs) * intervalMs;
|
|
|
|
for (let time = firstMarker; time <= maxTime; time += intervalMs) {
|
|
if (time >= visibleStart && time <= visibleEnd) {
|
|
markerTimes.add(Math.round(time));
|
|
}
|
|
}
|
|
|
|
if (visibleStart <= maxTime) {
|
|
markerTimes.add(Math.round(visibleStart));
|
|
}
|
|
|
|
if (videoDurationMs > 0) {
|
|
markerTimes.add(Math.round(videoDurationMs));
|
|
}
|
|
|
|
const sorted = Array.from(markerTimes)
|
|
.filter((time) => time <= maxTime)
|
|
.sort((a, b) => a - b);
|
|
|
|
// Generate minor ticks (4 ticks between major intervals)
|
|
const minorTicks = [];
|
|
const minorInterval = intervalMs / 5;
|
|
|
|
for (let time = firstMarker; time <= maxTime; time += minorInterval) {
|
|
if (time >= visibleStart && time <= visibleEnd) {
|
|
// Skip if it's close to a major marker
|
|
const isMajor = Math.abs(time % intervalMs) < 1;
|
|
if (!isMajor) {
|
|
minorTicks.push(time);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
markers: sorted.map((time) => ({
|
|
time,
|
|
label: formatTimeLabel(time, intervalMs),
|
|
})),
|
|
minorTicks,
|
|
};
|
|
}, [intervalMs, range.end, range.start, videoDurationMs]);
|
|
|
|
return (
|
|
<div
|
|
className="h-9 bg-[#0c0d10] border-b border-white/[0.07] relative overflow-hidden select-none"
|
|
style={{
|
|
[sideProperty === "right" ? "marginRight" : "marginLeft"]: `${sidebarWidth}px`,
|
|
}}
|
|
>
|
|
{/* Minor Ticks */}
|
|
{markers.minorTicks.map((time) => {
|
|
const offset = valueToPixels(time - range.start);
|
|
return (
|
|
<div
|
|
key={`minor-${time}`}
|
|
className="absolute bottom-0 h-1.5 w-[1px] bg-white/[0.07]"
|
|
style={{ [sideProperty]: `${offset}px` }}
|
|
/>
|
|
);
|
|
})}
|
|
|
|
{/* Major Markers */}
|
|
{markers.markers.map((marker) => {
|
|
const offset = valueToPixels(marker.time - range.start);
|
|
const markerStyle: React.CSSProperties = {
|
|
position: "absolute",
|
|
bottom: 0,
|
|
height: "100%",
|
|
display: "flex",
|
|
flexDirection: "row",
|
|
alignItems: "flex-end",
|
|
[sideProperty]: `${offset}px`,
|
|
};
|
|
|
|
return (
|
|
<div key={marker.time} style={markerStyle}>
|
|
<div className="flex flex-col items-center pb-1">
|
|
<div className="h-2.5 w-[1px] bg-white/20 mb-1" />
|
|
<span
|
|
className={cn(
|
|
"text-[10px] font-medium tabular-nums tracking-tight",
|
|
marker.time === currentTimeMs ? "text-[#34B27B]" : "text-slate-500",
|
|
)}
|
|
>
|
|
{marker.label}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Timeline({
|
|
items,
|
|
videoDurationMs,
|
|
currentTimeMs,
|
|
onSeek,
|
|
onRangeChange,
|
|
onSelectZoom,
|
|
onSelectTrim,
|
|
onSelectAnnotation,
|
|
onSelectBlur,
|
|
onSelectSpeed,
|
|
selectedZoomId,
|
|
selectedTrimId,
|
|
selectedAnnotationId,
|
|
selectedBlurId,
|
|
selectedSpeedId,
|
|
keyframes = [],
|
|
}: {
|
|
items: TimelineRenderItem[];
|
|
videoDurationMs: number;
|
|
currentTimeMs: number;
|
|
onSeek?: (time: number) => void;
|
|
onRangeChange?: (updater: (previous: Range) => Range) => void;
|
|
onSelectZoom?: (id: string | null) => void;
|
|
onSelectTrim?: (id: string | null) => void;
|
|
onSelectAnnotation?: (id: string | null) => void;
|
|
onSelectBlur?: (id: string | null) => void;
|
|
onSelectSpeed?: (id: string | null) => void;
|
|
selectedZoomId: string | null;
|
|
selectedTrimId?: string | null;
|
|
selectedAnnotationId?: string | null;
|
|
selectedBlurId?: string | null;
|
|
selectedSpeedId?: string | null;
|
|
keyframes?: { id: string; time: number }[];
|
|
}) {
|
|
const t = useScopedT("timeline");
|
|
const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext();
|
|
const localTimelineRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
const setRefs = useCallback(
|
|
(node: HTMLDivElement | null) => {
|
|
setTimelineRef(node);
|
|
localTimelineRef.current = node;
|
|
},
|
|
[setTimelineRef],
|
|
);
|
|
|
|
const handleTimelineClick = useCallback(
|
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
if (!onSeek || videoDurationMs <= 0) return;
|
|
|
|
// Only clear selection if clicking on empty space (not on items)
|
|
// This is handled by event propagation - items stop propagation
|
|
onSelectZoom?.(null);
|
|
onSelectTrim?.(null);
|
|
onSelectAnnotation?.(null);
|
|
onSelectBlur?.(null);
|
|
onSelectSpeed?.(null);
|
|
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const clickX = e.clientX - rect.left - sidebarWidth;
|
|
|
|
if (clickX < 0) return;
|
|
|
|
const relativeMs = pixelsToValue(clickX);
|
|
const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs));
|
|
const timeInSeconds = absoluteMs / 1000;
|
|
|
|
onSeek(timeInSeconds);
|
|
},
|
|
[
|
|
onSeek,
|
|
onSelectZoom,
|
|
onSelectTrim,
|
|
onSelectAnnotation,
|
|
onSelectBlur,
|
|
onSelectSpeed,
|
|
videoDurationMs,
|
|
sidebarWidth,
|
|
range.start,
|
|
pixelsToValue,
|
|
],
|
|
);
|
|
|
|
const handleTimelineWheel = useCallback(
|
|
(event: React.WheelEvent<HTMLDivElement>) => {
|
|
if (!onRangeChange || event.ctrlKey || event.metaKey || videoDurationMs <= 0) {
|
|
return;
|
|
}
|
|
|
|
const visibleMs = range.end - range.start;
|
|
if (visibleMs <= 0 || videoDurationMs <= visibleMs) {
|
|
return;
|
|
}
|
|
|
|
const dominantDelta =
|
|
Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
|
|
if (dominantDelta === 0) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
|
|
const pageWidthPx = Math.max(event.currentTarget.clientWidth - sidebarWidth, 1);
|
|
const normalizedDeltaPx = normalizeWheelDelta(dominantDelta, event.deltaMode, pageWidthPx);
|
|
const shiftMs = pixelsToValue(normalizedDeltaPx);
|
|
|
|
onRangeChange((previous) => {
|
|
const nextRange = clampVisibleRange(
|
|
{
|
|
start: previous.start + shiftMs,
|
|
end: previous.end + shiftMs,
|
|
},
|
|
videoDurationMs,
|
|
);
|
|
|
|
return nextRange.start === previous.start && nextRange.end === previous.end
|
|
? previous
|
|
: nextRange;
|
|
});
|
|
},
|
|
[onRangeChange, videoDurationMs, range.end, range.start, sidebarWidth, pixelsToValue],
|
|
);
|
|
|
|
const zoomItems = items.filter((item) => item.rowId === ZOOM_ROW_ID);
|
|
const trimItems = items.filter((item) => item.rowId === TRIM_ROW_ID);
|
|
const annotationItems = items.filter((item) => item.rowId === ANNOTATION_ROW_ID);
|
|
const blurItems = items.filter((item) => item.rowId === BLUR_ROW_ID);
|
|
const speedItems = items.filter((item) => item.rowId === SPEED_ROW_ID);
|
|
|
|
return (
|
|
<div
|
|
ref={setRefs}
|
|
style={style}
|
|
className="select-none bg-[#0b0c0f] min-h-[190px] relative cursor-pointer group"
|
|
onClick={handleTimelineClick}
|
|
onWheel={handleTimelineWheel}
|
|
>
|
|
<div className="absolute inset-0 bg-[linear-gradient(to_right,#ffffff05_1px,transparent_1px)] bg-[length:24px_100%] pointer-events-none" />
|
|
<TimelineAxis videoDurationMs={videoDurationMs} currentTimeMs={currentTimeMs} />
|
|
<PlaybackCursor
|
|
currentTimeMs={currentTimeMs}
|
|
videoDurationMs={videoDurationMs}
|
|
onSeek={onSeek}
|
|
onRangeChange={onRangeChange}
|
|
timelineRef={localTimelineRef}
|
|
keyframes={keyframes}
|
|
/>
|
|
|
|
<Row id={ZOOM_ROW_ID} isEmpty={zoomItems.length === 0} hint={t("hints.pressZoom")}>
|
|
{zoomItems.map((item) => (
|
|
<Item
|
|
id={item.id}
|
|
key={item.id}
|
|
rowId={item.rowId}
|
|
span={item.span}
|
|
isSelected={item.id === selectedZoomId}
|
|
onSelect={() => onSelectZoom?.(item.id)}
|
|
zoomDepth={item.zoomDepth}
|
|
zoomCustomScale={item.zoomCustomScale}
|
|
isAutoFocus={item.isAutoFocus}
|
|
variant="zoom"
|
|
>
|
|
{item.label}
|
|
</Item>
|
|
))}
|
|
</Row>
|
|
|
|
<Row id={TRIM_ROW_ID} isEmpty={trimItems.length === 0} hint={t("hints.pressTrim")}>
|
|
{trimItems.map((item) => (
|
|
<Item
|
|
id={item.id}
|
|
key={item.id}
|
|
rowId={item.rowId}
|
|
span={item.span}
|
|
isSelected={item.id === selectedTrimId}
|
|
onSelect={() => onSelectTrim?.(item.id)}
|
|
variant="trim"
|
|
>
|
|
{item.label}
|
|
</Item>
|
|
))}
|
|
</Row>
|
|
|
|
<Row
|
|
id={ANNOTATION_ROW_ID}
|
|
isEmpty={annotationItems.length === 0}
|
|
hint={t("hints.pressAnnotation")}
|
|
>
|
|
{annotationItems.map((item) => (
|
|
<Item
|
|
id={item.id}
|
|
key={item.id}
|
|
rowId={item.rowId}
|
|
span={item.span}
|
|
isSelected={item.id === selectedAnnotationId}
|
|
onSelect={() => onSelectAnnotation?.(item.id)}
|
|
variant="annotation"
|
|
>
|
|
{item.label}
|
|
</Item>
|
|
))}
|
|
</Row>
|
|
|
|
<Row id={BLUR_ROW_ID} isEmpty={blurItems.length === 0} hint={t("hints.pressBlur")}>
|
|
{blurItems.map((item) => (
|
|
<Item
|
|
id={item.id}
|
|
key={item.id}
|
|
rowId={item.rowId}
|
|
span={item.span}
|
|
isSelected={item.id === selectedBlurId}
|
|
onSelect={() => onSelectBlur?.(item.id)}
|
|
variant={item.variant}
|
|
>
|
|
{item.label}
|
|
</Item>
|
|
))}
|
|
</Row>
|
|
|
|
<Row id={SPEED_ROW_ID} isEmpty={speedItems.length === 0} hint={t("hints.pressSpeed")}>
|
|
{speedItems.map((item) => (
|
|
<Item
|
|
id={item.id}
|
|
key={item.id}
|
|
rowId={item.rowId}
|
|
span={item.span}
|
|
isSelected={item.id === selectedSpeedId}
|
|
onSelect={() => onSelectSpeed?.(item.id)}
|
|
variant="speed"
|
|
speedValue={item.speedValue}
|
|
>
|
|
{item.label}
|
|
</Item>
|
|
))}
|
|
</Row>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function TimelineEditor({
|
|
videoDuration,
|
|
hasVideoSource = false,
|
|
currentTime,
|
|
onSeek,
|
|
cursorTelemetry = [],
|
|
zoomRegions,
|
|
onZoomAdded,
|
|
onZoomSuggested,
|
|
onZoomSpanChange,
|
|
onZoomDelete,
|
|
selectedZoomId,
|
|
onSelectZoom,
|
|
trimRegions = [],
|
|
onTrimAdded,
|
|
onTrimSpanChange,
|
|
onTrimDelete,
|
|
selectedTrimId,
|
|
onSelectTrim,
|
|
annotationRegions = [],
|
|
onAnnotationAdded,
|
|
onAnnotationSpanChange,
|
|
onAnnotationDelete,
|
|
selectedAnnotationId,
|
|
onSelectAnnotation,
|
|
blurRegions = [],
|
|
onBlurAdded,
|
|
onBlurSpanChange,
|
|
onBlurDelete,
|
|
selectedBlurId,
|
|
onSelectBlur,
|
|
speedRegions = [],
|
|
onSpeedAdded,
|
|
onSpeedSpanChange,
|
|
onSpeedDelete,
|
|
selectedSpeedId,
|
|
onSelectSpeed,
|
|
aspectRatio,
|
|
onAspectRatioChange,
|
|
}: TimelineEditorProps) {
|
|
const t = useScopedT("timeline");
|
|
const totalMs = useMemo(() => Math.max(0, Math.round(videoDuration * 1000)), [videoDuration]);
|
|
const currentTimeMs = useMemo(() => Math.round(currentTime * 1000), [currentTime]);
|
|
const timelineScale = useMemo(() => calculateTimelineScale(videoDuration), [videoDuration]);
|
|
const safeMinDurationMs = useMemo(
|
|
() =>
|
|
totalMs > 0
|
|
? Math.min(timelineScale.minItemDurationMs, totalMs)
|
|
: timelineScale.minItemDurationMs,
|
|
[timelineScale.minItemDurationMs, totalMs],
|
|
);
|
|
|
|
const [range, setRange] = useState<Range>(() => createInitialRange(totalMs));
|
|
const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]);
|
|
const [selectedKeyframeId, setSelectedKeyframeId] = useState<string | null>(null);
|
|
const [scrollLabels, setScrollLabels] = useState({
|
|
pan: "Scroll",
|
|
zoom: "Ctrl + Scroll",
|
|
});
|
|
const timelineContainerRef = useRef<HTMLDivElement>(null);
|
|
const { shortcuts: keyShortcuts, isMac } = useShortcuts();
|
|
|
|
useEffect(() => {
|
|
formatShortcut(["mod", "Scroll"]).then((zoom) => {
|
|
setScrollLabels({ pan: "Scroll", zoom });
|
|
});
|
|
}, []);
|
|
|
|
// Add keyframe at current playhead position
|
|
const addKeyframe = useCallback(() => {
|
|
if (totalMs === 0) return;
|
|
const time = Math.max(0, Math.min(currentTimeMs, totalMs));
|
|
if (keyframes.some((kf) => Math.abs(kf.time - time) < 1)) return;
|
|
setKeyframes((prev) => [...prev, { id: uuidv4(), time }]);
|
|
}, [currentTimeMs, totalMs, keyframes]);
|
|
|
|
// Delete selected keyframe
|
|
const deleteSelectedKeyframe = useCallback(() => {
|
|
if (!selectedKeyframeId) return;
|
|
setKeyframes((prev) => prev.filter((kf) => kf.id !== selectedKeyframeId));
|
|
setSelectedKeyframeId(null);
|
|
}, [selectedKeyframeId]);
|
|
|
|
// Move keyframe to new time position
|
|
const handleKeyframeMove = useCallback(
|
|
(id: string, newTime: number) => {
|
|
setKeyframes((prev) =>
|
|
prev.map((kf) =>
|
|
kf.id === id ? { ...kf, time: Math.max(0, Math.min(newTime, totalMs)) } : kf,
|
|
),
|
|
);
|
|
},
|
|
[totalMs],
|
|
);
|
|
|
|
// Delete selected zoom item
|
|
const deleteSelectedZoom = useCallback(() => {
|
|
if (!selectedZoomId) return;
|
|
onZoomDelete(selectedZoomId);
|
|
onSelectZoom(null);
|
|
}, [selectedZoomId, onZoomDelete, onSelectZoom]);
|
|
|
|
// Delete selected trim item
|
|
const deleteSelectedTrim = useCallback(() => {
|
|
if (!selectedTrimId || !onTrimDelete || !onSelectTrim) return;
|
|
onTrimDelete(selectedTrimId);
|
|
onSelectTrim(null);
|
|
}, [selectedTrimId, onTrimDelete, onSelectTrim]);
|
|
|
|
const deleteSelectedAnnotation = useCallback(() => {
|
|
if (!selectedAnnotationId || !onAnnotationDelete || !onSelectAnnotation) return;
|
|
onAnnotationDelete(selectedAnnotationId);
|
|
onSelectAnnotation(null);
|
|
}, [selectedAnnotationId, onAnnotationDelete, onSelectAnnotation]);
|
|
|
|
const deleteSelectedBlur = useCallback(() => {
|
|
if (!selectedBlurId || !onBlurDelete || !onSelectBlur) return;
|
|
onBlurDelete(selectedBlurId);
|
|
onSelectBlur(null);
|
|
}, [selectedBlurId, onBlurDelete, onSelectBlur]);
|
|
|
|
const deleteSelectedSpeed = useCallback(() => {
|
|
if (!selectedSpeedId || !onSpeedDelete || !onSelectSpeed) return;
|
|
onSpeedDelete(selectedSpeedId);
|
|
onSelectSpeed(null);
|
|
}, [selectedSpeedId, onSpeedDelete, onSelectSpeed]);
|
|
|
|
useEffect(() => {
|
|
setRange(createInitialRange(totalMs));
|
|
}, [totalMs]);
|
|
|
|
// Normalize regions only when timeline bounds change (not on every region edit).
|
|
// Using refs to read current regions avoids a dependency-loop that re-fires
|
|
// this effect on every drag/resize and races with dnd-timeline's internal state.
|
|
const zoomRegionsRef = useRef(zoomRegions);
|
|
const trimRegionsRef = useRef(trimRegions);
|
|
const speedRegionsRef = useRef(speedRegions);
|
|
zoomRegionsRef.current = zoomRegions;
|
|
trimRegionsRef.current = trimRegions;
|
|
speedRegionsRef.current = speedRegions;
|
|
|
|
useEffect(() => {
|
|
if (totalMs === 0 || safeMinDurationMs <= 0) {
|
|
return;
|
|
}
|
|
|
|
zoomRegionsRef.current.forEach((region) => {
|
|
const clampedStart = Math.max(0, Math.min(region.startMs, totalMs));
|
|
const minEnd = clampedStart + safeMinDurationMs;
|
|
const clampedEnd = Math.min(totalMs, Math.max(minEnd, region.endMs));
|
|
const normalizedStart = Math.max(0, Math.min(clampedStart, totalMs - safeMinDurationMs));
|
|
const normalizedEnd = Math.max(minEnd, Math.min(clampedEnd, totalMs));
|
|
|
|
if (normalizedStart !== region.startMs || normalizedEnd !== region.endMs) {
|
|
onZoomSpanChange(region.id, { start: normalizedStart, end: normalizedEnd });
|
|
}
|
|
});
|
|
|
|
trimRegionsRef.current.forEach((region) => {
|
|
const clampedStart = Math.max(0, Math.min(region.startMs, totalMs));
|
|
const minEnd = clampedStart + safeMinDurationMs;
|
|
const clampedEnd = Math.min(totalMs, Math.max(minEnd, region.endMs));
|
|
const normalizedStart = Math.max(0, Math.min(clampedStart, totalMs - safeMinDurationMs));
|
|
const normalizedEnd = Math.max(minEnd, Math.min(clampedEnd, totalMs));
|
|
|
|
if (normalizedStart !== region.startMs || normalizedEnd !== region.endMs) {
|
|
onTrimSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd });
|
|
}
|
|
});
|
|
|
|
speedRegionsRef.current.forEach((region) => {
|
|
const clampedStart = Math.max(0, Math.min(region.startMs, totalMs));
|
|
const minEnd = clampedStart + safeMinDurationMs;
|
|
const clampedEnd = Math.min(totalMs, Math.max(minEnd, region.endMs));
|
|
const normalizedStart = Math.max(0, Math.min(clampedStart, totalMs - safeMinDurationMs));
|
|
const normalizedEnd = Math.max(minEnd, Math.min(clampedEnd, totalMs));
|
|
|
|
if (normalizedStart !== region.startMs || normalizedEnd !== region.endMs) {
|
|
onSpeedSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd });
|
|
}
|
|
});
|
|
// Only re-run when the timeline scale changes, not on every region edit
|
|
}, [totalMs, safeMinDurationMs, onZoomSpanChange, onTrimSpanChange, onSpeedSpanChange]);
|
|
|
|
const hasOverlap = useCallback(
|
|
(newSpan: Span, excludeId?: string): boolean => {
|
|
// Determine which row the item belongs to
|
|
const isZoomItem = zoomRegions.some((r) => r.id === excludeId);
|
|
const isTrimItem = trimRegions.some((r) => r.id === excludeId);
|
|
const isAnnotationItem = annotationRegions.some((r) => r.id === excludeId);
|
|
const isBlurItem = blurRegions.some((r) => r.id === excludeId);
|
|
const isSpeedItem = speedRegions.some((r) => r.id === excludeId);
|
|
|
|
if (isAnnotationItem || isBlurItem) {
|
|
return false;
|
|
}
|
|
|
|
// Helper to check overlap against a specific set of regions
|
|
const checkOverlap = (regions: (ZoomRegion | TrimRegion | SpeedRegion)[]) => {
|
|
return regions.some((region) => {
|
|
if (region.id === excludeId) return false;
|
|
// True overlap: regions actually intersect (not just adjacent)
|
|
return newSpan.end > region.startMs && newSpan.start < region.endMs;
|
|
});
|
|
};
|
|
|
|
if (isZoomItem) {
|
|
return checkOverlap(zoomRegions);
|
|
}
|
|
|
|
if (isTrimItem) {
|
|
return checkOverlap(trimRegions);
|
|
}
|
|
|
|
if (isSpeedItem) {
|
|
return checkOverlap(speedRegions);
|
|
}
|
|
|
|
return false;
|
|
},
|
|
[zoomRegions, trimRegions, annotationRegions, blurRegions, speedRegions],
|
|
);
|
|
|
|
// At least 5% of the timeline or 1000ms, whichever is larger, so the region
|
|
// is always wide enough to grab and resize comfortably.
|
|
const defaultRegionDurationMs = useMemo(
|
|
() => Math.max(1000, Math.round(totalMs * 0.05)),
|
|
[totalMs],
|
|
);
|
|
|
|
const handleAddZoom = useCallback(() => {
|
|
if (!videoDuration || videoDuration === 0 || totalMs === 0) {
|
|
return;
|
|
}
|
|
|
|
const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
|
|
if (defaultDuration <= 0) {
|
|
return;
|
|
}
|
|
|
|
// Always place zoom at playhead
|
|
const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
|
|
// Find the next zoom region after the playhead
|
|
const sorted = [...zoomRegions].sort((a, b) => a.startMs - b.startMs);
|
|
const nextRegion = sorted.find((region) => region.startMs > startPos);
|
|
const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos;
|
|
|
|
// Check if playhead is inside any zoom region
|
|
const isOverlapping = sorted.some(
|
|
(region) => startPos >= region.startMs && startPos < region.endMs,
|
|
);
|
|
if (isOverlapping || gapToNext <= 0) {
|
|
toast.error(t("errors.cannotPlaceZoom"), {
|
|
description: t("errors.zoomExistsAtLocation"),
|
|
});
|
|
return;
|
|
}
|
|
|
|
const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
|
|
onZoomAdded({ start: startPos, end: startPos + actualDuration });
|
|
}, [videoDuration, totalMs, currentTimeMs, zoomRegions, onZoomAdded, defaultRegionDurationMs, t]);
|
|
|
|
const handleSuggestZooms = useCallback(() => {
|
|
if (!videoDuration || videoDuration === 0 || totalMs === 0) {
|
|
return;
|
|
}
|
|
|
|
if (!onZoomSuggested) {
|
|
toast.error(t("errors.zoomSuggestionUnavailable"));
|
|
return;
|
|
}
|
|
|
|
if (cursorTelemetry.length < 2) {
|
|
toast.info(t("errors.noCursorTelemetry"), {
|
|
description: t("errors.noCursorTelemetryDescription"),
|
|
});
|
|
return;
|
|
}
|
|
|
|
const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
|
|
if (defaultDuration <= 0) {
|
|
return;
|
|
}
|
|
|
|
const reservedSpans = [...zoomRegions]
|
|
.map((region) => ({ start: region.startMs, end: region.endMs }))
|
|
.sort((a, b) => a.start - b.start);
|
|
|
|
const normalizedSamples = normalizeCursorTelemetry(cursorTelemetry, totalMs);
|
|
|
|
if (normalizedSamples.length < 2) {
|
|
toast.info(t("errors.noUsableTelemetry"), {
|
|
description: t("errors.noUsableTelemetryDescription"),
|
|
});
|
|
return;
|
|
}
|
|
|
|
const dwellCandidates = detectZoomDwellCandidates(normalizedSamples);
|
|
|
|
if (dwellCandidates.length === 0) {
|
|
toast.info(t("errors.noDwellMoments"), {
|
|
description: t("errors.noDwellMomentsDescription"),
|
|
});
|
|
return;
|
|
}
|
|
|
|
const sortedCandidates = [...dwellCandidates].sort((a, b) => b.strength - a.strength);
|
|
const acceptedCenters: number[] = [];
|
|
|
|
let addedCount = 0;
|
|
|
|
sortedCandidates.forEach((candidate) => {
|
|
const tooCloseToAccepted = acceptedCenters.some(
|
|
(center) => Math.abs(center - candidate.centerTimeMs) < SUGGESTION_SPACING_MS,
|
|
);
|
|
|
|
if (tooCloseToAccepted) {
|
|
return;
|
|
}
|
|
|
|
const centeredStart = Math.round(candidate.centerTimeMs - defaultDuration / 2);
|
|
const candidateStart = Math.max(0, Math.min(centeredStart, totalMs - defaultDuration));
|
|
const candidateEnd = candidateStart + defaultDuration;
|
|
const hasOverlap = reservedSpans.some(
|
|
(span) => candidateEnd > span.start && candidateStart < span.end,
|
|
);
|
|
|
|
if (hasOverlap) {
|
|
return;
|
|
}
|
|
|
|
reservedSpans.push({ start: candidateStart, end: candidateEnd });
|
|
acceptedCenters.push(candidate.centerTimeMs);
|
|
onZoomSuggested({ start: candidateStart, end: candidateEnd }, candidate.focus);
|
|
addedCount += 1;
|
|
});
|
|
|
|
if (addedCount === 0) {
|
|
toast.info(t("errors.noAutoZoomSlots"), {
|
|
description: t("errors.noAutoZoomSlotsDescription"),
|
|
});
|
|
return;
|
|
}
|
|
|
|
toast.success(
|
|
addedCount === 1
|
|
? t("success.addedZoomSuggestions", { count: String(addedCount) })
|
|
: t("success.addedZoomSuggestionsPlural", { count: String(addedCount) }),
|
|
);
|
|
}, [
|
|
videoDuration,
|
|
totalMs,
|
|
defaultRegionDurationMs,
|
|
zoomRegions,
|
|
onZoomSuggested,
|
|
cursorTelemetry,
|
|
t,
|
|
]);
|
|
|
|
const handleAddTrim = useCallback(() => {
|
|
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onTrimAdded) {
|
|
return;
|
|
}
|
|
|
|
const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
|
|
if (defaultDuration <= 0) {
|
|
return;
|
|
}
|
|
|
|
// Always place trim at playhead
|
|
const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
|
|
// Find the next trim region after the playhead
|
|
const sorted = [...trimRegions].sort((a, b) => a.startMs - b.startMs);
|
|
const nextRegion = sorted.find((region) => region.startMs > startPos);
|
|
const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos;
|
|
|
|
// Check if playhead is inside any trim region
|
|
const isOverlapping = sorted.some(
|
|
(region) => startPos >= region.startMs && startPos < region.endMs,
|
|
);
|
|
if (isOverlapping || gapToNext <= 0) {
|
|
toast.error(t("errors.cannotPlaceTrim"), {
|
|
description: t("errors.trimExistsAtLocation"),
|
|
});
|
|
return;
|
|
}
|
|
|
|
const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
|
|
onTrimAdded({ start: startPos, end: startPos + actualDuration });
|
|
}, [videoDuration, totalMs, currentTimeMs, trimRegions, onTrimAdded, defaultRegionDurationMs, t]);
|
|
|
|
const handleAddSpeed = useCallback(() => {
|
|
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onSpeedAdded) {
|
|
return;
|
|
}
|
|
|
|
const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
|
|
if (defaultDuration <= 0) {
|
|
return;
|
|
}
|
|
|
|
// Always place speed region at playhead
|
|
const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
|
|
// Find the next speed region after the playhead
|
|
const sorted = [...speedRegions].sort((a, b) => a.startMs - b.startMs);
|
|
const nextRegion = sorted.find((region) => region.startMs > startPos);
|
|
const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos;
|
|
|
|
// Check if playhead is inside any speed region
|
|
const isOverlapping = sorted.some(
|
|
(region) => startPos >= region.startMs && startPos < region.endMs,
|
|
);
|
|
if (isOverlapping || gapToNext <= 0) {
|
|
toast.error(t("errors.cannotPlaceSpeed"), {
|
|
description: t("errors.speedExistsAtLocation"),
|
|
});
|
|
return;
|
|
}
|
|
|
|
const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
|
|
onSpeedAdded({ start: startPos, end: startPos + actualDuration });
|
|
}, [
|
|
videoDuration,
|
|
totalMs,
|
|
currentTimeMs,
|
|
speedRegions,
|
|
onSpeedAdded,
|
|
defaultRegionDurationMs,
|
|
t,
|
|
]);
|
|
|
|
const handleAddAnnotation = useCallback(() => {
|
|
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAnnotationAdded) {
|
|
return;
|
|
}
|
|
|
|
const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
|
|
if (defaultDuration <= 0) {
|
|
return;
|
|
}
|
|
|
|
// Multiple annotations can exist at the same timestamp
|
|
const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
|
|
const endPos = Math.min(startPos + defaultDuration, totalMs);
|
|
|
|
onAnnotationAdded({ start: startPos, end: endPos });
|
|
}, [videoDuration, totalMs, currentTimeMs, onAnnotationAdded, defaultRegionDurationMs]);
|
|
|
|
const handleAddBlur = useCallback(() => {
|
|
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onBlurAdded) {
|
|
return;
|
|
}
|
|
|
|
const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
|
|
if (defaultDuration <= 0) {
|
|
return;
|
|
}
|
|
|
|
const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
|
|
const endPos = Math.min(startPos + defaultDuration, totalMs);
|
|
onBlurAdded({ start: startPos, end: endPos });
|
|
}, [videoDuration, totalMs, currentTimeMs, onBlurAdded, defaultRegionDurationMs]);
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
|
return;
|
|
}
|
|
|
|
if (matchesShortcut(e, keyShortcuts.addKeyframe, isMac)) {
|
|
addKeyframe();
|
|
}
|
|
if (matchesShortcut(e, keyShortcuts.addZoom, isMac)) {
|
|
handleAddZoom();
|
|
}
|
|
if (matchesShortcut(e, keyShortcuts.addTrim, isMac)) {
|
|
handleAddTrim();
|
|
}
|
|
if (matchesShortcut(e, keyShortcuts.addAnnotation, isMac)) {
|
|
handleAddAnnotation();
|
|
}
|
|
if (matchesShortcut(e, keyShortcuts.addBlur, isMac)) {
|
|
handleAddBlur();
|
|
}
|
|
if (matchesShortcut(e, keyShortcuts.addSpeed, isMac)) {
|
|
handleAddSpeed();
|
|
}
|
|
|
|
// Tab: Cycle through overlapping annotations at current time
|
|
if (e.key === "Tab" && annotationRegions.length > 0) {
|
|
const currentTimeMs = Math.round(currentTime * 1000);
|
|
const overlapping = annotationRegions
|
|
.filter((a) => currentTimeMs >= a.startMs && currentTimeMs <= a.endMs)
|
|
.sort((a, b) => a.zIndex - b.zIndex); // Sort by z-index
|
|
|
|
if (overlapping.length > 0) {
|
|
e.preventDefault();
|
|
|
|
if (!selectedAnnotationId || !overlapping.some((a) => a.id === selectedAnnotationId)) {
|
|
onSelectAnnotation?.(overlapping[0].id);
|
|
} else {
|
|
// Cycle to next annotation
|
|
const currentIndex = overlapping.findIndex((a) => a.id === selectedAnnotationId);
|
|
const nextIndex = e.shiftKey
|
|
? (currentIndex - 1 + overlapping.length) % overlapping.length // Shift+Tab = backward
|
|
: (currentIndex + 1) % overlapping.length; // Tab = forward
|
|
onSelectAnnotation?.(overlapping[nextIndex].id);
|
|
}
|
|
}
|
|
}
|
|
// Delete key or Ctrl+D / Cmd+D
|
|
if (
|
|
e.key === "Delete" ||
|
|
e.key === "Backspace" ||
|
|
matchesShortcut(e, keyShortcuts.deleteSelected, isMac)
|
|
) {
|
|
if (selectedKeyframeId) {
|
|
deleteSelectedKeyframe();
|
|
} else if (selectedZoomId) {
|
|
deleteSelectedZoom();
|
|
} else if (selectedTrimId) {
|
|
deleteSelectedTrim();
|
|
} else if (selectedAnnotationId) {
|
|
deleteSelectedAnnotation();
|
|
} else if (selectedBlurId) {
|
|
deleteSelectedBlur();
|
|
} else if (selectedSpeedId) {
|
|
deleteSelectedSpeed();
|
|
}
|
|
}
|
|
};
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
}, [
|
|
addKeyframe,
|
|
handleAddZoom,
|
|
handleAddTrim,
|
|
handleAddAnnotation,
|
|
handleAddBlur,
|
|
handleAddSpeed,
|
|
deleteSelectedKeyframe,
|
|
deleteSelectedZoom,
|
|
deleteSelectedTrim,
|
|
deleteSelectedAnnotation,
|
|
deleteSelectedBlur,
|
|
deleteSelectedSpeed,
|
|
selectedKeyframeId,
|
|
selectedZoomId,
|
|
selectedTrimId,
|
|
selectedAnnotationId,
|
|
selectedBlurId,
|
|
selectedSpeedId,
|
|
annotationRegions,
|
|
currentTime,
|
|
onSelectAnnotation,
|
|
keyShortcuts,
|
|
isMac,
|
|
]);
|
|
|
|
const clampedRange = useMemo<Range>(() => {
|
|
if (totalMs === 0) {
|
|
return range;
|
|
}
|
|
|
|
return {
|
|
start: Math.max(0, Math.min(range.start, totalMs)),
|
|
end: Math.min(range.end, totalMs),
|
|
};
|
|
}, [range, totalMs]);
|
|
|
|
const timelineItems = useMemo<TimelineRenderItem[]>(() => {
|
|
const zooms: TimelineRenderItem[] = zoomRegions.map((region, index) => ({
|
|
id: region.id,
|
|
rowId: ZOOM_ROW_ID,
|
|
span: { start: region.startMs, end: region.endMs },
|
|
label: t("labels.zoomItem", { index: String(index + 1) }),
|
|
zoomDepth: region.depth,
|
|
zoomCustomScale: region.customScale,
|
|
isAutoFocus: region.focusMode === "auto",
|
|
variant: "zoom",
|
|
}));
|
|
|
|
const trims: TimelineRenderItem[] = trimRegions.map((region, index) => ({
|
|
id: region.id,
|
|
rowId: TRIM_ROW_ID,
|
|
span: { start: region.startMs, end: region.endMs },
|
|
label: t("labels.trimItem", { index: String(index + 1) }),
|
|
variant: "trim",
|
|
}));
|
|
|
|
const annotations: TimelineRenderItem[] = annotationRegions.map((region) => {
|
|
let label: string;
|
|
|
|
if (region.type === "text") {
|
|
// Show text preview
|
|
const preview = region.content.trim() || t("labels.emptyText");
|
|
label = preview.length > 20 ? `${preview.substring(0, 20)}...` : preview;
|
|
} else if (region.type === "image") {
|
|
label = t("labels.imageItem");
|
|
} else {
|
|
label = t("labels.annotationItem");
|
|
}
|
|
|
|
return {
|
|
id: region.id,
|
|
rowId: ANNOTATION_ROW_ID,
|
|
span: { start: region.startMs, end: region.endMs },
|
|
label,
|
|
variant: "annotation",
|
|
};
|
|
});
|
|
|
|
const blurs: TimelineRenderItem[] = blurRegions.map((region, index) => ({
|
|
id: region.id,
|
|
rowId: BLUR_ROW_ID,
|
|
span: { start: region.startMs, end: region.endMs },
|
|
label: t("labels.blurItem", { index: String(index + 1) }),
|
|
variant: "blur",
|
|
}));
|
|
|
|
const speeds: TimelineRenderItem[] = speedRegions.map((region, index) => ({
|
|
id: region.id,
|
|
rowId: SPEED_ROW_ID,
|
|
span: { start: region.startMs, end: region.endMs },
|
|
label: t("labels.speedItem", { index: String(index + 1) }),
|
|
speedValue: region.speed,
|
|
variant: "speed",
|
|
}));
|
|
|
|
return [...zooms, ...trims, ...annotations, ...blurs, ...speeds];
|
|
}, [zoomRegions, trimRegions, annotationRegions, blurRegions, speedRegions, t]);
|
|
|
|
// Flat list of all non-annotation region spans for neighbour-clamping during drag/resize
|
|
const allRegionSpans = useMemo(() => {
|
|
const zooms = zoomRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs }));
|
|
const trims = trimRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs }));
|
|
const speeds = speedRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs }));
|
|
return [...zooms, ...trims, ...speeds];
|
|
}, [zoomRegions, trimRegions, speedRegions]);
|
|
|
|
const handleItemSpanChange = useCallback(
|
|
(id: string, span: Span) => {
|
|
// Check if it's a zoom, trim, speed, or annotation item
|
|
if (zoomRegions.some((r) => r.id === id)) {
|
|
onZoomSpanChange(id, span);
|
|
} else if (trimRegions.some((r) => r.id === id)) {
|
|
onTrimSpanChange?.(id, span);
|
|
} else if (speedRegions.some((r) => r.id === id)) {
|
|
onSpeedSpanChange?.(id, span);
|
|
} else if (annotationRegions.some((r) => r.id === id)) {
|
|
onAnnotationSpanChange?.(id, span);
|
|
} else if (blurRegions.some((r) => r.id === id)) {
|
|
onBlurSpanChange?.(id, span);
|
|
}
|
|
},
|
|
[
|
|
zoomRegions,
|
|
trimRegions,
|
|
speedRegions,
|
|
annotationRegions,
|
|
blurRegions,
|
|
onZoomSpanChange,
|
|
onTrimSpanChange,
|
|
onSpeedSpanChange,
|
|
onAnnotationSpanChange,
|
|
onBlurSpanChange,
|
|
],
|
|
);
|
|
|
|
if (!videoDuration || videoDuration === 0) {
|
|
return (
|
|
<div className="flex-1 flex flex-col items-center justify-center rounded-lg bg-[#09090b] gap-3">
|
|
<div className="w-12 h-12 rounded-full bg-white/5 flex items-center justify-center">
|
|
<Plus className="w-6 h-6 text-slate-600" />
|
|
</div>
|
|
<div className="text-center">
|
|
<p className="text-sm font-medium text-slate-300">
|
|
{hasVideoSource ? "Loading Timeline" : "No Video Loaded"}
|
|
</p>
|
|
<p className="text-xs text-slate-500 mt-1">
|
|
{hasVideoSource
|
|
? "Video opened, waiting for duration metadata"
|
|
: "Drag and drop a video to start editing"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex-1 min-h-0 flex flex-col bg-[#09090b] overflow-hidden">
|
|
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-white/[0.06] bg-[#08090b]/95">
|
|
<div className="flex items-center gap-0.5 rounded-xl border border-white/[0.06] bg-white/[0.025] p-0.5">
|
|
<Button
|
|
onClick={handleAddZoom}
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 rounded-lg text-slate-400 hover:text-[#34B27B] hover:bg-[#34B27B]/10 transition-all"
|
|
title={t("buttons.addZoom")}
|
|
>
|
|
<ZoomIn className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
onClick={handleSuggestZooms}
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 rounded-lg text-slate-400 hover:text-[#34B27B] hover:bg-[#34B27B]/10 transition-all"
|
|
title={t("buttons.suggestZooms")}
|
|
>
|
|
<WandSparkles className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
onClick={handleAddTrim}
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 rounded-lg text-slate-400 hover:text-[#ef4444] hover:bg-[#ef4444]/10 transition-all"
|
|
title={t("buttons.addTrim")}
|
|
>
|
|
<Scissors className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
onClick={handleAddAnnotation}
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 rounded-lg text-slate-400 hover:text-[#B4A046] hover:bg-[#B4A046]/10 transition-all"
|
|
title={t("buttons.addAnnotation")}
|
|
>
|
|
<MessageSquare className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
onClick={handleAddBlur}
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 rounded-lg text-slate-400 hover:text-[#7dd3fc] hover:bg-[#7dd3fc]/10 transition-all"
|
|
title={t("buttons.addBlur")}
|
|
>
|
|
<svg
|
|
className="w-4 h-4"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<circle cx="8" cy="12" r="3" />
|
|
<circle cx="16" cy="12" r="3" />
|
|
<path d="M6 6h12M6 18h12" />
|
|
</svg>
|
|
</Button>
|
|
<Button
|
|
onClick={handleAddSpeed}
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 rounded-lg text-slate-400 hover:text-[#d97706] hover:bg-[#d97706]/10 transition-all"
|
|
title={t("buttons.addSpeed")}
|
|
>
|
|
<Gauge className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 px-2 rounded-lg text-[11px] text-slate-400 hover:text-slate-200 hover:bg-white/[0.07] transition-all gap-1"
|
|
>
|
|
<span className="font-medium">{getAspectRatioLabel(aspectRatio)}</span>
|
|
<ChevronDown className="w-3 h-3" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="bg-[#1a1a1a] border-white/10">
|
|
{ASPECT_RATIOS.map((ratio) => (
|
|
<DropdownMenuItem
|
|
key={ratio}
|
|
onClick={() => onAspectRatioChange(ratio)}
|
|
className="text-slate-300 hover:text-white hover:bg-white/10 cursor-pointer flex items-center justify-between gap-3"
|
|
>
|
|
<span>{getAspectRatioLabel(ratio)}</span>
|
|
{aspectRatio === ratio && <Check className="w-3 h-3 text-[#34B27B]" />}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
<div className="flex-1" />
|
|
<div className="hidden md:flex items-center gap-3 text-[10px] text-slate-500 font-medium">
|
|
<span className="flex items-center gap-1.5">
|
|
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">
|
|
{scrollLabels.pan}
|
|
</kbd>
|
|
<span>{t("labels.pan")}</span>
|
|
</span>
|
|
<span className="flex items-center gap-1.5">
|
|
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">
|
|
{scrollLabels.zoom}
|
|
</kbd>
|
|
<span>{t("labels.zoom")}</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div
|
|
ref={timelineContainerRef}
|
|
className="flex-1 min-h-0 overflow-auto custom-scrollbar bg-[#09090b] relative"
|
|
onClick={() => setSelectedKeyframeId(null)}
|
|
>
|
|
<TimelineWrapper
|
|
range={clampedRange}
|
|
videoDuration={videoDuration}
|
|
hasOverlap={hasOverlap}
|
|
onRangeChange={setRange}
|
|
minItemDurationMs={timelineScale.minItemDurationMs}
|
|
minVisibleRangeMs={timelineScale.minVisibleRangeMs}
|
|
onItemSpanChange={handleItemSpanChange}
|
|
allRegionSpans={allRegionSpans}
|
|
>
|
|
<KeyframeMarkers
|
|
keyframes={keyframes}
|
|
selectedKeyframeId={selectedKeyframeId}
|
|
setSelectedKeyframeId={setSelectedKeyframeId}
|
|
onKeyframeMove={handleKeyframeMove}
|
|
videoDurationMs={totalMs}
|
|
timelineRef={timelineContainerRef}
|
|
/>
|
|
<Timeline
|
|
items={timelineItems}
|
|
videoDurationMs={totalMs}
|
|
currentTimeMs={currentTimeMs}
|
|
onSeek={onSeek}
|
|
onRangeChange={setRange}
|
|
onSelectZoom={onSelectZoom}
|
|
onSelectTrim={onSelectTrim}
|
|
onSelectAnnotation={onSelectAnnotation}
|
|
onSelectBlur={onSelectBlur}
|
|
onSelectSpeed={onSelectSpeed}
|
|
selectedZoomId={selectedZoomId}
|
|
selectedTrimId={selectedTrimId}
|
|
selectedAnnotationId={selectedAnnotationId}
|
|
selectedBlurId={selectedBlurId}
|
|
selectedSpeedId={selectedSpeedId}
|
|
keyframes={keyframes}
|
|
/>
|
|
</TimelineWrapper>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|