fix(studio): accept razor splits at the canvas clamp boundary (#1340)

This commit is contained in:
Carlos Alcaraz Gregor
2026-06-11 10:26:49 -04:00
committed by GitHub
parent 83662c11a8
commit c52165d1b6
3 changed files with 68 additions and 7 deletions
+2 -7
View File
@@ -7,7 +7,7 @@ import {
canSplitElement,
buildPatchTarget,
readFileContent,
SPLIT_BOUNDARY_EPSILON_S,
isSplitTimeWithinBounds,
} from "../utils/timelineElementSplit";
import type { RecordEditInput } from "./useTimelineEditing";
@@ -171,12 +171,7 @@ export function useRazorSplit({
const pid = projectIdRef.current;
if (!pid || !canSplitElement(element)) return;
const clipStart = element.start;
const clipEnd = element.start + element.duration;
if (
splitTime <= clipStart + SPLIT_BOUNDARY_EPSILON_S ||
splitTime >= clipEnd - SPLIT_BOUNDARY_EPSILON_S
) {
if (!isSplitTimeWithinBounds(splitTime, element.start, element.duration)) {
return;
}
@@ -0,0 +1,50 @@
import { describe, it, expect } from "vitest";
import { SPLIT_BOUNDARY_EPSILON_S, isSplitTimeWithinBounds } from "./timelineElementSplit";
describe("isSplitTimeWithinBounds", () => {
const start = 1;
const duration = 4;
const end = start + duration;
it("accepts the exact lower clamp boundary", () => {
// The timeline canvas clamps an edge click to exactly
// start + SPLIT_BOUNDARY_EPSILON_S, so that value must be splittable.
expect(isSplitTimeWithinBounds(start + SPLIT_BOUNDARY_EPSILON_S, start, duration)).toBe(true);
});
it("accepts the exact upper clamp boundary", () => {
expect(
isSplitTimeWithinBounds(start + duration - SPLIT_BOUNDARY_EPSILON_S, start, duration),
).toBe(true);
});
it("accepts an interior split time", () => {
expect(isSplitTimeWithinBounds(3, start, duration)).toBe(true);
});
it("rejects times at or outside the clip edges", () => {
expect(isSplitTimeWithinBounds(start, start, duration)).toBe(false);
expect(isSplitTimeWithinBounds(end, start, duration)).toBe(false);
expect(isSplitTimeWithinBounds(start - 1, start, duration)).toBe(false);
expect(isSplitTimeWithinBounds(end + 1, start, duration)).toBe(false);
});
it("rejects times inside the epsilon margins", () => {
expect(isSplitTimeWithinBounds(start + SPLIT_BOUNDARY_EPSILON_S / 2, start, duration)).toBe(
false,
);
expect(isSplitTimeWithinBounds(end - SPLIT_BOUNDARY_EPSILON_S / 2, start, duration)).toBe(
false,
);
});
it("rejects every time on a clip shorter than two epsilons", () => {
// Math.max(min, Math.min(max, t)) collapses to min when the clip is too
// short for the clamp range; that collapsed value must still be rejected.
const shortDuration = SPLIT_BOUNDARY_EPSILON_S;
expect(isSplitTimeWithinBounds(start + SPLIT_BOUNDARY_EPSILON_S, start, shortDuration)).toBe(
false,
);
expect(isSplitTimeWithinBounds(start + shortDuration / 2, start, shortDuration)).toBe(false);
});
});
@@ -5,6 +5,22 @@ export { buildPatchTarget, readFileContent } from "../hooks/timelineEditingHelpe
/** Minimum distance (seconds) from clip boundaries to allow a split. */
export const SPLIT_BOUNDARY_EPSILON_S = 0.03;
/**
* True when splitTime leaves at least SPLIT_BOUNDARY_EPSILON_S on both sides
* of the cut. Inclusive at the epsilon offsets: the timeline canvas clamps
* edge clicks to exactly start/end ± epsilon, so the clamped value must pass.
*/
export function isSplitTimeWithinBounds(
splitTime: number,
clipStart: number,
clipDuration: number,
): boolean {
return (
splitTime >= clipStart + SPLIT_BOUNDARY_EPSILON_S &&
splitTime <= clipStart + clipDuration - SPLIT_BOUNDARY_EPSILON_S
);
}
export function canSplitElement(el: TimelineElement): boolean {
return (
!el.timelineLocked &&