Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/studio/src/player/components/Timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ export const Timeline = memo(function Timeline({
onResizeElement: pinnedOnResizeElement,
onResizeElements: pinnedOnResizeElements,
onBlockedEditAttempt,
onSeek,
setShowPopover,
setRangeSelectionRef,
readZIndex: zSyncEnabled ? readClipZIndex : undefined,
Expand Down
15 changes: 9 additions & 6 deletions packages/studio/src/player/components/TimelineCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type { TimelineLaneBaseProps } from "./timelineLaneProps";
import type { TimelineLaneGapStrips } from "./useTimelineGapHighlights";
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
import { TimelineGestureOverlay } from "./TimelineGestureOverlay";
import { resolveSnapGuide } from "./timelineSnapping";

interface TimelineCanvasProps extends TimelineLaneBaseProps {
major: number[];
Expand All @@ -47,7 +48,8 @@ interface TimelineCanvasProps extends TimelineLaneBaseProps {
const DROP_PREVIEW_SECONDS = 3;

export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvasProps) {
const { draggedClip, scrollRef, selectedElementIds, displayTrackOrder } = props;
const { draggedClip, resizingClip, scrollRef, selectedElementIds, displayTrackOrder } = props;
const snapGuide = resolveSnapGuide(draggedClip, resizingClip);
const draggedRowIndex =
draggedClip?.started === true ? displayTrackOrder.indexOf(draggedClip.previewTrack) : -1;
const dropTrackIndex = props.dropPreview
Expand Down Expand Up @@ -117,6 +119,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas

<TimelineLanes
{...props}
snapGuide={snapGuide}
draggedElement={draggedElement}
multiDragPreview={multiDragPreview}
onToggleTrackHidden={onToggleTrackHidden}
Expand Down Expand Up @@ -218,18 +221,18 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
/>
)}

{/* Snap guide for non-beat targets during clip drag */}
{draggedClip?.started && draggedClip.snapTime != null && draggedClip.snapType !== "beat" && (
{/* Snap guide for non-beat targets during a clip move or trim */}
{snapGuide && snapGuide.type !== "beat" && (
<div
className="absolute pointer-events-none"
style={{
left: props.contentOrigin + draggedClip.snapTime * props.pps,
left: props.contentOrigin + snapGuide.time * props.pps,
top: RULER_H,
bottom: 0,
width: 1,
background: draggedClip.snapType === "playhead" ? "#3CE6AC" : "rgba(255,255,255,0.6)",
background: snapGuide.type === "playhead" ? "#3CE6AC" : "rgba(255,255,255,0.6)",
boxShadow:
draggedClip.snapType === "playhead"
snapGuide.type === "playhead"
? "0 0 6px rgba(60,230,172,0.5)"
: "0 0 6px rgba(255,255,255,0.4)",
zIndex: 60,
Expand Down
32 changes: 32 additions & 0 deletions packages/studio/src/player/components/TimelineLanes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ interface RenderLanesOptions {
onContextMenuLane?: (e: React.MouseEvent, track: number, time: number) => void;
hoveredClip?: string | null;
renderClipContent?: React.ComponentProps<typeof TimelineLanes>["renderClipContent"];
snapGuide?: { time: number; type: "beat" | "clip-edge" | "playhead" } | null;
}

function renderLanes(options: RenderLanesOptions = {}): {
Expand Down Expand Up @@ -141,6 +142,7 @@ function renderLanes(options: RenderLanesOptions = {}): {
renderClipContent={next.renderClipContent}
draggedClip={next.draggedClip ?? null}
draggedElement={null}
snapGuide={next.snapGuide ?? null}
multiDragPreview={next.multiDragPreview ?? null}
blockedClipRef={createRef<BlockedClipState | null>()}
suppressClickRef={{ current: false }}
Expand Down Expand Up @@ -179,6 +181,36 @@ function visibilityLabels(host: HTMLElement): (string | null)[] {
);
}

/** The beat guide's own highlight div, keyed by the green glow every other beat lacks. */
function beatHighlight(host: HTMLElement): HTMLElement | undefined {
return Array.from(host.querySelectorAll("div")).find((div) =>
(div.style.boxShadow ?? "").includes("34,197,94"),
);
}

describe("TimelineLanes beat guide", () => {
it("draws the beat highlight from snapGuide, not from the stale draggedClip prop", () => {
const view = renderLanes({
elements: [element("clip-a", TRACK_A)],
snapGuide: { time: 1.5, type: "beat" },
});

expect(beatHighlight(view.host)?.style.left).toBe("150px");
act(() => view.root.unmount());
});

it("clears the highlight once the trim it belonged to ends", () => {
const view = renderLanes({
elements: [element("clip-a", TRACK_A)],
snapGuide: { time: 1.5, type: "beat" },
});
view.rerender({ elements: [element("clip-a", TRACK_A)], snapGuide: null });

expect(beatHighlight(view.host)).toBeUndefined();
act(() => view.root.unmount());
});
});

describe("TimelineLanes track numbering", () => {
// Screen readers literally announced "Hide track 0.16666666666666666".
it("numbers tracks contiguously from 1 regardless of the fractional sort keys", () => {
Expand Down
7 changes: 2 additions & 5 deletions packages/studio/src/player/components/TimelineLanes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export function TimelineLanes({
hoveredClip,
draggedClip,
draggedElement,
snapGuide,
multiDragPreview,
blockedClipRef,
suppressClickRef,
Expand Down Expand Up @@ -331,11 +332,7 @@ export function TimelineLanes({
beatTimes={beatAnalysis?.beatTimes}
beatStrengths={beatAnalysis?.beatStrengths}
pps={pps}
highlightTime={
draggedClip?.started && draggedClip.snapType === "beat"
? draggedClip.snapTime
: null
}
highlightTime={snapGuide?.type === "beat" ? snapGuide.time : null}
renderTimeRange={rowsVirtualized ? renderTimeRange : undefined}
/>
{/* Beat dots on the active track (the one holding the selection),
Expand Down
3 changes: 2 additions & 1 deletion packages/studio/src/player/components/TimelineTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ export interface TimelineClipRenderContext {
export interface TimelineProps extends TimelineDropCallbacks, TimelineEditOverrides {
/** Project-scoped reset boundary; soft source refreshes retain the same epoch. */
sessionEpoch?: number;
onSeek?: (time: number) => void;
/** keepPlaying: true preserves the current play state across the seek. */
onSeek?: (time: number, options?: { keepPlaying?: boolean }) => void;
onDrillDown?: (element: TimelineElement) => void;
renderClipContent?: (
element: TimelineElement,
Expand Down
78 changes: 51 additions & 27 deletions packages/studio/src/player/components/timelineClipDragPreview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
snapMoveToTargets,
snapTimelineTime,
type TimelineSnapTarget,
type TimelineSnapType,
} from "./timelineSnapping";
import { resolveInsertRow, resolveZoneDropPlacement } from "./timelineCollision";
import {
Expand All @@ -21,11 +22,13 @@ import {
import { clampGroupMoveDelta } from "./timelineMultiDragPreview";
import type { DraggedClipState, ResizingClipState } from "./timelineClipDragTypes";
import { resolveDragLandingStart } from "./timelineDragLanding";
import { STUDIO_PREVIEW_FPS } from "../lib/time";

/** Snap-target builder closure supplied by the hook (closes over refs + store). */
type BuildSnapTargets = (
excludeElementKey: string | null,
includeBeats: boolean,
includePlayhead?: boolean,
) => TimelineSnapTarget[];

export interface DragPreviewContext {
Expand Down Expand Up @@ -223,6 +226,14 @@ export function computeDragPreview(
};
}

/** One frame: the last visible frame of a clip sits just before its end time. */
const TRIM_END_FRAME_LEAD_S = 1 / STUDIO_PREVIEW_FPS;

/** The composition time whose frame a trim shows: the edge being dragged. */
export function trimPreviewTime(edge: "start" | "end", start: number, duration: number): number {
return edge === "start" ? start : Math.max(start, start + duration - TRIM_END_FRAME_LEAD_S);
}

export interface ResizePreviewContext {
scroll: HTMLDivElement | null;
pps: number;
Expand All @@ -234,6 +245,9 @@ export interface ResizePreviewResult {
previewStart: number;
previewDuration: number;
previewPlaybackStart?: number;
/** The target the trimmed edge snapped to; null when the edge is free. */
snapTime: number | null;
snapType: TimelineSnapType | null;
}

/** Compute the trim preview for a pointer x (pure — the hook applies the state). */
Expand Down Expand Up @@ -287,28 +301,31 @@ export function computeResizePreview(
effectiveClientX,
);

// Snap edge to unified targets (beats + clip edges + playhead) when available.
// The snap must stay inside the same limits resolveTimelineResize enforces, or
// it would push the edge past the available source media / composition end.
// The music track defines the beats, so it must not snap to them — but it
// still snaps to the playhead and other clip edges.
// Snap to beats and clip edges, never the playhead (the dragged edge drives
// its own preview seek, so that would be circular). Stay inside the same
// limits resolveTimelineResize enforces. The music track defines the
// beats, so it must not snap to them, but still snaps to clip edges.
const trimTargets = buildSnapTargets(
resize.element.key ?? resize.element.id,
!isMusicTrack(resize.element),
false,
);
let snap: TimelineSnapTarget | null = null;
if (trimTargets.length > 0) {
const snapSecs = TIMELINE_SNAP_PX / Math.max(pps, 1);
if (resize.edge === "end") {
const edgeTime = nextResize.start + nextResize.duration;
const snapped = snapTimelineTime(edgeTime, trimTargets, snapSecs).time;
const { time: snapped, target } = snapTimelineTime(edgeTime, trimTargets, snapSecs);
// Stay within [start+minDuration, maxEnd] so the snap can't create a
// degenerate clip or run past the source/composition limit.
const snappedDuration = Math.round((snapped - nextResize.start) * 1000) / 1000;
if (snapped !== edgeTime && snapped <= maxEnd + 1e-6 && snappedDuration >= 0.05) {
nextResize = { ...nextResize, duration: snappedDuration };
if (target && snapped <= maxEnd + 1e-6 && snappedDuration >= 0.05) {
// An edge already on the target still owns the guide; only move it when off.
if (snapped !== edgeTime) nextResize = { ...nextResize, duration: snappedDuration };
snap = target;
}
} else {
const snapped = snapTimelineTime(nextResize.start, trimTargets, snapSecs).time;
const { time: snapped, target } = snapTimelineTime(nextResize.start, trimTargets, snapSecs);
const delta = nextResize.start - snapped; // >0 when snapping left
// Leftward snap reveals more source; cap so playbackStart can't go < 0.
const maxLeftDelta =
Expand All @@ -318,22 +335,20 @@ export function computeResizePreview(
// Also require the resulting duration to stay >= minDuration so a rightward
// snap (delta < 0) can't collapse the clip to zero/negative.
const snappedDuration = Math.round((nextResize.duration + delta) * 1000) / 1000;
if (
snapped !== nextResize.start &&
snapped >= 0 &&
delta <= maxLeftDelta + 1e-6 &&
snappedDuration >= 0.05
) {
nextResize = {
...nextResize,
start: snapped,
duration: snappedDuration,
playbackStart:
nextResize.playbackStart != null
? Math.round(Math.max(0, nextResize.playbackStart - delta * playbackRate) * 1000) /
1000
: undefined,
};
if (target && snapped >= 0 && delta <= maxLeftDelta + 1e-6 && snappedDuration >= 0.05) {
if (snapped !== nextResize.start) {
nextResize = {
...nextResize,
start: snapped,
duration: snappedDuration,
playbackStart:
nextResize.playbackStart != null
? Math.round(Math.max(0, nextResize.playbackStart - delta * playbackRate) * 1000) /
1000
: undefined,
};
}
snap = target;
}
}
}
Expand All @@ -343,6 +358,8 @@ export function computeResizePreview(
previewStart: nextResize.start,
previewDuration: nextResize.duration,
previewPlaybackStart: nextResize.playbackStart,
snapTime: snap?.time ?? null,
snapType: snap?.type ?? null,
};
}

Expand All @@ -359,11 +376,18 @@ export function previewGroupResize(
) => void,
): void {
const grabbedChange = applyTimelineGroupResizePreview(session, next);
const previewStart = grabbedChange?.start ?? next.previewStart;
const previewDuration = grabbedChange?.duration ?? next.previewDuration;
// A member clamp can pull the grabbed edge off the raw snap target; then no guide.
const edgeTime = session.edge === "end" ? previewStart + previewDuration : previewStart;
const stillSnapped = next.snapTime != null && Math.abs(edgeTime - next.snapTime) < 1e-3;
setResizeState({
originScrollLeft: next.originScrollLeft,
previewStart: grabbedChange?.start ?? next.previewStart,
previewDuration: grabbedChange?.duration ?? next.previewDuration,
previewStart,
previewDuration,
previewPlaybackStart: grabbedChange?.playbackStart ?? next.previewPlaybackStart,
snapTime: stillSnapped ? next.snapTime : null,
snapType: stillSnapped ? next.snapType : null,
groupPreview: session.changes,
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ export interface ResizingClipState {
previewStart: number;
previewDuration: number;
previewPlaybackStart?: number;
/** Snap target the trimmed edge landed on, for the guide highlight. */
snapTime?: number | null;
snapType?: TimelineSnapType | null;
/** Coordinator-owned group projection; canonical elements change only on commit. */
groupPreview?: readonly {
key: string;
Expand Down
3 changes: 3 additions & 0 deletions packages/studio/src/player/components/timelineLaneProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { TrackVisualStyle } from "./timelineIcons";
import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag";
import type { TimelineClipIndex, TimelineTimeRange } from "../lib/timelineClipIndex";
import type { TimelineRowGeometry } from "./timelineLayout";
import type { TimelineSnapTarget } from "./timelineSnapping";
import type { TimelineVirtualRow } from "./useTimelineVirtualRows";
import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
import type { TimelineEditCallbacks } from "./timelineCallbacks";
Expand Down Expand Up @@ -112,6 +113,8 @@ export interface TimelineLaneBaseProps {
export interface TimelineLanesProps extends TimelineLaneBaseProps {
/** Live-derived by TimelineCanvas from {@link TimelineLaneBaseProps.draggedClip}. */
draggedElement: TimelineElement | null;
/** Live move or trim snap target, resolved once by TimelineCanvas. */
snapGuide: TimelineSnapTarget | null;
multiDragPreview: MultiDragPreviewInput | null;
onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
onTogglePropertyGroupKeyframe: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
Expand Down
27 changes: 27 additions & 0 deletions packages/studio/src/player/components/timelineSnapping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
collectTimelineSnapTargets,
snapMoveToTargets,
snapTimelineTime,
resolveSnapGuide,
} from "./timelineSnapping";

describe("collectTimelineSnapTargets", () => {
Expand All @@ -26,6 +27,18 @@ describe("collectTimelineSnapTargets", () => {
expect(targets).toContainEqual({ time: 0.5, type: "beat" });
});

it("omits the playhead when includePlayhead is false, for a trim", () => {
const targets = collectTimelineSnapTargets({
elements,
playheadTime: 7.25,
beatTimes: [0.5],
includePlayhead: false,
});
expect(targets.some((t) => t.type === "playhead")).toBe(false);
expect(targets).toContainEqual({ time: 2, type: "clip-edge" });
expect(targets).toContainEqual({ time: 0.5, type: "beat" });
});

it("excludes the dragged element's own edges", () => {
const targets = collectTimelineSnapTargets({
elements,
Expand Down Expand Up @@ -132,3 +145,17 @@ describe("snapMoveToTargets", () => {
expect(r.snapTime).toBeNull();
});
});

describe("resolveSnapGuide", () => {
it("prefers a started move, falls back to a trim, and is null when neither snapped", () => {
const move = { started: true, snapTime: 2, snapType: "playhead" as const };
const trim = { snapTime: 5, snapType: "clip-edge" as const };
expect(resolveSnapGuide(move, trim)).toEqual({ time: 2, type: "playhead" });
expect(resolveSnapGuide({ ...move, started: false }, trim)).toEqual({
time: 5,
type: "clip-edge",
});
expect(resolveSnapGuide(null, { snapTime: null, snapType: null })).toBeNull();
expect(resolveSnapGuide(null, null)).toBeNull();
});
});
Loading
Loading