Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions packages/core/src/runtime/colorGrading.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2203,6 +2203,49 @@ describe("createColorGradingRuntime", () => {
expect(video.hasAttribute("data-hf-color-grading-source-hidden")).toBe(true);
expect(video.style.getPropertyValue("opacity")).toBe("0");
});

it("does not rescan the document when only text changes", async () => {
startRuntimeWithVideo();
const caption = document.createElement("p");
document.body.appendChild(caption);
await Promise.resolve();
const scan = vi.spyOn(document, "querySelectorAll");
caption.textContent = "Hello";
caption.append(document.createElement("span"));
await Promise.resolve();
expect(
scan.mock.calls.filter(([selector]) => selector.includes(HF_COLOR_GRADING_ATTR)),
).toEqual([]);
});

it("still grades media inserted later and drops media removed", async () => {
startRuntimeWithVideo();
const wrapper = document.createElement("section");
const later = makeDrawableVideo();
later.id = "later-video";
wrapper.appendChild(later);
document.body.appendChild(wrapper);
await Promise.resolve();
expect(runtime!.isGraded(later)).toBe(true);
wrapper.remove();
await Promise.resolve();
expect(runtime!.isGraded(later)).toBe(false);
});

it("drops media graded through the API when a wrapper holding it is removed", async () => {
startRuntimeWithVideo();
const wrapper = document.createElement("section");
const live = makeDrawableVideo();
live.id = "live-video";
live.removeAttribute(HF_COLOR_GRADING_ATTR);
wrapper.appendChild(live);
document.body.appendChild(wrapper);
runtime!.setGrading(live, { adjust: { exposure: 0.5 } });
expect(runtime!.isGraded(live)).toBe(true);
wrapper.remove();
await Promise.resolve();
expect(runtime!.isGraded(live)).toBe(false);
});
});

