fix(studio): apply split-bounds epsilon in razor split-all (#1404)

The single-clip razor path guards splits with isSplitTimeWithinBounds,
which keeps a SPLIT_BOUNDARY_EPSILON_S margin from each clip edge so a
cut never produces a degenerate near-zero slice. The split-all path
filtered with raw `splitTime > start && splitTime < end` instead, so it
accepted cuts inside that margin (and on clips shorter than two epsilons
that the single path always rejects), producing the very degenerate
slice the epsilon exists to prevent.

Extract the shared predicate canSplitElementAt and a selectSplittableElements
helper, and route both razor paths through them so the two stay consistent.

Adds unit coverage for the new helpers, including the regression where a
sub-epsilon clip with an interior split time must not be selected.

Co-authored-by: Carlos Alcaraz <193642530+calcarazgre646@users.noreply.github.com>
This commit is contained in:
Carlos Alcaraz Gregor
2026-06-16 23:57:14 -07:00
committed by GitHub
co-authored by Carlos Alcaraz
parent a4ae3c92a0
commit 66dde0898b
3 changed files with 83 additions and 11 deletions
@@ -1,5 +1,22 @@
import { describe, it, expect } from "vitest";
import { SPLIT_BOUNDARY_EPSILON_S, isSplitTimeWithinBounds } from "./timelineElementSplit";
import type { TimelineElement } from "../player/store/playerStore";
import {
SPLIT_BOUNDARY_EPSILON_S,
canSplitElementAt,
isSplitTimeWithinBounds,
selectSplittableElements,
} from "./timelineElementSplit";
function element(overrides: Partial<TimelineElement> = {}): TimelineElement {
return {
id: "el-1",
tag: "div",
start: 1,
duration: 4,
track: 0,
...overrides,
};
}
describe("isSplitTimeWithinBounds", () => {
const start = 1;
@@ -48,3 +65,46 @@ describe("isSplitTimeWithinBounds", () => {
expect(isSplitTimeWithinBounds(start + shortDuration / 2, start, shortDuration)).toBe(false);
});
});
describe("canSplitElementAt", () => {
it("accepts a splittable element at an interior time", () => {
expect(canSplitElementAt(element({ start: 1, duration: 4 }), 3)).toBe(true);
});
it("rejects a time inside the boundary epsilon", () => {
expect(
canSplitElementAt(element({ start: 1, duration: 4 }), 1 + SPLIT_BOUNDARY_EPSILON_S / 2),
).toBe(false);
});
it("rejects locked, implicit and sub-composition elements", () => {
expect(canSplitElementAt(element({ timelineLocked: true }), 3)).toBe(false);
expect(canSplitElementAt(element({ timingSource: "implicit" }), 3)).toBe(false);
expect(canSplitElementAt(element({ compositionSrc: "child.html" }), 3)).toBe(false);
});
});
describe("selectSplittableElements", () => {
it("excludes a clip shorter than two epsilons even when the time is inside it", () => {
// Regression: split-all used raw start < t < end, so a clip too short for
// the epsilon margin was still selected and produced a degenerate slice.
const tiny = element({ id: "tiny", start: 1, duration: SPLIT_BOUNDARY_EPSILON_S + 0.01 });
const interiorTime = tiny.start + tiny.duration / 2;
expect(interiorTime).toBeGreaterThan(tiny.start);
expect(interiorTime).toBeLessThan(tiny.start + tiny.duration);
expect(selectSplittableElements([tiny], interiorTime)).toEqual([]);
});
it("keeps only the elements whose epsilon-bounded range contains the time", () => {
const inside = element({ id: "inside", start: 0, duration: 4 });
const outside = element({ id: "outside", start: 5, duration: 4 });
const locked = element({ id: "locked", start: 0, duration: 4, timelineLocked: true });
const result = selectSplittableElements([inside, outside, locked], 2);
expect(result.map((el) => el.id)).toEqual(["inside"]);
});
it("accepts an element at the exact lower clamp boundary", () => {
const el = element({ start: 2, duration: 4 });
expect(selectSplittableElements([el], 2 + SPLIT_BOUNDARY_EPSILON_S)).toEqual([el]);
});
});
@@ -30,3 +30,21 @@ export function canSplitElement(el: TimelineElement): boolean {
Number.isFinite(el.duration)
);
}
/**
* True when `el` can be split AND `splitTime` lies within its boundary epsilon.
* Shared by the single-clip and split-all razor paths so both honor the same
* minimum-distance rule (split-all previously used raw `>`/`<`, letting cuts
* land inside the epsilon margin and produce a degenerate slice).
*/
export function canSplitElementAt(el: TimelineElement, splitTime: number): boolean {
return canSplitElement(el) && isSplitTimeWithinBounds(splitTime, el.start, el.duration);
}
/** Elements that the split-all razor action can cut at `splitTime`. */
export function selectSplittableElements(
elements: TimelineElement[],
splitTime: number,
): TimelineElement[] {
return elements.filter((el) => canSplitElementAt(el, splitTime));
}