diff --git a/src/components/video-editor/timeline/Item.tsx b/src/components/video-editor/timeline/Item.tsx
index 6f9a706..991ea02 100644
--- a/src/components/video-editor/timeline/Item.tsx
+++ b/src/components/video-editor/timeline/Item.tsx
@@ -79,10 +79,16 @@ export default function Item({
[span.start, span.end],
);
+ // Minimum clickable width on the outer wrapper.
+ // Kept small (6px) so items visually distinguish their real positions;
+ // users should zoom in to interact with sub-second items precisely.
+ const MIN_ITEM_PX = 6;
+ const safeItemStyle = { ...itemStyle, minWidth: MIN_ITEM_PX };
+
return (
onSelect?.()}
diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx
index 2b03685..6bdf5e5 100644
--- a/src/components/video-editor/timeline/TimelineEditor.tsx
+++ b/src/components/video-editor/timeline/TimelineEditor.tsx
@@ -67,8 +67,6 @@ interface TimelineEditorProps {
}
interface TimelineScaleConfig {
- intervalMs: number;
- gridMs: number;
minItemDurationMs: number;
defaultItemDurationMs: number;
minVisibleRangeMs: number;
@@ -85,6 +83,8 @@ interface TimelineRenderItem {
}
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 },
@@ -102,34 +102,42 @@ const SCALE_CANDIDATES = [
{ 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));
- const selectedCandidate = SCALE_CANDIDATES.find((candidate) => {
- if (durationSeconds <= 0) {
- return true;
- }
- const markers = durationSeconds / candidate.intervalSeconds;
- return markers <= TARGET_MARKER_COUNT;
- }) ?? SCALE_CANDIDATES[SCALE_CANDIDATES.length - 1];
+ // Minimum item duration: fixed at 100ms (0.1s).
+ // Allows precise cuts while remaining interactive.
+ const minItemDurationMs = 100;
- const intervalMs = Math.round(selectedCandidate.intervalSeconds * 1000);
- const gridMs = Math.round(selectedCandidate.gridSeconds * 1000);
+ // 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);
- // Set minItemDurationMs to 1ms for maximum granularity
- const minItemDurationMs = 1;
- const defaultItemDurationMs = Math.min(
- Math.max(minItemDurationMs, intervalMs * 2),
- totalMs > 0 ? totalMs : intervalMs * 2,
- );
-
- const minVisibleRangeMs = totalMs > 0
- ? Math.min(Math.max(intervalMs * 3, minItemDurationMs * 6, 1000), totalMs)
- : Math.max(intervalMs * 3, minItemDurationMs * 6, 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 {
- intervalMs,
- gridMs,
minItemDurationMs,
defaultItemDurationMs,
minVisibleRangeMs,
@@ -285,17 +293,21 @@ function PlaybackCursor({
}
function TimelineAxis({
- intervalMs,
videoDurationMs,
currentTimeMs,
}: {
- intervalMs: number;
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: [] };
@@ -404,7 +416,6 @@ function TimelineAxis({
function Timeline({
items,
videoDurationMs,
- intervalMs,
currentTimeMs,
onSeek,
onSelectZoom,
@@ -419,7 +430,6 @@ function Timeline({
}: {
items: TimelineRenderItem[];
videoDurationMs: number;
- intervalMs: number;
currentTimeMs: number;
onSeek?: (time: number) => void;
onSelectZoom?: (id: string | null) => void;
@@ -475,7 +485,7 @@ function Timeline({
onClick={handleTimelineClick}
>
-
+
@@ -1227,7 +1236,6 @@ export default function TimelineEditor({
>;
minItemDurationMs: number;
minVisibleRangeMs: number;
- gridSizeMs: number;
+ gridSizeMs?: number;
onItemSpanChange: (id: string, span: Span) => void;
allRegionSpans?: { id: string; start: number; end: number }[];
}
@@ -135,7 +135,10 @@ export default function TimelineWrapper({
const activeItemId = event.active.id as string;
let clampedSpan = clampSpanToBounds(updatedSpan);
- if (clampedSpan.end - clampedSpan.start < Math.min(minItemDurationMs, totalMs || minItemDurationMs)) {
+ const effectiveMinDuration = totalMs > 0
+ ? Math.min(minItemDurationMs, totalMs)
+ : minItemDurationMs;
+ if (clampedSpan.end - clampedSpan.start < effectiveMinDuration) {
return;
}