describe("createColorGradingRuntime after a context loss", () => {
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/runtime/colorGrading.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
import { copyMediaVisualStyles } from "../inline-scripts/parityContract";
import { readVariablesForElement } from "./variableScope";
import { swallow } from "./diagnostics";
import { createRecordFilter } from "./mutationRecords";
import { findInjectedRenderFrame } from "./renderFrameSibling";
import {
isCanvasElement,
Expand Down Expand Up @@ -4276,7 +4277,12 @@ export function createColorGradingRuntime(pausedMediaLease?: {
};

if (document.body) {
observer = new MutationObserver(() => refresh());
const recordsTouchGrading = createRecordFilter(COLOR_GRADING_SOURCE_SELECTOR, (node) =>
Array.from(trackedElements).some((element) => node.contains(element)),
);
observer = new MutationObserver((records) => {
if (recordsTouchGrading(records)) refresh();
});
observer.observe(document.body, {
childList: true,
subtree: true,
Expand Down
92 changes: 92 additions & 0 deletions packages/core/src/runtime/init.timingMutations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
createMockTimeline,
resetRuntimeFixtureDom,
stubDuration,
} from "./runtimeSeekFixture.test-helpers";

// A media cache refresh with no element list is a full rebuild of the clip index.
const mediaSpy = vi.hoisted(() => ({ fullRebuilds: 0 }));

vi.mock("./media", async (importOriginal) => {
const actual = await importOriginal<typeof import("./media")>();
return {
...actual,
refreshRuntimeMediaCache: (params: Parameters<typeof actual.refreshRuntimeMediaCache>[0]) => {
if (!params?.elements) mediaSpy.fullRebuilds += 1;
return actual.refreshRuntimeMediaCache(params);
},
};
});

const { initSandboxRuntimeModular } = await import("./init");

describe("timing caches across DOM edits", () => {
const originalRequestAnimationFrame = window.requestAnimationFrame;
const originalCancelAnimationFrame = window.cancelAnimationFrame;
const frames: FrameRequestCallback[] = [];
const flushFrame = () => frames.splice(0, frames.length).forEach((callback) => callback(0));

/** Full clip-index rebuilds one seek and one frame cost after `change`. */
const rebuildsAfter = async (change: () => void): Promise<number> => {
// Settle what init and the previous change left pending.
window.__player?.seek(0.25);
flushFrame();
await Promise.resolve();
mediaSpy.fullRebuilds = 0;
change();
await Promise.resolve();
window.__player?.seek(0.5);
flushFrame();
return mediaSpy.fullRebuilds;
};

beforeEach(() => {
resetRuntimeFixtureDom();
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
frames.push(callback);
return frames.length;
}) as typeof window.requestAnimationFrame;
window.cancelAnimationFrame = (() => {}) as typeof window.cancelAnimationFrame;
document.body.innerHTML =
`<div data-composition-id="main" data-root="true">` +
`<video id="clip" data-start="0" data-duration="4"></video>` +
`<p class="caption">Hel</p></div>`;
stubDuration(document.querySelector("video")!, 4);
window.__timelines = { main: createMockTimeline(4) };
initSandboxRuntimeModular();
flushFrame();
});

afterEach(() => {
window.__hfRuntimeTeardown?.();
window.requestAnimationFrame = originalRequestAnimationFrame;
window.cancelAnimationFrame = originalCancelAnimationFrame;
frames.length = 0;
delete window.__player;
delete window.__playerReady;
});

it("keeps the clip index when only text changes", async () => {
const caption = document.querySelector(".caption")!;
expect(await rebuildsAfter(() => (caption.textContent = "Hello"))).toBe(0);
expect(await rebuildsAfter(() => caption.append(document.createElement("span")))).toBe(0);
});

it("rebuilds the clip index when a clip is added or removed, or its timing changes", async () => {
const root = document.querySelector("[data-composition-id]")!;
expect(
await rebuildsAfter(() => {
const next = document.createElement("video");
next.setAttribute("data-start", "2");
next.setAttribute("data-duration", "1");
stubDuration(next, 1);
root.append(next);
}),
).toBeGreaterThan(0);
expect(await rebuildsAfter(() => document.getElementById("clip")!.remove())).toBeGreaterThan(0);
expect(
await rebuildsAfter(() => root.querySelector("video")!.setAttribute("data-start", "1")),
).toBeGreaterThan(0);
});
});
27 changes: 20 additions & 7 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ import { parseStrictFiniteTimingNumber, resolveMediaElementDurationSeconds } fro
import { cutToHostSlots, MEDIA_START_BASIS_ATTR } from "../mediaTiming";
import { settleFirstFrameCompositionReadiness } from "../compositionReadiness";
import { AUTHORED_DURATION_ATTR, AUTHORED_END_ATTR } from "./authoredTiming";
import { createTimingRecordFilter } from "./mutationRecords";
import {
clearRuntimeData,
setRuntimeData,
Expand Down Expand Up @@ -1333,6 +1334,13 @@ export function initSandboxRuntimeModular(): void {
"id",
"src",
];
// Sizing inputs too: postTimeline applies them, and it runs when this revision moves.
const COMPOSITION_TIMING_ATTRIBUTES = [
...DURATION_FLOOR_INPUT_ATTRIBUTES,
"data-width",
"data-height",
];
const recordsMoveTiming = createTimingRecordFilter(COMPOSITION_TIMING_ATTRIBUTES);
const COMPOSITION_TIMING_MEDIA_EVENTS = ["loadedmetadata", "durationchange", "emptied"] as const;
let durationFloorsCache: DurationFloors | null = null;
let durationFloorsRevision = -1;
Expand All @@ -1351,7 +1359,8 @@ export function initSandboxRuntimeModular(): void {
const queryForTransport = (selector: string): Element[] => {
if (!compositionTimingObserver || renderCaptureSeekStarted)
return Array.from(document.querySelectorAll(selector));
if (compositionTimingObserver.takeRecords().length > 0) invalidateCompositionTimingCaches();
if (recordsMoveTiming(compositionTimingObserver.takeRecords()))
invalidateCompositionTimingCaches();
const cached = queryCache.get(selector);
if (cached?.revision === compositionTimingRevision) return cached.nodes;
const nodes = Array.from(document.querySelectorAll(selector));
Expand Down Expand Up @@ -1400,26 +1409,30 @@ export function initSandboxRuntimeModular(): void {
const pending = compositionTimingObserver?.takeRecords() ?? [];
write();
const ours = compositionTimingObserver?.takeRecords() ?? [];
if (pending.length > 0 || ours.some((record) => record.target !== el))
if (
recordsMoveTiming(pending) ||
recordsMoveTiming(ours.filter((record) => record.target !== el))
)
invalidateCompositionTimingCaches();
};

const withoutOwnCompositionRecords = (write: () => void) => {
const pending = compositionTimingObserver?.takeRecords() ?? [];
write();
compositionTimingObserver?.takeRecords();
if (pending.length > 0) invalidateCompositionTimingCaches();
if (recordsMoveTiming(pending)) invalidateCompositionTimingCaches();
};

const watchCompositionTimingInputs = () => {
if (compositionTimingObserver || typeof MutationObserver === "undefined") return;
compositionTimingObserver = new MutationObserver(invalidateCompositionTimingCaches);
compositionTimingObserver = new MutationObserver((records) => {
if (recordsMoveTiming(records)) invalidateCompositionTimingCaches();
});
compositionTimingObserver.observe(document.documentElement, {
childList: true,
subtree: true,
attributes: true,
// Sizing inputs too: postTimeline applies them, and it runs when this revision moves.
attributeFilter: [...DURATION_FLOOR_INPUT_ATTRIBUTES, "data-width", "data-height"],
attributeFilter: COMPOSITION_TIMING_ATTRIBUTES,
});
// Media duration changes are published as events, not DOM mutations, and
// they do not bubble — so listen in the capture phase, which reaches every
Expand Down Expand Up @@ -1451,7 +1464,7 @@ export function initSandboxRuntimeModular(): void {
// here makes the caches correct within a task, not just across tasks.
// Taking the records suppresses the callback for them, which is exactly
// equivalent since the callback only bumps the revision.
if (compositionTimingObserver && compositionTimingObserver.takeRecords().length > 0) {
if (compositionTimingObserver && recordsMoveTiming(compositionTimingObserver.takeRecords())) {
invalidateCompositionTimingCaches();
}
// `window.__timelines` is a plain object: no MutationObserver and no event
Expand Down
99 changes: 99 additions & 0 deletions packages/core/src/runtime/mutationRecords.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { afterEach, describe, expect, it } from "vitest";
import { createTimingRecordFilter } from "./mutationRecords";

const recordsMoveTiming = createTimingRecordFilter(["data-start", "data-duration", "id"]);

/** Records the timing observer would see for one synchronous change. */
const recordsOf = (change: (root: HTMLElement) => void): MutationRecord[] => {
const root = document.getElementById("root")!;
const observer = new MutationObserver(() => {});
observer.observe(root, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["data-start", "data-duration", "id"],
});
change(root);
const records = observer.takeRecords();
observer.disconnect();
return records;
};

describe("createTimingRecordFilter", () => {
afterEach(() => {
document.body.innerHTML = "";
});

const mount = () => {
document.body.innerHTML =
`<div id="root"><p class="caption">Hel</p>` +
`<div class="clip" data-start="1" data-duration="2"><span>a</span></div></div>`;
};

it("ignores text replaced inside an untimed element", () => {
mount();
const records = recordsOf((root) => {
root.querySelector(".caption")!.textContent = "Hello";
});
expect(records.length).toBeGreaterThan(0);
expect(recordsMoveTiming(records)).toBe(false);
});

it("ignores an inserted element that carries no timing", () => {
mount();
const records = recordsOf((root) => {
root.querySelector(".clip span")!.append(document.createElement("b"));
});
expect(recordsMoveTiming(records)).toBe(false);
});

it("counts an inserted clip, and a subtree holding one", () => {
mount();
expect(
recordsMoveTiming(
recordsOf((root) => {
const clip = document.createElement("div");
clip.setAttribute("data-start", "4");
root.append(clip);
}),
),
).toBe(true);
expect(
recordsMoveTiming(
recordsOf((root) => {
const wrapper = document.createElement("section");
wrapper.innerHTML = `<video></video>`;
root.append(wrapper);
}),
),
).toBe(true);
});

it("counts a removed clip", () => {
mount();
const records = recordsOf((root) => root.querySelector(".clip")!.remove());
expect(recordsMoveTiming(records)).toBe(true);
});

it("counts an inserted element another clip's start can name by id", () => {
mount();
const records = recordsOf((root) => {
const anchor = document.createElement("span");
anchor.id = "intro";
root.append(anchor);
});
expect(recordsMoveTiming(records)).toBe(true);
});

it("counts a timing attribute edit and an inserted script", () => {
mount();
expect(
recordsMoveTiming(
recordsOf((root) => root.querySelector(".clip")!.setAttribute("data-duration", "3")),
),
).toBe(true);
expect(
recordsMoveTiming(recordsOf((root) => root.append(document.createElement("script")))),
).toBe(true);
});
});
30 changes: 30 additions & 0 deletions packages/core/src/runtime/mutationRecords.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { HF_AUDIO_GROUP_TAG } from "../audioGroups";

// Read for timing beyond their attributes; scripts can register timelines.
const TIMING_ELEMENTS = ["video", "audio", "source", "script", HF_AUDIO_GROUP_TAG];

// Observers here pre-filter attribute records, so those always count. A child-list record counts
// only when a node it adds or removes matches or holds `selector`, or `holds` says it holds
// something the caller tracks; a text edit never does.
export function createRecordFilter(
selector: string,
holds: (node: Element) => boolean = () => false,
): (records: readonly MutationRecord[]) => boolean {
const touches = (node: Node): boolean =>
node.nodeType === Node.ELEMENT_NODE &&
((node as Element).matches(selector) ||
(node as Element).querySelector(selector) !== null ||
holds(node as Element));
return (records) =>
records.some(
(record) =>
record.type === "attributes" ||
Array.from(record.addedNodes).some(touches) ||
Array.from(record.removedNodes).some(touches),
);
}

export const createTimingRecordFilter = (timingAttributes: readonly string[]) =>
createRecordFilter(
[...TIMING_ELEMENTS, ...timingAttributes.map((name) => `[${name}]`)].join(","),
);
Loading