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
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -848,6 +848,8 @@ jobs:
packages/studio/data/projects/studio-open
cp -R packages/studio/tests/e2e/fixtures/fade-handles \
packages/studio/data/projects/fade-handles
cp -R packages/studio/tests/e2e/fixtures/timeline-trim-snap \
packages/studio/data/projects/timeline-trim-snap
cp -R packages/studio/tests/e2e/fixtures/inline-text-caret \
packages/studio/data/projects/inline-text-caret
cp -R packages/studio/tests/e2e/fixtures/undo-socket-pool \
Expand Down Expand Up @@ -960,7 +962,7 @@ jobs:
node packages/studio/tests/e2e/perf-ratchet.mjs check \
packages/studio/tests/e2e/perf-ceilings.json studio-open /tmp/studio-open-counts.json \
/tmp/base-perf-ceilings.json
- name: Check fade handles, render selects and thumbnail connection release
- name: Check fade handles, render selects, thumbnail connection release and trim snapping
if: success() || failure()
run: |
set -euo pipefail
Expand All @@ -983,6 +985,8 @@ jobs:
node packages/studio/tests/e2e/render-selects.mjs || rc=1
STUDIO_URL="http://localhost:5316/" \
node packages/studio/tests/e2e/thumbnail-save.mjs || rc=1
STUDIO_URL="http://localhost:5316/#project/timeline-trim-snap" \
node packages/studio/tests/e2e/timeline-trim-snap.mjs || rc=1
exit "$rc"
- name: Measure the caret of a text edited in place
if: success() || failure()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const SNAP_LABEL: Record<TimelineSnapType, string> = {
playhead: "playhead",
"clip-edge": "clip edge",
beat: "beat",
grid: "grid line",
};

export type ClipFadeShape = AudioFades & { duration: number };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { TimelineElement } from "../store/playerStore";
import {
computeDragPreview,
computeResizePreview,
guideIfSaved,
getTimelineDragOverlayPosition,
type DragPreviewContext,
} from "./timelineClipDragPreview";
Expand Down Expand Up @@ -293,6 +294,52 @@ describe("computeDragPreview — a clip landing on an empty main track keeps its
});
});

describe("guideIfSaved — a guide only where the clip saves", () => {
const grid = { time: 3.25, type: "grid" as const };

it("drops the guide when start and duration round apart from the target", () => {
// 1440 px/s: 1.125s + 2.125s saves as 1.13s + 2.13s, 14px past the 3.25s guide.
const el = clip("a", 0, 1.125, 2, 0, "div");
expect(guideIfSaved(el, { start: 1.125, duration: 2.125 }, grid, 1440)).toBeNull();
});

it("drops the guide when a nested clip's local start rounds off it", () => {
const el = { ...clip("a", 0, 2, 1, 0, "div"), parentCompositionStart: 1 / 30 };
const target = { time: 2.1, type: "playhead" as const };
expect(guideIfSaved(el, { start: 2.1, duration: 1 }, target, 1440)).toBeNull();
});

it("keeps the guide when the saved edge is on it", () => {
const el = clip("a", 0, 1, 2, 0, "div");
expect(guideIfSaved(el, { start: 1, duration: 2.25 }, grid, 1440)).toBe(grid);
});

it("judges a move by the duration it keeps, not a rounded one", () => {
// A 31/30 s clip moved so its end meets 3.25s saves its start at 2.22s and ends 4.8px past.
const el = clip("a", 0, 1, 31 / 30, 0, "div");
const edge = { time: 3.25, type: "clip-edge" as const };
expect(guideIfSaved(el, { start: 2.217 }, edge, 1440)).toBeNull();
});

it("draws no guide for a move whose saved start misses the target", () => {
const nested = { ...moodboard, parentCompositionStart: 1 / 30 };
const { drag } = horizontalDrag(nested, 0.5, 0);
const context = {
...ctx(),
pps: 1440,
buildSnapTargets: () => [{ time: 21, type: "playhead" as const }],
};
const next = computeDragPreview(
drag,
drag.originClientX + 2 * 1440,
drag.originClientY,
context,
);
expect(next.previewStart).toBe(21);
expect(next.snapTime).toBeNull();
});
});

