mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
fix(studio-server): previewAdapter getElementTimings ignores relative data-start refs
Same bug class as the SDK's getElementTimings fix (#2092): data-start can be a relative-reference expression ("intro", "intro + 2"), not just an absolute number. The old code did a raw parseFloat on it, so any reference silently resolved to undefined instead of an actual time. Also: this function never read data-duration at all (only data-start/data-end literally), so a reference to a duration-authored (not end-authored) clip was unresolvable regardless of the parseFloat bug — resolving a reference needs the target's END, which for a duration-authored clip requires start+duration. Both fixed together via the shared parseStartExpression grammar parser (@hyperframes/core/runtime/start-expression), with the same cycle-guard pattern as the SDK fix. Reference resolution against other elements is scoped to this file's existing findById (bare data-hf-id lookup). 6 new tests: duration-based end resolution, relative reference (with and without offset), missing target, and a mutual-cycle termination check.
This commit is contained in:
@@ -253,5 +253,60 @@ describe("T10 — PreviewAdapter contract (spec for R7)", () => {
|
||||
expect(timings["hf-notimed"].start).toBeUndefined();
|
||||
expect(timings["hf-notimed"].end).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves data-duration into end when there is no data-end (never worked before)", () => {
|
||||
make("div", { "data-hf-id": "hf-t1", "data-start": "1", "data-duration": "3" });
|
||||
const adapter = adapterWith(() => null);
|
||||
const timings = adapter.getElementTimings();
|
||||
expect(timings["hf-t1"]).toEqual({ start: 1, end: 4 });
|
||||
});
|
||||
|
||||
it("resolves a relative data-start reference ('ref + offset') instead of returning undefined", () => {
|
||||
make("div", { "data-hf-id": "hf-intro", "data-start": "1", "data-duration": "3" });
|
||||
make("div", { "data-hf-id": "hf-outro", "data-start": "hf-intro + 2", "data-duration": "1" });
|
||||
const adapter = adapterWith(() => null);
|
||||
const timings = adapter.getElementTimings();
|
||||
// hf-intro ends at 4 (1 + 3); hf-outro starts 2s after that = 6.
|
||||
expect(timings["hf-outro"]).toEqual({ start: 6, end: 7 });
|
||||
});
|
||||
|
||||
it("resolves a bare reference (no offset) to the referenced element's end", () => {
|
||||
make("div", { "data-hf-id": "hf-intro", "data-start": "1", "data-end": "4" });
|
||||
make("div", {
|
||||
"data-hf-id": "hf-right-after",
|
||||
"data-start": "hf-intro",
|
||||
"data-duration": "1",
|
||||
});
|
||||
const adapter = adapterWith(() => null);
|
||||
const timings = adapter.getElementTimings();
|
||||
expect(timings["hf-right-after"]).toEqual({ start: 4, end: 5 });
|
||||
});
|
||||
|
||||
it("returns undefined start (not NaN) when the reference target doesn't exist", () => {
|
||||
make("div", {
|
||||
"data-hf-id": "hf-orphan",
|
||||
"data-start": "hf-nonexistent + 5",
|
||||
"data-duration": "2",
|
||||
});
|
||||
const adapter = adapterWith(() => null);
|
||||
const timings = adapter.getElementTimings();
|
||||
expect(timings["hf-orphan"].start).toBeUndefined();
|
||||
});
|
||||
|
||||
it("terminates (not an infinite loop) on a mutual A <-> B reference cycle", () => {
|
||||
make("div", { "data-hf-id": "hf-a", "data-start": "hf-b", "data-duration": "2" });
|
||||
make("div", { "data-hf-id": "hf-b", "data-start": "hf-a", "data-duration": "3" });
|
||||
const adapter = adapterWith(() => null);
|
||||
// Unlike the SDK's static resolver (which fails safe to 0 and lets real
|
||||
// durations propagate outward into arbitrary-but-finite numbers), this
|
||||
// simpler resolver has no 0-fallback — an unresolvable `sv` poisons
|
||||
// `resolveEnd` (it requires a finite start to add duration), so the whole
|
||||
// cycle correctly reports "no defined timing" rather than a fabricated
|
||||
// number. The guard's job is just termination; this call must return
|
||||
// synchronously instead of hanging.
|
||||
const timings = adapter.getElementTimings();
|
||||
expect(timings["hf-a"].start).toBeUndefined();
|
||||
expect(timings["hf-b"].start).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
STUDIO_HEIGHT_PROP,
|
||||
STUDIO_MANUAL_EDIT_GESTURE_ATTR,
|
||||
} from "./draftMarkers.js";
|
||||
import { parseStartExpression } from "@hyperframes/core/runtime/start-expression";
|
||||
|
||||
export type DraftPayload =
|
||||
| { type: "move"; hfId: string; dx: number; dy: number }
|
||||
@@ -138,18 +139,66 @@ export function createPreviewAdapter(
|
||||
},
|
||||
|
||||
getElementTimings() {
|
||||
// data-start can be a relative-reference expression ("intro", "intro + 2" —
|
||||
// parseStartExpression's grammar), not just an absolute number. A raw
|
||||
// parseFloat on it (the old behavior) silently resolves any reference to
|
||||
// undefined. Resolve references recursively against the target's own
|
||||
// resolved end (data-end, or data-start + data-duration when the target
|
||||
// has no data-end) — this function never read data-duration before either,
|
||||
// so a reference to a duration-authored (not end-authored) clip used to be
|
||||
// unresolvable regardless of the parseFloat bug.
|
||||
const startCache = new Map<Element, number | undefined>();
|
||||
const visiting = new Set<Element>();
|
||||
|
||||
const resolveEnd = (el: Element): number | undefined => {
|
||||
const endAttr = el.getAttribute("data-end");
|
||||
if (endAttr !== null) {
|
||||
const ev = parseFloat(endAttr);
|
||||
if (Number.isFinite(ev)) return ev;
|
||||
}
|
||||
const durationAttr = el.getAttribute("data-duration");
|
||||
const dv = durationAttr !== null ? parseFloat(durationAttr) : NaN;
|
||||
const sv = resolveStart(el);
|
||||
if (Number.isFinite(dv) && sv !== undefined) return sv + dv;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Split out of resolveStart so its own branching stays low — mirrors the
|
||||
// SDK's getElementTimings resolver split (resolveReferenceStart).
|
||||
const resolveReferenceStart = (refId: string, offset: number): number | undefined => {
|
||||
const target = findById(refId);
|
||||
const targetEnd = target ? resolveEnd(target) : undefined;
|
||||
return targetEnd !== undefined ? Math.max(0, targetEnd + offset) : undefined;
|
||||
};
|
||||
|
||||
const resolveStart = (el: Element): number | undefined => {
|
||||
if (startCache.has(el)) return startCache.get(el);
|
||||
if (visiting.has(el)) return undefined; // reference cycle — fail safe, don't loop
|
||||
visiting.add(el);
|
||||
let resolved: number | undefined;
|
||||
try {
|
||||
const startStr = el.getAttribute("data-start");
|
||||
const expr = parseStartExpression(startStr);
|
||||
if (expr?.kind === "reference") {
|
||||
resolved = resolveReferenceStart(expr.refId, expr.offset);
|
||||
} else if (expr?.kind === "absolute") {
|
||||
resolved = expr.value;
|
||||
} else {
|
||||
const sv = startStr !== null ? parseFloat(startStr) : NaN;
|
||||
resolved = Number.isFinite(sv) ? sv : undefined;
|
||||
}
|
||||
} finally {
|
||||
visiting.delete(el);
|
||||
}
|
||||
startCache.set(el, resolved);
|
||||
return resolved;
|
||||
};
|
||||
|
||||
const result: Record<string, { start?: number; end?: number }> = {};
|
||||
for (const el of doc.querySelectorAll("[data-hf-id]")) {
|
||||
const hfId = el.getAttribute("data-hf-id");
|
||||
if (!hfId) continue;
|
||||
const s = el.getAttribute("data-start");
|
||||
const e = el.getAttribute("data-end");
|
||||
const sv = s !== null ? parseFloat(s) : NaN;
|
||||
const ev = e !== null ? parseFloat(e) : NaN;
|
||||
result[hfId] = {
|
||||
start: Number.isFinite(sv) ? sv : undefined,
|
||||
end: Number.isFinite(ev) ? ev : undefined,
|
||||
};
|
||||
result[hfId] = { start: resolveStart(el), end: resolveEnd(el) };
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user