Skip to content

Commit 80ce01a

Browse files
committed
fix(core): clips that meet within float rounding count as meeting in edits and playback
1 parent 162de1a commit 80ce01a

11 files changed

Lines changed: 227 additions & 20 deletions

File tree

‎packages/cli/src/timeline/a2Shared.ts‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
} from "@hyperframes/studio-server";
77
import type { AppliedFileMutation, PatchOperation } from "@hyperframes/studio-server";
88
import { fpsToNumber, parseFpsWithDefault } from "@hyperframes/core";
9+
import { spansOverlap } from "@hyperframes/core/clip-facts";
910
import { readCompositionFps } from "../utils/compositionFps.js";
1011
import { readFileSync } from "node:fs";
1112
import type { ProjectTimeline, TimelineRow } from "./describeProject.js";
@@ -147,7 +148,7 @@ function overlap(
147148
candidate !== row &&
148149
candidate.file === row.file &&
149150
candidate.trackIndex === row.trackIndex &&
150-
Math.max(start, candidate.start) < Math.min(end, candidate.end),
151+
spansOverlap(start, end, candidate.start, candidate.end),
151152
);
152153
}
153154

@@ -263,7 +264,8 @@ function finishTrim(
263264
): MutationDecision | { ok: true; nextStart: number; nextDuration: number } {
264265
if (end && !end.ok) return end;
265266
if (duration && !duration.ok) return duration;
266-
const nextDuration = duration?.seconds ?? (end ? end.seconds - nextStart : context.row.duration);
267+
const nextDuration =
268+
duration?.seconds ?? (end ? durationUntil(nextStart, end.seconds) : context.row.duration);
267269
if (nextDuration <= 0) {
268270
return {
269271
ok: false,
@@ -279,6 +281,19 @@ function trimStart(context: MutationContext, expression: string | undefined) {
279281
return parseMutationTime(context, expression, "pass a valid time expression");
280282
}
281283

284+
function durationUntil(start: number, end: number): number {
285+
let duration = end - start;
286+
while (duration > 0 && start + duration > end) duration = nextSmaller(duration);
287+
return duration;
288+
}
289+
290+
const float = new DataView(new ArrayBuffer(8));
291+
function nextSmaller(positive: number): number {
292+
float.setFloat64(0, positive);
293+
float.setBigUint64(0, float.getBigUint64(0) - 1n);
294+
return float.getFloat64(0);
295+
}
296+
282297
function trimEnd(context: MutationContext, expression: string | undefined) {
283298
if (!expression) return undefined;
284299
return parseMutationTime(context, expression, "pass a valid time expression");
@@ -413,8 +428,7 @@ export function mutationConflict(
413428
(candidate) =>
414429
candidate.file === row.file &&
415430
candidate.trackIndex === row.trackIndex &&
416-
candidate.start < nextStart &&
417-
nextStart < candidate.end,
431+
spansOverlap(nextStart, nextStart, candidate.start, candidate.end),
418432
);
419433
if (!conflict) return null;
420434
return {

‎packages/cli/src/timeline/timeline.e2e.test.ts‎

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
33
import { tmpdir } from "node:os";
44
import { join, resolve } from "node:path";
55
import { fileURLToPath } from "node:url";
6+
import { isClipVisibleAt } from "@hyperframes/core";
67
import { describe, expect, it } from "vitest";
78

89
const cliEntry = resolve(fileURLToPath(import.meta.url), "..", "..", "cli.ts");
@@ -102,6 +103,135 @@ describe("timeline edit command", () => {
102103
}
103104
});
104105

106+
const meeting = (aStart: string, aDuration: string, bStart: string, fps = "") =>
107+
`<div data-composition-id="main"${fps} data-duration="40"><div id="a" data-hf-id="a" data-start="${aStart}" data-duration="${aDuration}" data-track-index="0"></div><div id="b" data-hf-id="b" data-start="${bStart}" data-duration="2" data-track-index="0"></div></div>`;
108+
const clip = (html: string, id: string) => {
109+
const tag = new RegExp(`<div[^>]*\\sid="${id}"[^>]*>`).exec(html)?.[0] ?? "";
110+
const attr = (name: string) => new RegExp(`${name}="([^"]+)"`).exec(tag)?.[1];
111+
return { start: attr("data-start"), duration: attr("data-duration") };
112+
};
113+
const endOf = (html: string, id: string) =>
114+
Number(clip(html, id).start) + Number(clip(html, id).duration);
115+
const visibleAt = (html: string, time: number, ids: string[]) =>
116+
ids.filter((id) => isClipVisibleAt(time, Number(clip(html, id).start), endOf(html, id), 40));
117+
118+
const composition = (fps: string, ...clips: [string, string, string][]) =>
119+
`<div data-composition-id="main"${fps} data-duration="40">${clips
120+
.map(
121+
([id, start, duration]) =>
122+
`<div id="${id}" data-hf-id="${id}" data-start="${start}" data-duration="${duration}" data-track-index="0"></div>`,
123+
)
124+
.join("")}</div>`;
125+
126+
it.each([
127+
[
128+
"move",
129+
["#b", "20f"],
130+
composition(' data-fps="24"', ["a", "0", String(20 / 24 + 5e-7)], ["b", "2", "0.25"]),
131+
],
132+
["duplicate", ["#c", "--at", "1"], composition("", ["a", "0", "1.0000005"], ["c", "3", "1"])],
133+
])("refuses a %s that lands half a microsecond inside another clip", (verb, args, html) => {
134+
const dir = project();
135+
try {
136+
writeFileSync(join(dir, "index.html"), html);
137+
expect(run(dir, verb, ...args).status).not.toBe(0);
138+
expect(readFileSync(join(dir, "index.html"), "utf8")).toBe(html);
139+
} finally {
140+
rmSync(dir, { recursive: true, force: true });
141+
}
142+
});
143+
144+
it.each([
145+
[
146+
"a clip wholly before the insertion point",
147+
["#x", "--at", "5"],
148+
composition("", ["x", "0", "1"], ["t", "4.9999995", "0.0000002"]),
149+
4.9999995,
150+
],
151+
[
152+
"a clip half a microsecond after it",
153+
["#a"],
154+
composition("", ["a", "0", "1"], ["t", "1.0000005", "1"]),
155+
1.0000005 + 1,
156+
],
157+
])("duplicates without snapping %s to the copy", (_, args, html, tStart) => {
158+
const dir = project();
159+
try {
160+
writeFileSync(join(dir, "index.html"), html);
161+
expect(run(dir, "duplicate", ...args).status).toBe(0);
162+
expect(Number(clip(readFileSync(join(dir, "index.html"), "utf8"), "t").start)).toBe(tStart);
163+
} finally {
164+
rmSync(dir, { recursive: true, force: true });
165+
}
166+
});
167+
168+
it("trims a clip that only meets the one before it, ending where asked", () => {
169+
const dir = project();
170+
try {
171+
writeFileSync(join(dir, "index.html"), meeting("19.8", "6.4", "26.2"));
172+
expect(run(dir, "trim", "#b", "--end", "29").status).toBe(0);
173+
const html = readFileSync(join(dir, "index.html"), "utf8");
174+
expect(clip(html, "b")).toEqual({ start: "26.2", duration: "2.8000000000000007" });
175+
expect(endOf(html, "b")).toBe(29);
176+
} finally {
177+
rmSync(dir, { recursive: true, force: true });
178+
}
179+
});
180+
181+
it("trims up to the next clip's start without ending past it, where no duration lands exactly", () => {
182+
const dir = project();
183+
try {
184+
writeFileSync(join(dir, "index.html"), meeting("4.74", "10", "25.74"));
185+
expect(run(dir, "trim", "#a", "--end", "25.74").status).toBe(0);
186+
const html = readFileSync(join(dir, "index.html"), "utf8");
187+
expect(endOf(html, "a")).toBeLessThanOrEqual(25.74);
188+
expect(visibleAt(html, 25.74, ["a", "b"])).toEqual(["b"]);
189+
} finally {
190+
rmSync(dir, { recursive: true, force: true });
191+
}
192+
});
193+
194+
it("trims to frame 20 at 30 fps so that frame shows the next clip and not this one", () => {
195+
const dir = project();
196+
try {
197+
writeFileSync(join(dir, "index.html"), meeting("0", "2", "2", ' data-fps="30"'));
198+
expect(run(dir, "trim", "#a", "--end", "20f").status).toBe(0);
199+
expect(run(dir, "trim", "#b", "--start", "20f").status).toBe(0);
200+
const html = readFileSync(join(dir, "index.html"), "utf8");
201+
expect([clip(html, "a").duration, clip(html, "b").start]).toEqual([
202+
"0.6666666666666666",
203+
"0.6666666666666666",
204+
]);
205+
expect(visibleAt(html, 19 / 30, ["a", "b"])).toEqual(["a"]);
206+
expect(visibleAt(html, 20 / 30, ["a", "b"])).toEqual(["b"]);
207+
} finally {
208+
rmSync(dir, { recursive: true, force: true });
209+
}
210+
});
211+
212+
it.each([
213+
["19.8", "6.4", "26.2"],
214+
["0", "0.6666666666666666", "0.6666666666666666"],
215+
["0.1", "1.1", "1.2"],
216+
])(
217+
"duplicates a clip at %s lasting %s up against the clip at %s, each boundary exact",
218+
(aStart, aDuration, bStart) => {
219+
const dir = project();
220+
try {
221+
writeFileSync(join(dir, "index.html"), meeting(aStart, aDuration, bStart));
222+
expect(run(dir, "duplicate", "#a").status).toBe(0);
223+
const html = readFileSync(join(dir, "index.html"), "utf8");
224+
const ids = ["a", "a-copy", "b"];
225+
expect(Number(clip(html, "a-copy").start)).toBe(endOf(html, "a"));
226+
expect(Number(clip(html, "b").start)).toBe(endOf(html, "a-copy"));
227+
expect(visibleAt(html, endOf(html, "a"), ids)).toEqual(["a-copy"]);
228+
expect(visibleAt(html, endOf(html, "a-copy"), ids)).toEqual(["b"]);
229+
} finally {
230+
rmSync(dir, { recursive: true, force: true });
231+
}
232+
},
233+
);
234+
105235
it("refuses an ambiguous reference", () => {
106236
const dir = project();
107237
try {

‎packages/core/src/clipFacts.test.ts‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from "vitest";
2-
import { byStart, formatClipLine, type ClipFact } from "./clipFacts.js";
2+
import { byStart, formatClipLine, sameInstant, spansOverlap, type ClipFact } from "./clipFacts.js";
33

44
const clip = (over: Partial<ClipFact> = {}): ClipFact => ({
55
id: "a",
@@ -60,3 +60,28 @@ describe("byStart", () => {
6060
expect(rows.sort(byStart).map((r) => r.id)).toEqual(["a", "b", "c"]);
6161
});
6262
});
63+
64+
describe("spansOverlap", () => {
65+
it("counts clips that only meet as apart, though the float sum of the first overshoots", () => {
66+
expect(19.8 + 6.4).toBeGreaterThan(26.2);
67+
expect(spansOverlap(19.8, 19.8 + 6.4, 26.2, 29)).toBe(false);
68+
expect(spansOverlap(26.2, 29, 19.8, 19.8 + 6.4)).toBe(false);
69+
});
70+
71+
it("counts clips that share time as overlapping, down to half a microsecond", () => {
72+
expect(spansOverlap(0, 2, 1.9, 3)).toBe(true);
73+
expect(spansOverlap(1, 2, 0, 5)).toBe(true);
74+
expect(spansOverlap(0, 20 / 24 + 5e-7, 20 / 24, 20 / 24 + 0.25)).toBe(true);
75+
});
76+
});
77+
78+
describe("sameInstant", () => {
79+
it("joins times a float rounding step apart, and nothing wider", () => {
80+
expect(sameInstant(19.8 + 6.4, 26.2)).toBe(true);
81+
expect(sameInstant(0.1 + 1.1, 1.2)).toBe(true);
82+
expect(sameInstant(3600.1 + 0.2, 3600.3)).toBe(true);
83+
expect(sameInstant(20 / 24 + 5e-7, 20 / 24)).toBe(false);
84+
expect(sameInstant(1.0000005, 1)).toBe(false);
85+
expect([sameInstant(5, Infinity), sameInstant(Infinity, Infinity)]).toEqual([false, true]);
86+
});
87+
});

‎packages/core/src/clipFacts.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,18 @@ const roundTo3 = (n: number) => Math.round(n * 1000) / 1000;
3434

3535
const num = (n: number) => String(roundTo3(n));
3636

37+
/** Float sums like 19.8 + 6.4 miss 26.2 by a rounding step; a few steps, scaled to the time, are one instant. */
38+
export function sameInstant(a: number, b: number): boolean {
39+
if (!Number.isFinite(a) || !Number.isFinite(b)) return a === b;
40+
return Math.abs(a - b) <= 4 * Number.EPSILON * Math.max(1, Math.abs(a), Math.abs(b));
41+
}
42+
43+
const isBefore = (a: number, b: number) => a < b && !sameInstant(a, b);
44+
45+
export function spansOverlap(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean {
46+
return isBefore(aStart, bEnd) && isBefore(bStart, aEnd);
47+
}
48+
3749
export function formatClipLine(clip: ClipFact): string {
3850
const parts = [
3951
`${clip.kind} "${clip.id}"`,

‎packages/core/src/runtime/clipWindow.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@ describe("isInClipWindow", () => {
55
it("includes the start and excludes the end", () => {
66
expect([1, 1.5, 2].map((t) => isInClipWindow(t, 1, 2))).toEqual([true, true, false]);
77
});
8+
9+
it("hands the instant a float sum misses by a rounding step to the next clip only", () => {
10+
expect([isInClipWindow(26.2, 19.8, 19.8 + 6.4), isInClipWindow(26.2, 26.2, 28.2)]).toEqual([
11+
false,
12+
true,
13+
]);
14+
expect(isInClipWindow(20 / 24, 0, 20 / 24 + 5e-7)).toBe(true);
15+
});
16+
17+
it("keeps a clip with no known end in its window", () => {
18+
expect(isInClipWindow(5, 0, Number.POSITIVE_INFINITY)).toBe(true);
19+
});
820
});
921

1022
describe("isClipVisibleAt", () => {

‎packages/core/src/runtime/clipWindow.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1-
/** Half-open: two back-to-back clips never both hold the shared boundary instant. */
1+
import { sameInstant } from "../clipFacts";
2+
3+
const hasStarted = (time: number, start: number) => time >= start || sameInstant(time, start);
4+
5+
/** Half-open: two back-to-back clips never both hold the shared boundary instant, float sums included. */
26
export const isInClipWindow = (time: number, start: number, end: number): boolean =>
3-
time >= start && time < end;
7+
hasStarted(time, start) && time < end && !sameInstant(time, end);
48

59
const TERMINAL_EPSILON_SECONDS = 1e-6;
610

@@ -15,6 +19,6 @@ export const isClipVisibleAt = (
1519
compositionDuration: number,
1620
): boolean =>
1721
isInClipWindow(time, start, end) ||
18-
(time >= start &&
22+
(hasStarted(time, start) &&
1923
compositionDuration > 0 &&
2024
end >= compositionDuration - TERMINAL_EPSILON_SECONDS);

‎packages/core/src/runtime/media.test.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,14 @@ describe("syncRuntimeMedia", () => {
376376
document.body.innerHTML = "";
377377
});
378378

379+
it("seeks a clip whose start is a float sum to its first frame, never before it", () => {
380+
const clip = createMockClip({ start: 19.8 + 0.1, end: 25 });
381+
Object.defineProperty(clip.el, "readyState", { value: 4, writable: true });
382+
clip.el.currentTime = 3;
383+
syncRuntimeMedia({ clips: [clip], timeSeconds: 19.9, playing: false, playbackRate: 1 });
384+
expect(clip.el.currentTime).toBe(0);
385+
});
386+
379387
describe("speed ramp", () => {
380388
it("seeks to the integrated source time and plays at the instantaneous rate", () => {
381389
const rate = {

‎packages/core/src/runtime/media.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,8 @@ export function syncRuntimeMedia(params: {
327327
params.timeSeconds >= clip.end &&
328328
isClipVisibleAt(params.timeSeconds, clip.start, clip.end, params.getCompositionDuration());
329329
let relTime =
330-
sourceTimeAt(clipRate, Math.min(params.timeSeconds, clip.end) - clip.start) + clip.mediaStart;
330+
sourceTimeAt(clipRate, Math.max(0, Math.min(params.timeSeconds, clip.end) - clip.start)) +
331+
clip.mediaStart;
331332
const isHeldVideoTail =
332333
isTerminalVideo ||
333334
(isNonLoopVideo && clip.sourceDuration != null && relTime >= clip.sourceDuration && inWindow);

‎packages/studio-server/src/helpers/duplicateElement.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { sameInstant } from "@hyperframes/core/clip-facts";
12
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
23
import {
34
findTargetElement,
@@ -59,9 +60,10 @@ function rippleElements(
5960
continue;
6061
}
6162
const start = numericAttribute(candidate, "data-start");
62-
if (start !== null && start >= at) {
63-
candidate.setAttribute("data-start", String(start + duration));
64-
}
63+
if (start === null) continue;
64+
const metInsertionPoint = sameInstant(start, at);
65+
if (start < at && !metInsertionPoint) continue;
66+
candidate.setAttribute("data-start", String((metInsertionPoint ? at : start) + duration));
6567
}
6668
}
6769

‎packages/studio/src/player/components/timelineStackingSync.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
* style) and apply the returned `StackingPatch[]` however they persist styles.
1818
*/
1919

20+
import { spansOverlap } from "@hyperframes/core/clip-facts";
21+
2022
/** Minimal element view this module reasons over. */
2123
export interface StackingElement {
2224
/** Stable identity (TimelineElement.key ?? id). */
@@ -69,8 +71,6 @@ export interface StackingPatch {
6971
zIndex: number;
7072
}
7173

72-
const EPS = 1e-6;
73-
7474
/**
7575
* Canonical paint-scope key: leaf z-indexes are comparable only within the same
7676
* source document and CSS stacking context. The ONLY place this normalization
@@ -90,17 +90,17 @@ export function samePaintScope(
9090
/**
9191
* Two clips overlap in time when their half-open [start, end) intervals intersect.
9292
*
93-
* NOTE the `- EPS`: this DELIBERATELY diverges from `timeRangesOverlap`'s exact
93+
* NOTE the float slack: this DELIBERATELY diverges from `timeRangesOverlap`'s exact
9494
* strict-`<` (timelineCollision.ts). A boolean collision decision is idempotent, so
9595
* exact `<` is fine there; here the result drives a VISIBLE stacking re-lane, so the
96-
* epsilon guards against float fuzz (e.g. 5.0000001 vs 5) spuriously overlapping two
96+
* epsilon guards against float fuzz (e.g. 19.8 + 6.4 vs 26.2) spuriously overlapping two
9797
* abutting clips and shuffling lanes. The two are intended to differ, not align.
9898
*/
9999
function overlapsInTime(
100100
a: Pick<StackingElement, "start" | "duration">,
101101
b: Pick<StackingElement, "start" | "duration">,
102102
): boolean {
103-
return a.start < b.start + b.duration - EPS && b.start < a.start + a.duration - EPS;
103+
return spansOverlap(a.start, a.start + a.duration, b.start, b.start + b.duration);
104104
}
105105

106106
/**

0 commit comments

Comments
 (0)