describe("computeResizePreview — composition source continuity", () => {
it("seeds a legacy composition offset and advances it at playback rate", () => {
const element = {
Expand Down Expand Up @@ -320,6 +367,27 @@ describe("computeResizePreview — composition source continuity", () => {
});
});

it("draws no guide when the trimmed start cannot save onto the target", () => {
// 1440 px/s: a playhead on frame 31 (1.033s) saves a start of 1.03s, 4px off the guide.
const result = computeResizePreview(
{
element: clip("a", 0, 1, 2, 0, "div"),
edge: "start",
originClientX: 0,
previewStart: 1,
previewDuration: 2,
started: true,
},
48,
{
scroll: fakeScroll(),
pps: 1440,
buildSnapTargets: () => [{ time: 1.033, type: "playhead" }],
},
);
expect(result).toMatchObject({ previewStart: 1.03, snapTime: null, snapType: null });
});

it("does not let a tail snap shrink a clip below the drag's minimum duration", () => {
const result = computeResizePreview(
{
Expand Down
53 changes: 39 additions & 14 deletions packages/studio/src/player/components/timelineClipDragPreview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
resolveTimelineMinDuration,
} from "./timelineGroupEditing";
import type { TimelineElement } from "../store/playerStore";
import { clampToHostStart } from "../store/timelineElement";
import { clampToHostStart, savedClipEdges } from "../store/timelineElement";
import {
CLIP_Y,
TRACK_H,
Expand Down Expand Up @@ -34,7 +34,6 @@ import { heldAudioShiftRange, heldPartnerVideoBounds } from "./audioClipLink";
type BuildSnapTargets = (
excludeElementKey: string | null,
includeBeats: boolean,
includePlayhead?: boolean,
) => TimelineSnapTarget[];

export interface DragPreviewContext {
Expand All @@ -56,6 +55,8 @@ export interface DragPreviewContext {
* on demand from `elements`, so the result is identical either way.
*/
audioTracks?: ReadonlySet<number>;
/** Seconds between the ruler's lines; 0 or absent when snapping is off. */
gridStep?: number;
}

export function createKeyboardClipDrag(
Expand Down Expand Up @@ -248,6 +249,7 @@ export function computeDragPreview(
// Relaxed clamp: allow the snapped start past the content, up to the
// rendered extent (see dragMaxStart) — the composition grows on commit.
dragMaxStart + drag.element.duration,
ctx.gridStep,
);
// A group moves rigidly: the grabbed clip stops where any mover would cross its host's start.
const group = resolveGroupDrag(drag, ctx);
Expand All @@ -266,6 +268,11 @@ export function computeDragPreview(
return { ...drag, started: true };
}
const { track: previewTrack, insertRow } = placement;
const target =
placement.start === snap.start && snap.snapTime !== null && snap.snapType !== null
? { time: snap.snapTime, type: snap.snapType }
: null;
const guide = guideIfSaved(drag.element, { start: snap.start }, target, pps);
return {
...drag,
started: true,
Expand All @@ -277,11 +284,24 @@ export function computeDragPreview(
// tell a deliberate vertical lane change from a horizontal drag.
desiredTrack: nextMove.track,
insertRow,
snapTime: placement.start === snap.start ? snap.snapTime : null,
snapType: placement.start === snap.start ? snap.snapType : null,
snapTime: guide?.time ?? null,
snapType: guide?.type ?? null,
};
}

/** A snap's guide, kept only when the clip's edge as its file will save it is within a pixel of it. */
export function guideIfSaved(
element: TimelineElement,
clip: { start: number; duration?: number },
target: TimelineSnapTarget | null,
pps: number,
): TimelineSnapTarget | null {
if (!target) return null;
const saved = savedClipEdges(element, clip.start, clip.duration);
const off = Math.min(Math.abs(saved.start - target.time), Math.abs(saved.end - target.time));
return off * pps < 1 ? target : null;
}

/** 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;

Expand All @@ -296,6 +316,8 @@ export interface ResizePreviewContext {
buildSnapTargets: BuildSnapTargets;
elements?: readonly TimelineElement[];
gestureKeys?: ReadonlySet<string>;
/** Seconds between the ruler's lines; 0 or absent when snapping is off. */
gridStep?: number;
}

export interface ResizePreviewResult {
Expand Down Expand Up @@ -362,21 +384,19 @@ export function computeResizePreview(
effectiveClientX,
);

// 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.
// Snap within the same limits resolveTimelineResize enforces. The music
// track defines the beats, so it must not snap to them.
const trimTargets = buildSnapTargets(
resize.element.key ?? resize.element.id,
!isMusicTrack(resize.element),
false,
);
const gridStep = ctx.gridStep ?? 0;
let snap: TimelineSnapTarget | null = null;
if (trimTargets.length > 0) {
if (trimTargets.length > 0 || gridStep > 0) {
const snapSecs = TIMELINE_SNAP_PX / Math.max(pps, 1);
if (resize.edge === "end") {
const edgeTime = nextResize.start + nextResize.duration;
const { time: snapped, target } = snapTimelineTime(edgeTime, trimTargets, snapSecs);
const { time: snapped, target } = snapTimelineTime(edgeTime, trimTargets, snapSecs, gridStep);
// 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;
Expand All @@ -387,16 +407,21 @@ export function computeResizePreview(
) {
// An edge already on the target still owns the guide; only move it when off.
if (snapped !== edgeTime) nextResize = { ...nextResize, duration: snappedDuration };
snap = target;
snap = guideIfSaved(resize.element, nextResize, target, pps);
}
} else {
const { time: snapped, target } = snapTimelineTime(nextResize.start, trimTargets, snapSecs);
const { time: snapped, target } = snapTimelineTime(
nextResize.start,
trimTargets,
snapSecs,
gridStep,
);
const clip = { ...nextResize, playbackRate: resize.element.playbackRate };
const delta = snapped - nextResize.start;
const bounds = clipStartTrimDeltaBounds(clip, minStart, resolveTimelineMinDuration());
if (target && delta >= bounds.minDelta - 1e-6 && delta <= bounds.maxDelta + 1e-6) {
if (snapped !== nextResize.start) nextResize = applyClipStartTrimDelta(clip, delta);
snap = target;
snap = guideIfSaved(resize.element, nextResize, target, pps);
}
}
}
Expand Down
25 changes: 24 additions & 1 deletion packages/studio/src/player/components/timelineLayout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ import {
resolveTimelineAssetDrop,
getTimelineBeatEntries,
} from "./timelineLayout";
import { generateTicks, getTimelineMajorTickInterval } from "./timelineRulerGeometry";
import {
generateTicks,
getTimelineGridStep,
getTimelineMajorTickInterval,
} from "./timelineRulerGeometry";
import { resolveInsertRow } from "./timelineCollision";
import { getTimelineRenderTimeRange } from "./timelineViewportGeometry";

Expand All @@ -42,6 +46,25 @@ describe("horizontal timeline window", () => {
expect(ticks.major.length + ticks.minor.length).toBeLessThan(100);
});

it("spaces the snap grid exactly like the ruler's drawn lines at each zoom", () => {
for (const [pps, fps] of [
[100, undefined],
[12, undefined],
[900, undefined],
[100, 30],
[3, 30],
] as const) {
const { major, minor } = generateTicks(60, pps, fps);
const lines = [...major, ...minor].sort((a, b) => a - b);
expect(getTimelineGridStep(60, pps, fps)).toBeCloseTo(lines[1]! - lines[0]!, 6);
}
});

it("has no snap grid where the ruler draws no lines", () => {
expect(generateTicks(20_000, 100)).toEqual({ major: [], minor: [] });
expect(getTimelineGridStep(20_000, 100)).toBe(0);
});

it("slices beat records with original strength indexes and unions a pinned beat", () => {
expect(
getTimelineBeatEntries(
Expand Down
27 changes: 24 additions & 3 deletions packages/studio/src/player/components/timelineRulerGeometry.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { formatTime } from "../lib/time";
import { formatTime, STUDIO_PREVIEW_FPS } from "../lib/time";
import type { TimelineTimeDisplayMode } from "../../utils/studioUiPreferences";
import type { TimelineTimeRange } from "../lib/timelineClipIndex";

// fallow-ignore-next-line complexity
Expand Down Expand Up @@ -87,15 +88,35 @@ function appendMinorTicks(
}
}

function tickSpacing(duration: number, pixelsPerSecond?: number, frameRate?: number) {
const majorInterval = getTimelineMajorTickInterval(duration, pixelsPerSecond, frameRate);
const subdivisions = getMinorSubdivisions(majorInterval, pixelsPerSecond, frameRate);
return { majorInterval, subdivisions };
}

export function rulerFrameRate(mode: TimelineTimeDisplayMode): number | undefined {
return mode === "frame" ? STUDIO_PREVIEW_FPS : undefined;
}

/** Seconds between neighbouring ruler lines (minor ticks when drawn, else major): the snap grid. */
export function getTimelineGridStep(
duration: number,
pixelsPerSecond?: number,
frameRate?: number,
): number {
if (!isSupportedTickDuration(duration)) return 0;
const { majorInterval, subdivisions } = tickSpacing(duration, pixelsPerSecond, frameRate);
return subdivisions > 0 ? majorInterval / subdivisions : majorInterval;
}

export function generateTicks(
duration: number,
pixelsPerSecond?: number,
frameRate?: number,
range?: TimelineTimeRange,
): { major: number[]; minor: number[] } {
if (!isSupportedTickDuration(duration)) return { major: [], minor: [] };
const majorInterval = getTimelineMajorTickInterval(duration, pixelsPerSecond, frameRate);
const subdivisions = getMinorSubdivisions(majorInterval, pixelsPerSecond, frameRate);
const { majorInterval, subdivisions } = tickSpacing(duration, pixelsPerSecond, frameRate);
const minorInterval = subdivisions > 0 ? majorInterval / subdivisions : 0;
const major: number[] = [];
const minor: number[] = [];
Expand Down
Loading
Loading