mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(core): stop the runtime stamping timing onto an <hf-audio-group> bus
Where the phantom rows came from. The runtime stamps `data-start="0"` and `data-duration=<whole composition>` on every id'd child of the composition root "so they appear in the timeline even without animations" (init.ts, the `window.parent !== window` block). Its only skips were SCRIPT / STYLE / LINK, so an `<hf-audio-group>` got stamped too — which made it match the clip-manifest selector `[data-start], …`, so the bus entered `__clipManifest` as `kind: "element"`, `tagName: "hf-audio-group"`, 0 → 40s, and the studio drew it as an ordinary full-width clip row directly above the real group header. Observed on audio-real: 18 timeline elements, two of them `Voiceover|voiceover|hf-audio-group|manifest|0-40.0` and the same for `sfx`. 16 after this change, and the group rows and FX rack are unaffected. That row was draggable, trimmable and DELETABLE, and deleting it deletes the bus element — which is why deleting it took the group's automation lanes and its FX rack with it. Nothing was corrupted; the rack's subject was gone. `isTimelineIgnoredElement` in studio already excluded the tag with this exact reasoning, but it only guards the DOM-scan and implicit-layer paths. The bus arrived through the manifest, upstream of all of them, so the guard never saw it. Fixed at the source instead: both stamp loops now skip the tag. Not fixed: the `Stage` row in the same screenshot. That one is a real implicit layer for `<div class="stage">` — a visual container the author wrote — and it belongs in the timeline. `data-hf-ignore` on such a wrapper suppresses its row. Regression test asserts the bus keeps no timing while an id'd sibling still gets stamped; verified it fails on a revert. It has to stage `window.parent !== window` because the stamp only runs inside the studio preview, and it lives inside the `initSandboxRuntimeModular` describe so it gets the DOM reset — outside it, a previous test's leftover root wins `resolveRootCompositionElement()` and nothing is stamped at all, which reads as a pass. core: 122 files, 2481 tests.
This commit is contained in:
@@ -11,55 +11,6 @@ it("schedules WebAudio element gain from author volume without bridge volume", (
|
|||||||
expect(source).not.toMatch(/vol\s*\*\s*state\.bridgeVolume/);
|
expect(source).not.toMatch(/vol\s*\*\s*state\.bridgeVolume/);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* `data-volume` is an authoring GAIN up to `MAX_AUDIO_GAIN` (12 dB ~ 3.98) —
|
|
||||||
* `HTMLMediaElement.volume` accepts only 0..1. The bridge clamps its own
|
|
||||||
* argument, but the PRODUCT `clipVolume * volume` was assigned unclamped, so a
|
|
||||||
* clip authored above unity threw
|
|
||||||
* `IndexSizeError: The volume provided (2.42103) is outside the range [0, 1]`
|
|
||||||
* (2.42103 is the +7.68 dB fader stop) — and the throw aborted the loop, so
|
|
||||||
* every media element after it kept its old volume too.
|
|
||||||
*/
|
|
||||||
it("clamps the native volume of an over-unity clip instead of throwing", () => {
|
|
||||||
const root = document.createElement("div");
|
|
||||||
root.setAttribute("data-composition-id", "main");
|
|
||||||
root.setAttribute("data-root", "true");
|
|
||||||
root.setAttribute("data-start", "0");
|
|
||||||
root.setAttribute("data-duration", "10");
|
|
||||||
root.setAttribute("data-width", "1920");
|
|
||||||
root.setAttribute("data-height", "1080");
|
|
||||||
document.body.appendChild(root);
|
|
||||||
|
|
||||||
const loud = document.createElement("audio");
|
|
||||||
loud.setAttribute("data-start", "0");
|
|
||||||
loud.setAttribute("data-duration", "10");
|
|
||||||
loud.setAttribute("data-volume", "2.42103");
|
|
||||||
loud.load = () => {};
|
|
||||||
root.appendChild(loud);
|
|
||||||
// Second element proves the throw took the whole sweep down with it, not just
|
|
||||||
// the offending clip.
|
|
||||||
const quiet = document.createElement("audio");
|
|
||||||
quiet.setAttribute("data-start", "0");
|
|
||||||
quiet.setAttribute("data-duration", "10");
|
|
||||||
quiet.setAttribute("data-volume", "0.5");
|
|
||||||
quiet.load = () => {};
|
|
||||||
root.appendChild(quiet);
|
|
||||||
|
|
||||||
window.__timelines = { main: createMockTimeline(10) };
|
|
||||||
initSandboxRuntimeModular();
|
|
||||||
|
|
||||||
const errors: string[] = [];
|
|
||||||
const onError = (e: ErrorEvent) => errors.push(String(e.message ?? e.error));
|
|
||||||
window.addEventListener("error", onError);
|
|
||||||
window.dispatchEvent(
|
|
||||||
new MessageEvent("message", {
|
|
||||||
data: { source: "hf-parent", type: "control", action: "set-volume", volume: 1 },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
window.removeEventListener("error", onError);
|
|
||||||
expect(errors).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
function createMockTimeline(duration: number): RuntimeTimelineLike {
|
function createMockTimeline(duration: number): RuntimeTimelineLike {
|
||||||
const state = { time: 0, paused: true, duration };
|
const state = { time: 0, paused: true, duration };
|
||||||
return {
|
return {
|
||||||
@@ -208,6 +159,99 @@ describe("initSandboxRuntimeModular", () => {
|
|||||||
window.cancelAnimationFrame = originalCancelAnimationFrame;
|
window.cancelAnimationFrame = originalCancelAnimationFrame;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `data-volume` is an authoring GAIN up to `MAX_AUDIO_GAIN` (12 dB ~ 3.98) —
|
||||||
|
* `HTMLMediaElement.volume` accepts only 0..1. The bridge clamps its own
|
||||||
|
* argument, but the PRODUCT `clipVolume * volume` was assigned unclamped, so a
|
||||||
|
* clip authored above unity threw
|
||||||
|
* `IndexSizeError: The volume provided (2.42103) is outside the range [0, 1]`
|
||||||
|
* (2.42103 is the +7.68 dB fader stop) — and the throw aborted the loop, so
|
||||||
|
* every media element after it kept its old volume too.
|
||||||
|
*/
|
||||||
|
it("clamps the native volume of an over-unity clip instead of throwing", () => {
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.setAttribute("data-composition-id", "main");
|
||||||
|
root.setAttribute("data-root", "true");
|
||||||
|
root.setAttribute("data-start", "0");
|
||||||
|
root.setAttribute("data-duration", "10");
|
||||||
|
root.setAttribute("data-width", "1920");
|
||||||
|
root.setAttribute("data-height", "1080");
|
||||||
|
document.body.appendChild(root);
|
||||||
|
|
||||||
|
const loud = document.createElement("audio");
|
||||||
|
loud.setAttribute("data-start", "0");
|
||||||
|
loud.setAttribute("data-duration", "10");
|
||||||
|
loud.setAttribute("data-volume", "2.42103");
|
||||||
|
loud.load = () => {};
|
||||||
|
root.appendChild(loud);
|
||||||
|
// Second element proves the throw took the whole sweep down with it, not just
|
||||||
|
// the offending clip.
|
||||||
|
const quiet = document.createElement("audio");
|
||||||
|
quiet.setAttribute("data-start", "0");
|
||||||
|
quiet.setAttribute("data-duration", "10");
|
||||||
|
quiet.setAttribute("data-volume", "0.5");
|
||||||
|
quiet.load = () => {};
|
||||||
|
root.appendChild(quiet);
|
||||||
|
|
||||||
|
window.__timelines = { main: createMockTimeline(10) };
|
||||||
|
initSandboxRuntimeModular();
|
||||||
|
|
||||||
|
const errors: string[] = [];
|
||||||
|
const onError = (e: ErrorEvent) => errors.push(String(e.message ?? e.error));
|
||||||
|
window.addEventListener("error", onError);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new MessageEvent("message", {
|
||||||
|
data: { source: "hf-parent", type: "control", action: "set-volume", volume: 1 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
window.removeEventListener("error", onError);
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The runtime stamps `data-start`/`data-duration` on every id'd child of the
|
||||||
|
* composition root so a blank canvas still shows selectable rows. An
|
||||||
|
* `<hf-audio-group>` is a mixer BUS, not a clip: stamping it put it in
|
||||||
|
* `__clipManifest` as a full-duration element, which the studio drew as an
|
||||||
|
* ordinary clip row above the real group header — draggable, trimmable, and
|
||||||
|
* deletable, and deleting it takes the bus (so the group's FX rack) with it.
|
||||||
|
*/
|
||||||
|
it("does not stamp timing onto an <hf-audio-group> bus", () => {
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.setAttribute("data-composition-id", "main");
|
||||||
|
root.setAttribute("data-root", "true");
|
||||||
|
root.setAttribute("data-start", "0");
|
||||||
|
root.setAttribute("data-duration", "10");
|
||||||
|
root.setAttribute("data-width", "1920");
|
||||||
|
root.setAttribute("data-height", "1080");
|
||||||
|
document.body.appendChild(root);
|
||||||
|
|
||||||
|
const bus = document.createElement("hf-audio-group");
|
||||||
|
bus.id = "voiceover";
|
||||||
|
bus.setAttribute("data-label", "Voiceover");
|
||||||
|
root.appendChild(bus);
|
||||||
|
|
||||||
|
// A plain id'd sibling proves the stamp still happens for everything else.
|
||||||
|
const caption = document.createElement("div");
|
||||||
|
caption.id = "cap-1";
|
||||||
|
root.appendChild(caption);
|
||||||
|
|
||||||
|
window.__timelines = { main: createMockTimeline(10) };
|
||||||
|
// The stamp only runs inside the studio preview (`window.parent !== window`),
|
||||||
|
// which jsdom is not — so the condition has to be staged for the test.
|
||||||
|
const realParent = window.parent;
|
||||||
|
Object.defineProperty(window, "parent", { value: {}, configurable: true });
|
||||||
|
try {
|
||||||
|
initSandboxRuntimeModular();
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(window, "parent", { value: realParent, configurable: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(bus.hasAttribute("data-start")).toBe(false);
|
||||||
|
expect(bus.hasAttribute("data-duration")).toBe(false);
|
||||||
|
expect(caption.getAttribute("data-start")).toBe("0");
|
||||||
|
});
|
||||||
|
|
||||||
it("resolves Studio hold as a deterministic step at the segment end", () => {
|
it("resolves Studio hold as a deterministic step at the segment end", () => {
|
||||||
const defaultEase = (progress: number) => progress;
|
const defaultEase = (progress: number) => progress;
|
||||||
const originalParseEase = vi.fn(() => defaultEase);
|
const originalParseEase = vi.fn(() => defaultEase);
|
||||||
|
|||||||
@@ -1461,6 +1461,14 @@ export function initSandboxRuntimeModular(): void {
|
|||||||
// scene container we auto-stamp below (e.g. an opacity-crossfaded scene)
|
// scene container we auto-stamp below (e.g. an opacity-crossfaded scene)
|
||||||
// must NOT suppress its own animated children — otherwise those children
|
// must NOT suppress its own animated children — otherwise those children
|
||||||
// never become timeline clips and that scene can't inline-expand.
|
// never become timeline clips and that scene can't inline-expand.
|
||||||
|
// A bus is not a clip. `<hf-audio-group>` carries a group's label, fader,
|
||||||
|
// mute and FX chain and has no timing of its own, so stamping it put it in
|
||||||
|
// `__clipManifest` as a full-duration element — which the studio drew as an
|
||||||
|
// ordinary clip row above the real group header. That row was draggable,
|
||||||
|
// trimmable and deletable, and deleting it removed the bus, taking the
|
||||||
|
// group's automation lanes and FX rack with it.
|
||||||
|
const isAudioGroupBus = (el: Element): boolean =>
|
||||||
|
el.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG;
|
||||||
const authoredTimed = new Set<Element>(document.querySelectorAll("[data-start]"));
|
const authoredTimed = new Set<Element>(document.querySelectorAll("[data-start]"));
|
||||||
const hasAuthoredTimedAncestor = (element: HTMLElement): boolean => {
|
const hasAuthoredTimedAncestor = (element: HTMLElement): boolean => {
|
||||||
let node = element.parentElement;
|
let node = element.parentElement;
|
||||||
@@ -1479,6 +1487,7 @@ export function initSandboxRuntimeModular(): void {
|
|||||||
for (const target of child.targets()) {
|
for (const target of child.targets()) {
|
||||||
if (!(target instanceof HTMLElement)) continue;
|
if (!(target instanceof HTMLElement)) continue;
|
||||||
if (target === rootComp) continue;
|
if (target === rootComp) continue;
|
||||||
|
if (isAudioGroupBus(target)) continue;
|
||||||
if (target.hasAttribute("data-start")) continue;
|
if (target.hasAttribute("data-start")) continue;
|
||||||
if (hasAuthoredTimedAncestor(target)) continue;
|
if (hasAuthoredTimedAncestor(target)) continue;
|
||||||
if (seen.has(target)) continue;
|
if (seen.has(target)) continue;
|
||||||
@@ -1506,6 +1515,7 @@ export function initSandboxRuntimeModular(): void {
|
|||||||
if (hasAuthoredTimedAncestor(el)) continue;
|
if (hasAuthoredTimedAncestor(el)) continue;
|
||||||
if (seen.has(el)) continue;
|
if (seen.has(el)) continue;
|
||||||
if (el.tagName === "SCRIPT" || el.tagName === "STYLE" || el.tagName === "LINK") continue;
|
if (el.tagName === "SCRIPT" || el.tagName === "STYLE" || el.tagName === "LINK") continue;
|
||||||
|
if (isAudioGroupBus(el)) continue;
|
||||||
seen.add(el);
|
seen.add(el);
|
||||||
el.setAttribute("data-start", "0");
|
el.setAttribute("data-start", "0");
|
||||||
el.setAttribute("data-duration", dur);
|
el.setAttribute("data-duration", dur);
|
||||||
|
|||||||
Reference in New Issue
Block a user