perf(runtime): swap edited scenes into the running preview from a rebuilt document (#4502)

* perf(runtime): swap edited scenes in place from a rebuilt preview document

window.__hfSwapScenes(html) compares the preview manifests. When the rebuilt document differs
only inside existing scenes, it replaces each changed scene's host, styles and scripts, drops
the old scene timelines (nested ones included), reruns the passes boot runs on scene DOM,
releases the old media, rebinds and keeps the playhead. Anything else rejects before touching
the film, so the caller does its full reload.

* fix(runtime): refuse unsafe scene swaps and redraw the swapped scene like a seek

The swap now refuses scenes marked unswappable or with more than one
host, keeps the style order, lets adapters find the new scene, redraws
through the transport seek and rejects when torn down mid-swap.

* fix(runtime): scope swap caption overrides to the swapped scene and swap only manifest pages

Other scenes kept re-dimmed caption words after a swap, and pages without
a scene manifest offered a swap that could only be refused.

* fix(runtime): repaint caption colours that were overridden after their tween rendered

A swapped scene, or a film reloaded mid-way, rendered its caption tweens
before the overrides rewrote their colours, so words kept the old colour.

* fix(runtime): repaint caption colours of a swapped scene the playhead has passed

* refactor(core): cut scene swap comments to what names and types do not say

* fix(runtime): a scene swap keeps other media loaded and waits for new media by reloading

* fix(runtime): a scene swap resolves bound and proxied media before refusing

The new-media check compared the new scene raw src against the live DOM, which variable
bindings and the media proxy have already rewritten. A scene with a customised data-var-src
logo, or a proxied video, was refused on every edit. Both sides now resolve src the same way:
the bound variable value, else the src attribute before any proxy swap, which the proxy now
records where it rewrites the src.

* fix(runtime): a scene swap binds variables only in the swapped scene, before media setup

The swap re-ran variable bindings over the whole document after binding media, so a proxied
video in any scene got its original, undecodable src back and a bound text in an untouched
scene was rewritten. Bindings now run only inside the swapped hosts and before media binding,
as boot does, and the src guard compares against the src the element had before its proxy.

* fix(runtime): a scene swap keeps unchanged video and audio playing

A text edit beside a video rebuilt the video from scratch, so it went blank until it loaded
again. A video or audio in the edited scene whose src matches one in the old scene (same src,
same order among those) is now the old element, moved into the new scene and given the new
element attributes and children, except src and the runtime-owned preload.

* fix(runtime): a scene swap refuses more new-media forms and names the scene

The new-media check missed uppercase URL(), image-set() and @import strings, SVG image href
and xlink:href, and object data, so a new asset could pop in after the swap. Its refusal now
says the media is new to this scene, which is what it checks. The existing inline style url(),
poster and srcset checks get tests of their own.

* fix(runtime): a scene swap loads caption overrides before changing the page

The swap wrote the new scene first and only then fetched caption-overrides.json, so a stalled
request left the preview half swapped when the caller gave up and reloaded. The overrides are
now fetched before the first write and applied from the fetched data. A swap that another swap
finished first while it waited now refuses, since its document is the older one.

* refactor(runtime): keep the proxy src record within the comment share

* fix(runtime): a scene swap keeps media only when the edit left it as written and ungraded

Keeping a live video or audio and copying the new attributes onto it was not the same as a
fresh load: muted only sets the initial state, and a graded video left its grading canvas in
the discarded scene, so it went black. The swap now keeps an element only when its authored
markup, captured before the runtime writes to it, equals the new one, and the grading runtime
says it is not graded. Anything else is rebuilt fresh; the attribute copy is gone.

* fix(runtime): a waiting scene swap refuses after any other swap changed the page

The check compared the scene manifest, which misses a scene changed and changed back while a
captioned swap waited for its overrides; that swap then went on with stale elements and wrote a
wrong manifest. A generation count the swap owns now moves on every page change by a swap, so
any intervening swap makes the waiting one refuse.

* fix(runtime): a scene swap warns about a non-media data-var-src once, as a load does

The new-media scan resolved every bound src through the warning path, so each swap logged the
ignored data-var-src again for the old and the new element. The scan now resolves silently and
only the bindings pass warns, once for the new element.

* refactor(runtime): keep the grading query within the comment share

* fix(runtime): a scene swap replaces each of a scene's styles in its own place

The bundle can now give one scene several style runs separated by another scene's, when its
nested scene comes later in source order. The swap put all new styles before the first old one,
so a later same-named @keyframes moved ahead of the other scene's and the cascade differed from a
fresh load and the render. Each new style now replaces its old counterpart in place, and a scene
whose style count differs is refused before any write.

* test(core): check the bundled stylesheets, not the inlined runtime, for a leftover @import

* fix(runtime): a video kept through a scene swap starts its new tweens from its authored values

* fix(runtime): a from() caption colour tween with an override still lights its word up

* fix(runtime): a scene swap reverts the old timelines, so a kept video shows the edited CSS

* fix(runtime): a caption word with a dim-colour override still lights up when spoken

* fix(runtime): a word whose colour override lands late still looks dim before it is spoken

* fix(runtime): a caption override keeps class highlights and resolves var() colours per word
This commit is contained in:
Miguel Ángel
2026-09-26 13:18:17 +00:00
committed by GitHub
parent 162de1a6ab
commit cb8e7995a2
13 changed files with 1401 additions and 209 deletions
@@ -27,6 +27,9 @@ function makeTempProject(files: Record<string, string>): string {
* resolved to the right file: resolving from the wrong base directory finds no
* file at all, so nothing is inlined and the assertion fails.
*/
const styleText = (html: string) =>
[...html.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)].map((m) => m[1]).join("\n");
function inlinedAs(mime: string, content: string): string {
return `data:${mime};base64,${Buffer.from(content, "utf-8").toString("base64")}`;
}
@@ -1184,7 +1187,7 @@ describe("bundleToSingleHtml", () => {
const bundled = await bundleToSingleHtml(dir);
expect(bundled).toContain("--brand: #ff5728");
expect(bundled).not.toContain("@import");
expect(styleText(bundled)).not.toContain("@import");
expect(bundled).toContain("margin: 0");
});
@@ -1273,7 +1276,7 @@ describe("bundleToSingleHtml", () => {
expect(bundled).toContain("--tk-teal: #1a3540");
expect(bundled).toContain("display: flex");
expect(bundled).toContain("color: red");
expect(bundled).not.toContain("@import");
expect(styleText(bundled)).not.toContain("@import");
});
it("wraps @import with media query in @media block", async () => {
@@ -1292,7 +1295,7 @@ describe("bundleToSingleHtml", () => {
expect(bundled).toContain("@media print");
expect(bundled).toContain("display: block");
expect(bundled).not.toContain("@import");
expect(styleText(bundled)).not.toContain("@import");
});
it("preserves @import for absolute URLs", async () => {
@@ -1329,7 +1332,7 @@ describe("bundleToSingleHtml", () => {
expect(bundled).toContain(`url('${inlinedAs("font/woff2", "fake-font-data")}')`);
expect(bundled).not.toContain("url('assets/fonts/brand.woff2')");
expect(bundled).not.toContain("@import");
expect(styleText(bundled)).not.toContain("@import");
});
it("rebases url() paths in <link>-inlined CSS from subdirectories", async () => {
@@ -1525,7 +1528,7 @@ describe("bundleToSingleHtml", () => {
expect(sharedCount).toBe(1);
expect(bundled).toContain(".a { color: red; }");
expect(bundled).toContain(".b { color: blue; }");
expect(bundled).not.toContain("@import");
expect(styleText(bundled)).not.toContain("@import");
});
it("does not resolve @import inside CSS comments", async () => {
@@ -1,7 +1,7 @@
/**
* @vitest-environment jsdom
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { applyVariableBindings } from "./applyVariableBindings";
import { getVariables } from "./getVariables";
@@ -176,3 +176,14 @@ describe("applyVariableBindings", () => {
});
});
});
describe("applyVariableBindings media src", () => {
it("leaves an unchanged src alone, since rewriting it restarts the media's load", () => {
setDeclared([{ id: "clip", type: "string", label: "Clip", default: "clip.mp4" }]);
document.body.innerHTML = '<video data-var-src="clip" src="clip.mp4"></video>';
const video = document.querySelector("video")!;
const writes = vi.spyOn(video, "setAttribute");
applyVariableBindings(document);
expect(writes).not.toHaveBeenCalledWith("src", expect.anything());
});
});
@@ -33,6 +33,7 @@ import {
isSafeMediaUrl,
} from "@hyperframes/parsers/composition";
import { isHtmlElement } from "./domRealm";
import { unproxiedSrc } from "./proxySrc";
// data-var-src only rebinds media `src` on media elements. A user-controlled
// variable value assigned to a src is an XSS surface on tags whose src executes
@@ -131,16 +132,9 @@ function findTopRoot(doc: Document): Element | null {
);
}
function applyCssCustomProperties(doc: Document, cache: ScopeValuesCache): void {
// Top-level root plus every inlined sub-composition root; custom props
// inherit, so descendants of each root see its scope's values.
const roots = new Set<Element>();
const topRoot = findTopRoot(doc);
if (topRoot) roots.add(topRoot);
for (const el of Array.from(doc.querySelectorAll("[data-composition-id]"))) {
roots.add(el);
}
for (const root of roots) {
// Custom props inherit, so each composition root carries its own scope's values.
function applyCssCustomProperties(roots: Iterable<Element>, cache: ScopeValuesCache): void {
for (const root of new Set(roots)) {
const values = valuesForElement(root, cache);
for (const [id, value] of Object.entries(values)) {
const css = cssValueFor(value);
@@ -151,31 +145,48 @@ function applyCssCustomProperties(doc: Document, cache: ScopeValuesCache): void
}
}
export function applyVariableBindings(doc: Document): void {
const cache: ScopeValuesCache = new Map();
applyCssCustomProperties(doc, cache);
for (const el of Array.from(doc.querySelectorAll("[data-var-src]"))) {
const id = el.getAttribute("data-var-src")?.trim();
if (!id) continue;
// Only media elements may take a variable-driven src (see VAR_SRC_TAGS) — a
// src on <iframe>/<script>/<embed> is a code-execution sink, not a media ref.
if (!VAR_SRC_TAGS.has(el.tagName.toLowerCase())) {
function variableSrcFor(el: Element, cache: ScopeValuesCache, warn = true): string | null {
const id = el.getAttribute("data-var-src")?.trim();
if (!id) return null;
// Only media elements may take a variable-driven src (see VAR_SRC_TAGS) — a
// src on <iframe>/<script>/<embed> is a code-execution sink, not a media ref.
if (!VAR_SRC_TAGS.has(el.tagName.toLowerCase())) {
if (warn)
console.warn(
`[hyperframes] Ignoring data-var-src on <${el.tagName.toLowerCase()}>: variable-bound src is only allowed on ${Array.from(VAR_SRC_TAGS).join("/")}.`,
);
continue;
}
const url = resolveUrl(valuesForElement(el, cache)[id]);
if (url === null) continue;
if (!isSafeMediaUrl(url)) {
console.warn(`[hyperframes] Ignoring data-var-src="${id}": unsafe URL protocol.`);
continue;
}
el.setAttribute("src", url);
return null;
}
const url = resolveUrl(valuesForElement(el, cache)[id]);
if (url === null) return null;
if (!isSafeMediaUrl(url)) {
if (warn) console.warn(`[hyperframes] Ignoring data-var-src="${id}": unsafe URL protocol.`);
return null;
}
return url;
}
/** The src an element loads, preview proxy aside: its bound variable's value, else its attribute. */
export function unproxiedMediaSrc(el: Element): string | null {
return variableSrcFor(el, new Map(), false) ?? unproxiedSrc(el);
}
/** Applies the bindings in `doc`, or only those inside `within` and `within` itself. */
export function applyVariableBindings(doc: Document, within?: Element): void {
const cache: ScopeValuesCache = new Map();
const all = (selector: string): Element[] =>
within
? [...(within.matches(selector) ? [within] : []), ...within.querySelectorAll(selector)]
: Array.from(doc.querySelectorAll(selector));
const topRoot = within ? null : findTopRoot(doc);
applyCssCustomProperties([...(topRoot ? [topRoot] : []), ...all("[data-composition-id]")], cache);
for (const el of all("[data-var-src]")) {
const url = variableSrcFor(el, cache);
if (url !== null && unproxiedSrc(el) !== url) el.setAttribute("src", url);
}
for (const el of Array.from(doc.querySelectorAll("[data-var-text]"))) {
for (const el of all("[data-var-text]")) {
const id = el.getAttribute("data-var-text")?.trim();
if (!id) continue;
const value = valuesForElement(el, cache)[id];
@@ -37,7 +37,9 @@ function installGsapMock() {
/** A gsap mock whose `getTweensOf` returns the supplied colour tweens, so classification is testable. */
function installGsapMockWithTweens(tweens: Array<Record<string, unknown>>) {
const gsap = {
set() {},
set(target: HTMLElement, vars: Record<string, unknown>) {
if (typeof vars.color === "string") target.style.color = vars.color;
},
killTweensOf() {},
getTweensOf() {
return tweens.map((vars, i) => ({ vars, startTime: () => i }));
@@ -57,6 +59,7 @@ afterEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
vi.unstubAllGlobals();
document.head.innerHTML = "";
document.body.innerHTML = "";
Reflect.deleteProperty(window, "gsap");
});
@@ -181,17 +184,110 @@ describe("applyCaptionOverrides", () => {
});
});
/** Mounts one caption word whose stylesheet (rest) colour is `rest`. */
function mountWord(rest: string) {
document.head.innerHTML = `<style>#w0 { color: ${rest}; }</style>`;
document.body.innerHTML = `<div class="caption-group"><span id="w0">Hi</span></div>`;
}
const SHIPPED_REST = "rgba(255, 255, 255, 0.55)";
describe("caption colour classification", () => {
it.each([
[{ dimColor: "#888", activeColor: "#fff" }, "#fff"],
[{ dimColor: "#888" }, "#ffffff"],
])("lights up a word whose only tween leaves its rest colour, with %o", async (override, lit) => {
// The shape the shipped caption scripts emit: dim by CSS, one tween to white when spoken.
const tweens = installGsapMockWithTweens([{ color: "#ffffff" }]);
installCaptionOverrideFetch([{ wordIndex: 0, ...override }]);
mountWord(SHIPPED_REST);
applyCaptionOverrides();
await flushCaptionOverrides();
expect(tweens[0].color).toBe(lit);
});
it("classifies a return to the rest colour as dim, however the colour is spelled", async () => {
const tweens = installGsapMockWithTweens([
{ color: "white" },
{ color: "rgba(255,255,255,.55)" },
]);
installCaptionOverrideFetch([{ wordIndex: 0, dimColor: "#888", activeColor: "#0f0" }]);
mountWord(SHIPPED_REST);
applyCaptionOverrides();
await flushCaptionOverrides();
expect(tweens.map((tw) => tw.color)).toEqual(["#0f0", "#888"]);
});
it("lights up a fromTo() from a dim colour into the word's own stylesheet colour", async () => {
const tweens = installGsapMockWithTweens([{ color: "#fff", startAt: { color: "#444" } }]);
installCaptionOverrideFetch([{ wordIndex: 0, dimColor: "#888", activeColor: "#0f0" }]);
mountWord("#fff");
applyCaptionOverrides();
await flushCaptionOverrides();
expect(tweens[0].color).toBe("#0f0");
});
it("repaints a word the playhead already lit with its rest colour, so it is dim before it is spoken", async () => {
// Overrides can land after the word lit up; its invalidated tween re-reads its start from this.
const tweens = installGsapMockWithTweens([{ color: "#ffffff" }]);
installCaptionOverrideFetch([{ wordIndex: 0, activeColor: "#0f0" }]);
mountWord(SHIPPED_REST);
document.getElementById("w0")!.style.color = "#ffffff";
applyCaptionOverrides();
await flushCaptionOverrides();
expect(tweens[0].color).toBe("#0f0");
expect(document.getElementById("w0")!.style.color).toBe(SHIPPED_REST);
});
it("leaves the colour a from() tween has rendered on its word", async () => {
// The from() is mid-way (#a2a2a2) when the overrides land; its recorded start stays #444.
installGsapMockWithTweens([{ color: "#444", runBackwards: true }]);
installCaptionOverrideFetch([{ wordIndex: 0, activeColor: "#0f0" }]);
mountWord("#fff");
document.getElementById("w0")!.style.color = "#a2a2a2";
applyCaptionOverrides();
await flushCaptionOverrides();
expect(document.getElementById("w0")!.style.color).toBe("rgb(162, 162, 162)");
});
it("resolves each distinct tween colour once per apply, not once per word", async () => {
installGsapMockWithTweens([{ color: "#ffffff" }, { color: SHIPPED_REST }]);
// An override that leaves the colours as they are, so every word sees the same two.
installCaptionOverrideFetch(
[0, 1, 2].map((wordIndex) => ({ wordIndex, activeColor: "#ffffff" })),
);
document.head.innerHTML = `<style>span { color: ${SHIPPED_REST}; }</style>`;
document.body.innerHTML = `<div class="caption-group"><span></span><span></span><span></span></div>`;
const computed = vi.spyOn(window, "getComputedStyle");
applyCaptionOverrides();
await flushCaptionOverrides();
// One rest colour per word, plus the two tween colours.
expect(computed).toHaveBeenCalledTimes(3 + 2);
});
});
describe("caption state declaration", () => {
it("honours a declared state when dim and active colours are IDENTICAL", async () => {
// The failure the declaration exists for. Classification by colour equality takes the first
// tween's colour as the dim baseline, so a composition whose two states share a colour has
// every tween classified dim — and `activeColor` is silently dropped.
// Without the declaration both tweens return the word to its rest colour, so both read as dim
// and `activeColor` is silently dropped.
const tweens = installGsapMockWithTweens([
{ color: "#888", data: { captionState: "dim" } },
{ color: "#888", data: { captionState: "active" } },
]);
installCaptionOverrideFetch([{ wordIndex: 0, dimColor: "#111", activeColor: "#eee" }]);
document.body.innerHTML = `<div class="caption-group"><span id="w0">Hi</span></div>`;
mountWord("#888");
applyCaptionOverrides();
await flushCaptionOverrides();
@@ -200,77 +296,84 @@ describe("caption state declaration", () => {
expect(tweens[1].color).toBe("#eee");
});
it("falls back to colour classification when nothing is declared", async () => {
// Undeclared compositions must keep working exactly as before — the declaration is additive.
const tweens = installGsapMockWithTweens([{ color: "#222" }, { color: "#fff" }]);
installCaptionOverrideFetch([{ wordIndex: 0, dimColor: "#111", activeColor: "#eee" }]);
it("invalidates every colour tween on the word, so each re-reads its start from the dimmed word", async () => {
const tween = (color: string, at: number) => ({
vars: { color },
startTime: () => at,
invalidate: vi.fn(),
});
const rendered = [tween("#fff", 0), tween("#222", 1)];
const gsap = { set() {}, killTweensOf() {}, getTweensOf: () => rendered };
Object.defineProperty(window, "gsap", { configurable: true, value: gsap });
installCaptionOverrideFetch([{ wordIndex: 0, dimColor: "#111" }]);
mountWord("#222");
applyCaptionOverrides();
await flushCaptionOverrides();
expect(rendered.map((tw) => [tw.vars.color, tw.invalidate.mock.calls.length])).toEqual([
["#fff", 1],
["#111", 1],
]);
});
it("leaves a from() colour tween as recorded, so it still lights the word up to its own colour", async () => {
const from = {
vars: { color: "#444", runBackwards: true },
startTime: () => 0,
invalidate: vi.fn(),
};
const gsap = { set() {}, killTweensOf() {}, getTweensOf: () => [from] };
Object.defineProperty(window, "gsap", { configurable: true, value: gsap });
installCaptionOverrideFetch([{ wordIndex: 0, dimColor: "#888" }]);
document.body.innerHTML = `<div class="caption-group"><span id="w0">Hi</span></div>`;
applyCaptionOverrides();
await flushCaptionOverrides();
expect(tweens[0].color).toBe("#111");
expect(tweens[1].color).toBe("#eee");
expect(from.invalidate).not.toHaveBeenCalled();
});
it("prefers the declaration over the colour heuristic when they disagree", async () => {
// Declared order is deliberately the reverse of what colour-equality would infer.
it("leaves a word with no colour tween to its own classes under an active-only override", async () => {
const set = vi.fn((el: HTMLElement, vars: Record<string, unknown>) => {
if (vars.color) el.style.color = String(vars.color);
});
Object.defineProperty(window, "gsap", {
configurable: true,
value: { set, killTweensOf() {}, getTweensOf: () => [] },
});
installCaptionOverrideFetch([{ wordIndex: 0, activeColor: "#ff0" }]);
document.body.innerHTML = `<style>.w { color: #888; }</style><div class="caption-group"><span class="w">Hi</span></div>`;
applyCaptionOverrides();
await flushCaptionOverrides();
expect(document.querySelector<HTMLElement>(".w")?.style.color).toBe("");
});
it("prefers the declaration over the rest colour, per tween, when they disagree", async () => {
// The first two are the reverse of what the rest colour infers; the third is undeclared.
const tweens = installGsapMockWithTweens([
{ color: "#222", data: { captionState: "active" } },
{ color: "#fff", data: { captionState: "dim" } },
{ color: "#222" },
]);
installCaptionOverrideFetch([{ wordIndex: 0, dimColor: "#111", activeColor: "#eee" }]);
document.body.innerHTML = `<div class="caption-group"><span id="w0">Hi</span></div>`;
mountWord("#222");
applyCaptionOverrides();
await flushCaptionOverrides();
expect(tweens[0].color).toBe("#eee");
expect(tweens[1].color).toBe("#111");
expect(tweens.map((tw) => tw.color)).toEqual(["#eee", "#111", "#111"]);
});
it("does not let a declared tween poison the baseline for undeclared ones", async () => {
// The baseline exists to guess about tweens the declaration has NOT spoken to. Deriving it from
// a tween declared "active" makes every undeclared same-state tween compare against a colour
// that has explicitly said it is not the dim reference — so the one genuinely dim tween here
// gets classified active and receives the wrong override.
const tweens = installGsapMockWithTweens([
{ color: "#eee", data: { captionState: "active" } },
{ color: "#111" },
]);
installCaptionOverrideFetch([{ wordIndex: 0, dimColor: "#dim", activeColor: "#active" }]);
document.body.innerHTML = `<div class="caption-group"><span id="w0">Hi</span></div>`;
applyCaptionOverrides();
await flushCaptionOverrides();
expect(tweens[0].color).toBe("#active");
expect(tweens[1].color).toBe("#dim");
});
it("prefers a declared dim tween as the baseline over an undeclared one", async () => {
const tweens = installGsapMockWithTweens([
{ color: "#aaa" },
{ color: "#bbb", data: { captionState: "dim" } },
]);
installCaptionOverrideFetch([{ wordIndex: 0, dimColor: "#dim", activeColor: "#active" }]);
document.body.innerHTML = `<div class="caption-group"><span id="w0">Hi</span></div>`;
applyCaptionOverrides();
await flushCaptionOverrides();
// #bbb is the declared dim reference, so the undeclared #aaa is not dim.
expect(tweens[0].color).toBe("#active");
expect(tweens[1].color).toBe("#dim");
});
it("falls through to the heuristic for a malformed declaration", async () => {
it("falls through to the rest colour for a malformed declaration", async () => {
// A typo, a primitive, or a null must never ship a broken composition — an unrecognised value
// is not a state, so classification continues as if nothing was declared.
for (const data of [{ captionState: "typo" }, { captionState: 42 }, null, "notAnObject"]) {
const tweens = installGsapMockWithTweens([{ color: "#222", data }, { color: "#fff" }]);
installCaptionOverrideFetch([{ wordIndex: 0, dimColor: "#dim", activeColor: "#active" }]);
document.body.innerHTML = `<div class="caption-group"><span id="w0">Hi</span></div>`;
mountWord("#222");
applyCaptionOverrides();
await flushCaptionOverrides();
+160 -95
View File
@@ -46,6 +46,7 @@ function parseCaptionOverridePayload(value: unknown): CaptionOverride[] {
interface GsapTween {
vars: Record<string, unknown>;
startTime(): number;
invalidate?(): void;
}
interface GsapStatic {
@@ -61,7 +62,7 @@ interface GsapStatic {
* vars through untouched, so this costs a declaring composition nothing at runtime.
*
* It exists because the fallback below has to GUESS. Classifying by colour equality breaks outright
* when a composition's two states share a colour: every tween matches the dim baseline, and the
* when a composition's two states share a colour: every tween matches the rest colour, and the
* active override is silently dropped. A declaration is the composition telling us what it built,
* rather than us inferring it from what it happens to look like.
*/
@@ -113,104 +114,168 @@ function getOrCreateCaptionWrapper(el: HTMLElement): HTMLElement {
return wrapper;
}
export function applyCaptionOverrides(): void {
const gsap = (window as unknown as { gsap?: GsapStatic }).gsap;
if (!gsap) return;
const gsapOf = () => (window as unknown as { gsap?: GsapStatic }).gsap;
// Only fetch overrides if the composition has caption groups
if (document.querySelectorAll(".caption-group").length === 0) return;
const fetchOverridesPayload = (): Promise<unknown> =>
fetch("caption-overrides.json").then((r) => (r.ok ? r.json() : null));
fetch("caption-overrides.json")
.then((r) => {
if (!r.ok) return null;
return r.json();
})
.then((data: unknown) => {
if (data === null) return;
const overrides = parseCaptionOverridePayload(data);
if (overrides.length === 0) return;
const parseOverrides = (data: unknown): readonly CaptionOverride[] =>
data === null ? [] : parseCaptionOverridePayload(data);
// Build word element index for wordIndex fallback
const wordEls = getCaptionWordElements();
function logInvalidOverrides(error: unknown): void {
const message = error instanceof Error ? error.message : String(error);
console.error(`[HyperFrames] Invalid caption-overrides.json: ${message}`);
}
for (const override of overrides) {
let el: HTMLElement | null = null;
if (override.wordId) {
el = resolveCaptionWordElement(document.getElementById(override.wordId));
}
if (!el && override.wordIndex !== undefined) {
el = wordEls[override.wordIndex] ?? null;
}
if (!el) continue;
// Split into transform props (wrapper) and style props (word span)
const transformProps: Record<string, unknown> = {};
const styleProps: Record<string, unknown> = {};
if (override.x !== undefined) transformProps.x = override.x;
if (override.y !== undefined) transformProps.y = override.y;
if (override.scale !== undefined) transformProps.scale = override.scale;
if (override.rotation !== undefined) transformProps.rotation = override.rotation;
if (override.opacity !== undefined) styleProps.opacity = override.opacity;
if (override.fontSize !== undefined) styleProps.fontSize = `${override.fontSize}px`;
if (override.fontWeight !== undefined) styleProps.fontWeight = override.fontWeight;
if (override.fontFamily !== undefined) styleProps.fontFamily = override.fontFamily;
// Replace color values in existing GSAP tweens, classified in two layers.
//
// A tween that DECLARES its state is taken at its word. Anything undeclared falls back to
// colour equality against a dim reference — a guess, and the reason the declaration exists:
// two states sharing a colour make every tween look dim.
//
// The reference is drawn only from tweens the guess still applies to (a declared "dim" one
// if present, else the first undeclared one). Deriving it from a tween declared "active"
// would compare undeclared siblings against a colour that has explicitly said it is not the
// dim reference.
if (override.activeColor || override.dimColor) {
const allTweens = gsap.getTweensOf(el);
const colorTweens = allTweens
.filter((tw) => tw.vars.color !== undefined)
.sort((a, b) => a.startTime() - b.startTime());
const dimReference =
colorTweens.find((tw) => declaredCaptionState(tw) === "dim") ??
colorTweens.find((tw) => declaredCaptionState(tw) === undefined);
const dimBaseline = dimReference ? String(dimReference.vars.color) : "";
for (const tw of colorTweens) {
// A declaration wins over the colour guess, per tween, so a composition can declare
// some tweens and leave others to the fallback.
const state =
declaredCaptionState(tw) ??
(String(tw.vars.color) === dimBaseline ? "dim" : "active");
if (state === "dim") {
if (override.dimColor) tw.vars.color = override.dimColor;
} else if (override.activeColor) {
tw.vars.color = override.activeColor;
}
}
// Set current visible color (words start in dim state)
if (override.dimColor) {
gsap.set(el, { color: override.dimColor });
}
}
// Apply non-color style props
if (Object.keys(styleProps).length > 0) {
gsap.set(el, styleProps);
}
// Wrap the word in an inline-block span and apply transforms to the wrapper.
// This preserves all GSAP entrance/exit/karaoke animations on the inner span.
if (Object.keys(transformProps).length > 0) {
const wrapper = getOrCreateCaptionWrapper(el);
gsap.set(wrapper, transformProps);
}
}
})
/** The page's caption overrides; empty when there are none or no GSAP to apply them with. */
export function fetchCaptionOverrides(): Promise<readonly CaptionOverride[]> {
if (!gsapOf()) return Promise.resolve([]);
return fetchOverridesPayload()
.then(parseOverrides)
.catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
console.error(`[HyperFrames] Invalid caption-overrides.json: ${message}`);
logInvalidOverrides(error);
return [];
});
}
export function applyCaptionOverrides(
within?: readonly Element[],
beforeRewrite?: () => void,
): Promise<void> {
if (!gsapOf()) return Promise.resolve();
// Only fetch overrides if the composition has caption groups
if (document.querySelectorAll(".caption-group").length === 0) return Promise.resolve();
return fetchOverridesPayload()
.then((data) => applyFetchedCaptionOverrides(parseOverrides(data), within, beforeRewrite))
.catch(logInvalidOverrides);
}
function findOverrideTarget(override: CaptionOverride, wordEls: HTMLElement[]): HTMLElement | null {
const byId = override.wordId
? resolveCaptionWordElement(document.getElementById(override.wordId))
: null;
return byId ?? (override.wordIndex !== undefined ? (wordEls[override.wordIndex] ?? null) : null);
}
function definedProps(override: CaptionOverride, keys: (keyof CaptionOverride)[]) {
return Object.fromEntries(
keys.filter((key) => override[key] !== undefined).map((key) => [key, override[key]]),
);
}
// The colour the browser paints on `el` with `color` inline, so any spelling compares equal.
// An empty `color` reads the stylesheet colour past whatever GSAP has rendered inline.
function paintedColor(el: HTMLElement, color: string): string {
const inline = el.style.color;
el.style.color = color;
const painted = getComputedStyle(el).color;
el.style.color = inline;
return painted;
}
// What the word shows before it is spoken: its first colour tween's start colour (a fromTo()),
// else its stylesheet colour.
function restColorOf(el: HTMLElement, colorTweens: GsapTween[]): string {
const startAt = colorTweens[0]?.vars.startAt as { color?: unknown } | undefined;
return paintedColor(el, startAt?.color === undefined ? "" : String(startAt.color));
}
// A declaration wins, per tween; otherwise a tween back to the word's rest colour is dim.
// `painted` caches resolutions for one apply: tween colours repeat, and each forces a style recalc.
function tweenState(
tw: GsapTween,
el: HTMLElement,
rest: string,
painted: Map<string, string>,
): "dim" | "active" {
const declared = declaredCaptionState(tw);
if (declared) return declared;
const color = String(tw.vars.color);
// var() and currentColor resolve per word, so only plain colours are shared.
if (/var\(|currentcolor/i.test(color)) return paintedColor(el, color) === rest ? "dim" : "active";
if (!painted.has(color)) painted.set(color, paintedColor(el, color));
return painted.get(color) === rest ? "dim" : "active";
}
// What the invalidated tweens re-read their start from, wherever the playhead has been. A from()
// recorded its own start, and a word with no colour tween keeps its classes' colours.
function restPaintOf(
override: CaptionOverride,
colorTweens: GsapTween[],
rest: string,
): string | undefined {
if (override.dimColor) return override.dimColor;
if (colorTweens.length === 0 || colorTweens.some((tw) => tw.vars.runBackwards)) return undefined;
return rest;
}
function rewriteColorTweens(
gsap: GsapStatic,
el: HTMLElement,
override: CaptionOverride,
painted: Map<string, string>,
): void {
const colorTweens = gsap
.getTweensOf(el)
.filter((tw) => tw.vars.color !== undefined)
.sort((a, b) => a.startTime() - b.startTime());
const rest = restColorOf(el, colorTweens);
for (const tw of colorTweens) {
const state = tweenState(tw, el, rest, painted);
const color = state === "dim" ? override.dimColor : override.activeColor;
if (color) tw.vars.color = color;
// Each re-reads its start from the word as repainted below, rewritten or not.
// A from() re-read that way would end on that colour instead.
if (!tw.vars.runBackwards) tw.invalidate?.();
}
const restPaint = restPaintOf(override, colorTweens, rest);
if (restPaint) gsap.set(el, { color: restPaint });
}
function applyWordOverride(
gsap: GsapStatic,
el: HTMLElement,
override: CaptionOverride,
painted: Map<string, string>,
): void {
// Split into transform props (wrapper) and style props (word span)
const transformProps = definedProps(override, ["x", "y", "scale", "rotation"]);
const styleProps = definedProps(override, ["opacity", "fontWeight", "fontFamily"]);
if (override.fontSize !== undefined) styleProps.fontSize = `${override.fontSize}px`;
if (override.activeColor || override.dimColor) rewriteColorTweens(gsap, el, override, painted);
// Apply non-color style props
if (Object.keys(styleProps).length > 0) {
gsap.set(el, styleProps);
}
// Wrap the word in an inline-block span and apply transforms to the wrapper.
// This preserves all GSAP entrance/exit/karaoke animations on the inner span.
if (Object.keys(transformProps).length > 0) {
const wrapper = getOrCreateCaptionWrapper(el);
gsap.set(wrapper, transformProps);
}
}
export function applyFetchedCaptionOverrides(
overrides: readonly CaptionOverride[],
within?: readonly Element[],
beforeRewrite: () => void = () => {},
): void {
const gsap = gsapOf();
if (!gsap || overrides.length === 0) return;
beforeRewrite();
// Build word element index for wordIndex fallback
const wordEls = getCaptionWordElements();
const painted = new Map<string, string>();
for (const override of overrides) {
const el = findOverrideTarget(override, wordEls);
if (!el || (within && !within.some((root) => root.contains(el)))) continue;
applyWordOverride(gsap, el, override, painted);
}
}
@@ -236,6 +236,17 @@ describe("createColorGradingRuntime", () => {
},
);
it("says an element is graded only while it draws through a grading canvas", () => {
const { video } = startRuntimeWithVideo();
const plain = document.createElement("video");
document.body.appendChild(plain);
expect(runtime!.isGraded(video)).toBe(true);
expect(runtime!.isGraded(plain)).toBe(false);
video.removeAttribute(HF_COLOR_GRADING_ATTR);
runtime!.refresh();
expect(runtime!.isGraded(video)).toBe(false);
});
it("uses the default non-preserved drawing buffer outside capture instrumentation", () => {
startRuntimeWithVideo();
@@ -249,6 +249,7 @@ export interface RuntimeColorGradingApi {
rawCompare: unknown,
) => boolean;
setSourceVisibility: (target: Element, visible: boolean) => boolean;
isGraded: (target: Element) => boolean;
getStatus: (
target: HfColorGradingTarget | string | null | undefined,
) => RuntimeColorGradingStatus;
@@ -3708,6 +3709,9 @@ export function createColorGradingRuntime(pausedMediaLease?: {
return true;
};
const isGraded = (target: Element): boolean =>
isColorGradingMediaElement(target) && entries.has(target);
// fallow-ignore-next-line complexity
const getStatus = (
target: HfColorGradingTarget | string | null | undefined,
@@ -3832,6 +3836,7 @@ export function createColorGradingRuntime(pausedMediaLease?: {
setGrading,
setCompare,
setSourceVisibility,
isGraded,
getStatus,
renderPreviews,
startPreviewPlayback,
@@ -0,0 +1,716 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { initSandboxRuntimeModular } from "./init";
import type { RuntimeTimelineLike } from "./types";
import { resetRuntimeDataForTests } from "./runtimeData";
import { probeAndCacheElementVolume } from "./mediaVolumeEnvelope.js";
vi.mock("./mediaVolumeEnvelope.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./mediaVolumeEnvelope.js")>();
return { ...actual, probeAndCacheElementVolume: vi.fn(actual.probeAndCacheElementVolume) };
});
// jsdom has no WebGL, so no element ever gets graded: stand in for the grading runtime's answer.
vi.mock("./colorGrading", async (importOriginal) => {
const actual = await importOriginal<typeof import("./colorGrading")>();
return {
...actual,
createColorGradingRuntime: (...args: Parameters<typeof actual.createColorGradingRuntime>) => ({
...actual.createColorGradingRuntime(...args),
isGraded: (el: Element) => el.hasAttribute("data-color-grading"),
}),
};
});
type Tl = RuntimeTimelineLike & { kill: ReturnType<typeof vi.fn>; label: string };
function tl(label: string, duration = 2): Tl {
const s = { time: 0, paused: true };
return {
label,
play: () => void (s.paused = false),
pause: () => void (s.paused = true),
seek: (t?: number) => (t !== undefined && (s.time = t), s.time),
totalTime: (t?: number) => (t !== undefined && (s.time = t), s.time),
time: () => s.time,
duration: () => duration,
add: () => {},
paused: (v?: boolean) => (typeof v === "boolean" && (s.paused = v), s.paused),
timeScale: () => {},
set: () => {},
getChildren: () => [],
kill: vi.fn(),
} as unknown as Tl;
}
function trackingRoot() {
const children: Array<{ child: unknown; at: number | undefined }> = [];
const root = tl("root", 6) as Tl & { remove: (c: unknown) => void };
root.add = ((child: unknown, at?: number) => void children.push({ child, at })) as Tl["add"];
root.getChildren = (() => children.map((c) => c.child)) as Tl["getChildren"];
root.remove = (child) => {
const i = children.findIndex((c) => c.child === child);
if (i >= 0) children.splice(i, 1);
};
return { root, children };
}
// A scene script in a real preview registers its timeline; here it names one from `made`.
const made: Record<string, Tl> = {};
// jsdom also runs it in its own global, where the test's objects do not exist: skip there.
const sceneScript = (id: string, label: string) =>
`if (window.__made) window.__timelines[${JSON.stringify(id)}] = window.__made[${JSON.stringify(label)}];`;
interface Scene {
id: string;
start: number;
body: string;
css: string;
label: string;
hash: string;
extraAttrs?: string;
}
function preview(scenes: Scene[], shared = "s1", sharedMarkup = "") {
const manifest = JSON.stringify({
shared,
scenes: Object.fromEntries(scenes.map((s) => [s.id, s.hash])),
}).replace(/"/g, "&quot;");
const head =
`<meta name="hf-scene-parts" content="${manifest}"><style>.shared{}</style>` +
scenes.map((s) => `<style data-hf-scene="${s.id}">${s.css}</style>`).join("");
const body =
`<div data-composition-id="main" data-root="true" data-start="0" data-duration="6">${sharedMarkup}` +
scenes
.map(
(s) =>
`<div data-composition-id="${s.id}" data-hf-scene="${s.id}" data-start="${s.start}" data-duration="2"${s.extraAttrs ?? ""}>${s.body}</div>`,
)
.join("") +
`</div>` +
scenes
.map((s) => `<script data-hf-scene="${s.id}">${sceneScript(s.id, s.label)}</script>`)
.join("");
return {
head,
body,
html: `<!doctype html><html><head>${head}</head><body>${body}</body></html>`,
};
}
const A1: Scene = {
id: "a",
start: 1,
body: "<p>A one</p>",
css: ".a{color:red}",
label: "a1",
hash: "ha1",
};
const B: Scene = {
id: "b",
start: 3,
body: "<p>B</p>",
css: ".b{color:blue}",
label: "b",
hash: "hb",
};
const A2: Scene = { ...A1, body: "<p>A two</p>", css: ".a{color:green}", label: "a2", hash: "ha2" };
function boot(scenes: Scene[], root: Tl, editHead = (head: string) => head) {
const { head, body } = preview(scenes);
document.head.innerHTML = editHead(head);
document.body.innerHTML = body;
window.__timelines = { main: root };
for (const s of scenes) window.__timelines[s.id] = made[s.label];
// Run each scene script the swap appends when it is appended, as a browser would.
const append = document.body.appendChild.bind(document.body);
document.body.appendChild = <T extends Node>(node: T): T => {
append(node);
if (node instanceof HTMLScriptElement && node.hasAttribute("data-hf-scene"))
new Function(node.textContent ?? "")();
return node;
};
initSandboxRuntimeModular();
}
const tick = () => new Promise<void>((r) => window.setTimeout(r, 0));
const sceneHost = (id: string) =>
document.querySelector(`[data-hf-scene="${id}"]:not(style):not(script)`)!;
const scoped = window as unknown as {
__hfVariablesByComp?: Record<string, Record<string, unknown>>;
};
const quietMedia = () => {
vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {});
};
const proxyHostile = () => {
window.__HF_MEDIA_CODEC_MAP__ = {
"/clip.mov": { codecName: "prores", browserHostile: true, representativeMime: null },
};
return new URL("clip.mov?hf-proxy=h264", document.baseURI).href;
};
const cssText = () =>
[...document.head.querySelectorAll("style")].map((s) => s.textContent).join("");
// Boots A1 and B, then starts swapping in a captioned A whose caption overrides have not arrived.
async function bootWithPendingCaptions() {
const { root } = trackingRoot();
(window as unknown as { gsap: unknown }).gsap = { set: () => {} };
let answer: (r: Response) => void = () => {};
vi.spyOn(globalThis, "fetch").mockImplementation(
() => new Promise<Response>((resolve) => (answer = resolve)),
);
boot([A1, B], root);
await tick();
const before = document.documentElement.innerHTML;
const captions: Scene = { ...A2, body: '<div class="caption-group"><span>w</span></div>' };
const swap = window.__hfSwapScenes!(preview([captions, B]).html);
return { swap, before, answer: (r: Response) => answer(r) };
}
describe("__hfSwapScenes", () => {
beforeEach(() => {
resetRuntimeDataForTests();
(globalThis as { CSS?: { escape?: (v: string) => string } }).CSS ??= {};
globalThis.CSS.escape ??= (v: string) => v;
window.requestAnimationFrame = ((cb: FrameRequestCallback) => (
cb(0), 1
)) as typeof window.requestAnimationFrame;
window.cancelAnimationFrame = (() => {}) as typeof window.cancelAnimationFrame;
for (const k of Object.keys(made)) delete made[k];
for (const label of ["a1", "a2", "b", "n1", "n2"]) made[label] = tl(label);
(window as unknown as { __made: typeof made }).__made = made;
});
afterEach(() => {
Reflect.deleteProperty(document.body, "appendChild");
Reflect.deleteProperty(window, "gsap");
window.__hfRuntimeTeardown?.();
document.head.innerHTML = "";
document.body.innerHTML = "";
delete scoped.__hfVariablesByComp;
delete window.__HF_MEDIA_CODEC_MAP__;
vi.restoreAllMocks();
});
it("swaps only the edited scene: its DOM, style and timeline, keeping the time and the other scene", async () => {
const { root, children } = trackingRoot();
boot([A1, B], root);
await tick();
window.__player?.renderSeek(2);
const bHost = document.querySelector('[data-hf-scene="b"]:not(style):not(script)');
await window.__hfSwapScenes!(preview([A2, B]).html);
const aHost = document.querySelector('[data-hf-scene="a"]:not(style):not(script)');
expect(aHost?.textContent).toBe("A two");
expect(document.querySelector('[data-hf-scene="b"]:not(style):not(script)')).toBe(bHost);
expect(cssText()).toContain(".a{color:green}");
expect(cssText()).not.toContain(".a{color:red}");
expect(cssText()).toContain(".b{color:blue}");
expect(made.a1!.kill).toHaveBeenCalled();
expect(made.b!.kill).not.toHaveBeenCalled();
expect(children).toContainEqual({ child: made.a2, at: 1 });
expect(children.map((c) => c.child)).not.toContain(made.a1);
expect(window.__player?.getTime()).toBe(2);
expect(made.a2!.time()).toBe(1);
const meta =
document.querySelector('meta[name="hf-scene-parts"]')?.getAttribute("content") ?? "";
expect(JSON.parse(meta).scenes.a).toBe("ha2");
});
it("rejects without touching the film when anything outside the scenes changed", async () => {
const { root } = trackingRoot();
boot([A1, B], root);
await tick();
const before = document.body.innerHTML;
await expect(window.__hfSwapScenes!(preview([A2, B], "s2").html)).rejects.toThrow(
"outside its scenes",
);
expect(document.body.innerHTML).toBe(before);
expect(made.a1!.kill).not.toHaveBeenCalled();
});
it("rejects when a scene was added or removed, or when nothing changed", async () => {
const { root } = trackingRoot();
boot([A1, B], root);
await tick();
await expect(window.__hfSwapScenes!(preview([A1]).html)).rejects.toThrow("added or removed");
await expect(window.__hfSwapScenes!(preview([A1, B]).html)).rejects.toThrow("no scene changed");
await expect(window.__hfSwapScenes!("<html><body></body></html>")).rejects.toThrow(
"no scene manifest",
);
});
it("rejects a duplicated scene, whose script also registers under its shared original id", async () => {
const { root } = trackingRoot();
const dup = { ...A1, extraAttrs: ' data-hf-original-composition-id="orig"' };
boot([dup, B], root);
await tick();
await expect(
window.__hfSwapScenes!(preview([{ ...A2, extraAttrs: dup.extraAttrs }, B]).html),
).rejects.toThrow("cannot be swapped");
expect(made.a1!.kill).not.toHaveBeenCalled();
});
it("drops the timelines of compositions nested inside the swapped scene", async () => {
const { root } = trackingRoot();
const withNested = (s: Scene, n: string): Scene => ({
...s,
body: `${s.body}<div data-composition-id="n"><i>${n}</i></div>`,
});
boot([withNested(A1, "n1"), B], root);
window.__timelines!.n = made.n1;
await tick();
await window.__hfSwapScenes!(preview([withNested(A2, "n2"), B]).html);
expect(made.n1!.kill).toHaveBeenCalled();
expect(window.__timelines!.n).toBeUndefined();
});
it("stops and strips the replaced scene's media, <source> children included", async () => {
const { root } = trackingRoot();
vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
const load = vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {});
const video = (text: string) => ({
...A1,
body: `<video title="${text}"><source src="https://example.com/a.mp4"></video><p>${text}</p>`,
});
boot([video("one"), B], root);
await tick();
const old = document.querySelector("video")!;
await window.__hfSwapScenes!(preview([{ ...video("two"), hash: "hv2" }, B]).html);
expect(document.querySelector("video")).not.toBe(old);
expect(old.querySelector("source")).toBeNull();
expect(load).toHaveBeenCalled();
});
it("re-applies caption overrides only when a swapped scene has captions", async () => {
const { root } = trackingRoot();
(window as unknown as { gsap: unknown }).gsap = { set: () => {} };
const captions: Scene = { ...B, body: '<div class="caption-group"><span>w</span></div>' };
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockImplementation(async () => new Response("null", { status: 404 }));
boot([A1, captions], root);
await tick();
const captionFetches = () =>
fetchSpy.mock.calls.filter(([url]) => String(url).includes("caption-overrides")).length;
const atBoot = captionFetches();
await window.__hfSwapScenes!(preview([A2, captions]).html);
expect(captionFetches()).toBe(atBoot);
await window.__hfSwapScenes!(
preview([A2, { ...captions, body: captions.body + "<b>x</b>", hash: "hc2" }]).html,
);
expect(captionFetches()).toBe(atBoot + 1);
delete (window as unknown as { gsap?: unknown }).gsap;
});
it("rewinds a swapped caption scene's timeline before rewriting its colour tweens", async () => {
const { root } = trackingRoot();
const order: string[] = [];
const tween = {
vars: { color: "#dim" },
startTime: () => 0,
invalidate: () => void order.push("rewrite"),
};
(window as unknown as { gsap: unknown }).gsap = { set: () => {}, getTweensOf: () => [tween] };
vi.spyOn(globalThis, "fetch").mockImplementation(async () =>
Response.json([{ wordIndex: 0, dimColor: "#111", activeColor: "#eee" }]),
);
const captions = (label: string, hash: string): Scene => ({
...B,
label,
hash,
body: '<div class="caption-group"><span>w</span></div>',
});
boot([A1, captions("b", "hb")], root);
for (let i = 0; i < 5; i++) await tick();
order.length = 0;
// The playhead is past the scene, so its new timeline has already rendered at its end.
made.n1!.totalTime(2);
const rewind = made.n1!.totalTime.bind(made.n1);
made.n1!.totalTime = ((t?: number) => (
t === 0 && order.push("rewind"), rewind(t)
)) as Tl["totalTime"];
await window.__hfSwapScenes!(preview([A1, captions("n1", "hb2")]).html);
expect(order.slice(0, 2)).toEqual(["rewind", "rewrite"]);
delete (window as unknown as { gsap?: unknown }).gsap;
});
it("refuses a swap that brings in media or images this scene has not loaded", async () => {
const { root } = trackingRoot();
boot([A1, B], root);
await tick();
const withImage = { ...A2, body: '<img src="new.png">' };
await expect(window.__hfSwapScenes!(preview([withImage, B]).html)).rejects.toThrow(
"it loads media this scene has not loaded",
);
const withBackground = { ...A2, css: ".a{background:url(new.png)}" };
await expect(window.__hfSwapScenes!(preview([withBackground, B]).html)).rejects.toThrow(
"it loads media this scene has not loaded",
);
expect(document.querySelector('[data-hf-scene="a"]:not(style):not(script)')?.textContent).toBe(
"A one",
);
});
it.each([
["an inline style url()", '<p style="background:url(new.png)">A two</p>', ""],
["a poster", '<video poster="new.png"></video>', ""],
["a srcset", '<img srcset="new.png 2x">', ""],
["an uppercase URL()", "", ".a{background:URL(new.png)}"],
["an image-set()", "", '.a{background:image-set("new.png" 1x)}'],
["an @import", "", '@import "new.css";'],
["an SVG <image href>", '<svg><image href="new.png"></image></svg>', ""],
["an SVG <image xlink:href>", '<svg><image xlink:href="new.png"></image></svg>', ""],
["an <object data>", '<object data="new.svg"></object>', ""],
["a <source src>", '<video><source src="new.mp4"></video>', ""],
])("refuses new media brought in by %s", async (_, body, css) => {
const { root } = trackingRoot();
quietMedia();
boot([A1, B], root);
await tick();
const edited = { ...A2, body: body || A2.body, css: A2.css + css };
await expect(window.__hfSwapScenes!(preview([edited, B]).html)).rejects.toThrow(
"scene a cannot be swapped: it loads media this scene has not loaded",
);
});
it("swaps a scene whose bound image shows its variable's value, and refuses a bound one that is new", async () => {
const { root } = trackingRoot();
scoped.__hfVariablesByComp = { a: { logo: "brand.svg", other: "other.svg" } };
const logo = '<img data-var-src="logo" src="assets/logo.svg">';
boot([{ ...A1, body: `<p>A one</p>${logo}` }, B], root);
await tick();
await window.__hfSwapScenes!(preview([{ ...A2, body: `<p>A two</p>${logo}` }, B]).html);
expect(sceneHost("a").querySelector("img")?.getAttribute("src")).toBe("brand.svg");
const other = '<img data-var-src="other" src="brand.svg">';
const withOther = { ...A2, hash: "ha3", body: `<p>A two</p>${logo}${other}` };
await expect(window.__hfSwapScenes!(preview([withOther, B]).html)).rejects.toThrow(
"it loads media",
);
});
it("leaves a proxied video in a scene the edit did not touch on its proxy", async () => {
const { root } = trackingRoot();
quietMedia();
const proxied = proxyHostile();
scoped.__hfVariablesByComp = { b: { clip: "clip.mov" } };
const withVideo = { ...B, body: '<video data-var-src="clip" src="clip.mov"></video>' };
boot([A1, withVideo], root);
await tick();
const video = sceneHost("b").querySelector("video")!;
expect(video.src).toBe(proxied);
await window.__hfSwapScenes!(preview([A2, withVideo]).html);
expect(video.src).toBe(proxied);
});
it("keeps a proxied video in the edited scene on its proxy, and proxies a new copy of it", async () => {
const { root } = trackingRoot();
quietMedia();
const proxied = proxyHostile();
scoped.__hfVariablesByComp = { a: { clip: "clip.mov" } };
const video = '<video data-var-src="clip" src="placeholder.mp4"></video>';
boot([{ ...A1, body: `<p>A one</p>${video}` }, B], root);
await tick();
await window.__hfSwapScenes!(
preview([{ ...A2, body: `<p>A two</p>${video}${video}` }, B]).html,
);
const videos = Array.from(sceneHost("a").querySelectorAll("video"));
expect(videos.map((v) => v.src)).toEqual([proxied, proxied]);
});
it("leaves the bound text of a scene the edit did not touch as it is", async () => {
const { root } = trackingRoot();
scoped.__hfVariablesByComp = { b: { title: "Hello" } };
const titled = { ...B, body: '<p data-var-text="title">x</p>' };
boot([A1, titled], root);
await tick();
const p = sceneHost("b").querySelector("p")!;
expect(p.textContent).toBe("Hello");
// A text tween part way through.
p.textContent = "Hel";
await window.__hfSwapScenes!(preview([A2, titled]).html);
expect(p.textContent).toBe("Hel");
});
it("keeps the same video element through an edit beside it", async () => {
const { root } = trackingRoot();
quietMedia();
const scene = (text: string, video: string, hash: string): Scene => ({
...A1,
hash,
body: `<p>${text}</p><video src="clip.mp4" data-start="1" ${video}>one</video>`,
});
// The attribute the grading runtime stamps on every video as the page parses.
boot([scene("A one", 'data-hf-authored-opacity=""', "ha1"), B], root);
await tick();
const video = sceneHost("a").querySelector("video");
await window.__hfSwapScenes!(preview([scene("A two", "", "ha2"), B]).html);
expect(sceneHost("a").querySelector("p")?.textContent).toBe("A two");
expect(sceneHost("a").querySelector("video")).toBe(video);
expect(video?.getAttribute("preload")).toBe("auto");
});
it.each([
["rewinds and kills an old timeline that cannot revert", false, "1", 1],
["reverts an old timeline that can, dropping the inline values it wrote", true, "", 0],
])(
"%s, so a kept video carries none of its tweens' values",
async (_, canRevert, opacity, kills) => {
const { root } = trackingRoot();
quietMedia();
const scene = (text: string, label: string, hash: string): Scene => ({
...A1,
label,
hash,
body: `<p>${text}</p><video src="clip.mp4" data-start="1">one</video>`,
});
boot([scene("A one", "a1", "ha1"), B], root);
await tick();
const video = sceneHost("a").querySelector("video")!;
const seek = made.a1!.totalTime.bind(made.a1);
const fadeOverTwentySeconds = (t?: number) => (
t !== undefined && (video.style.opacity = String(1 - t / 20)), seek(t)
);
made.a1!.totalTime = fadeOverTwentySeconds as Tl["totalTime"];
if (canRevert)
Object.assign(made.a1!, { revert: () => video.style.removeProperty("opacity") });
made.a1!.totalTime(10);
await window.__hfSwapScenes!(preview([scene("A two", "a2", "ha2"), B]).html);
expect(sceneHost("a").querySelector("video")).toBe(video);
expect(video.style.opacity).toBe(opacity);
// GSAP's revert() kills the timeline itself; a second kill() fires its onInterrupt again.
expect(made.a1!.kill).toHaveBeenCalledTimes(kills);
},
);
it.each([
["muted is added", "muted"],
["its timing and style change", 'data-start="1.5" style="opacity: 0.5"'],
["its fallback content changes", "", "two"],
["it is colour graded", `data-color-grading='{"adjust":{"exposure":1.5}}'`, "one", true],
])("rebuilds a video fresh when %s", async (_, edit, content = "one", graded = false) => {
const { root } = trackingRoot();
quietMedia();
const scene = (text: string, attrs: string, inner: string, hash: string): Scene => ({
...A1,
hash,
body: `<p>${text}</p><video src="clip.mp4" ${attrs}>${inner}</video>`,
});
boot([scene("A one", graded ? edit : "", "one", "ha1"), B], root);
await tick();
const video = sceneHost("a").querySelector("video");
await window.__hfSwapScenes!(preview([scene("A two", edit, content, "ha2"), B]).html);
const rebuilt = sceneHost("a").querySelector("video");
expect(rebuilt).not.toBe(video);
const written = document.createElement("template");
written.innerHTML = `<video ${edit}></video>`;
for (const { name } of written.content.firstElementChild!.attributes)
expect(rebuilt?.hasAttribute(name)).toBe(true);
expect(rebuilt?.textContent).toBe(content);
await window.__hfSwapScenes!(preview([scene("A three", edit, content, "ha3"), B]).html);
const again = sceneHost("a").querySelector("video");
if (graded) expect(again).not.toBe(rebuilt);
else expect(again).toBe(rebuilt);
});
it("probes the swapped scene's media for volume once the scene is in the root timeline", async () => {
const { root, children } = trackingRoot();
const withAudio = (s: Scene, text: string): Scene => ({
...s,
body: `<p>${text}</p><audio src="music.mp3" data-start="1" data-duration="2"></audio>`,
});
boot([withAudio(A1, "A one"), B], root);
await tick();
const probe = vi.mocked(probeAndCacheElementVolume);
probe.mockClear();
const nestedWhenProbed: boolean[] = [];
probe.mockImplementation((el) => {
if (el.isConnected) nestedWhenProbed.push(children.some((c) => c.child === made.a2));
});
await window.__hfSwapScenes!(preview([withAudio(A2, "A two"), B]).html);
expect(nestedWhenProbed).toContain(true);
probe.mockReset();
});
it("re-applies a moved element's position edit after the swap", async () => {
const { root } = trackingRoot();
boot([A1, B], root);
await tick();
const moved = { ...A2, body: '<p data-x="30" data-hf-edit-base-x="0">A two</p>' };
await window.__hfSwapScenes!(preview([moved, B]).html);
const p = document.querySelector('[data-hf-scene="a"]:not(style):not(script) p') as HTMLElement;
expect(p.style.translate).toContain("30");
});
it("rejects a scene the bundler marked as not swappable, naming why", async () => {
const { root } = trackingRoot();
const marked = (s: Scene): Scene => ({
...s,
extraAttrs: ' data-hf-scene-no-swap="its script uses addEventListener"',
});
boot([marked(A1), B], root);
await tick();
await expect(window.__hfSwapScenes!(preview([marked(A2), B]).html)).rejects.toThrow(
"scene a cannot be swapped: its script uses addEventListener",
);
expect(made.a1!.kill).not.toHaveBeenCalled();
});
it("rejects a scene with more than one host rather than dropping one", async () => {
const { root } = trackingRoot();
boot([A1, B], root);
await tick();
const twoHosts = preview([A2, B]).html.replace(
'<div data-composition-id="b"',
'<div data-hf-scene="a"><p>second</p></div><div data-composition-id="b"',
);
await expect(window.__hfSwapScenes!(twoHosts)).rejects.toThrow("no single host");
});
it("keeps the swapped scene's style where the old one was", async () => {
const { root } = trackingRoot();
boot([A1, B], root);
await tick();
await window.__hfSwapScenes!(preview([A2, B]).html);
expect(cssText().indexOf(".a{color:green}")).toBeLessThan(cssText().indexOf(".b{color:blue}"));
});
it("replaces each of a scene's separated styles in place, so same-named @keyframes cascade as a fresh load's do", async () => {
const { root } = trackingRoot();
// Scene a's nested scene comes after b in source order, so a owns a second style run after b's.
const secondStyle = (css: string) => (html: string) =>
html.replace(
`.b{color:blue}</style>`,
`.b{color:blue}</style><style data-hf-scene="a">${css}</style>`,
);
boot([A1, B], root, secondStyle(".a2{color:red}"));
await tick();
const edited = secondStyle(".a2{color:green}")(preview([A2, B]).html);
await window.__hfSwapScenes!(edited);
const sceneStyles = Array.from(
document.head.querySelectorAll("style[data-hf-scene]"),
(el) => el.textContent,
);
expect(sceneStyles).toEqual([".a{color:green}", ".b{color:blue}", ".a2{color:green}"]);
});
it("refuses a swap whose scene has a different number of style parts", async () => {
const { root } = trackingRoot();
boot([A1, B], root);
await tick();
const extra = preview([A2, B]).html.replace(
`.b{color:blue}</style>`,
`.b{color:blue}</style><style data-hf-scene="a">.a2{}</style>`,
);
await expect(window.__hfSwapScenes!(extra)).rejects.toThrow("its styles moved");
expect(made.a1!.kill).not.toHaveBeenCalled();
});
it("puts the swapped scene's CSS animations under the playhead", async () => {
const { root } = trackingRoot();
const animation = { currentTime: null as number | null, pause: vi.fn(), play: vi.fn() };
const proto = HTMLElement.prototype as unknown as { getAnimations?: () => unknown[] };
proto.getAnimations = function (this: HTMLElement) {
return this.textContent === "A two" ? [animation] : [];
};
try {
boot([A1, B], root);
await tick();
const animated: Scene = {
...A2,
body: '<p style="animation-name: spin; animation-duration: 2s">A two</p>',
};
await window.__hfSwapScenes!(preview([animated, B]).html);
expect(animation.currentTime).toBeTypeOf("number");
expect(animation.pause).toHaveBeenCalled();
} finally {
delete proto.getAnimations;
}
});
it("rejects a swap the preview was torn down during", async () => {
const { swap, answer } = await bootWithPendingCaptions();
window.__hfRuntimeTeardown?.();
answer(new Response("null", { status: 404 }));
await expect(swap).rejects.toThrow("torn down");
});
it("leaves the page untouched while the caption overrides are still loading", async () => {
const { before } = await bootWithPendingCaptions();
for (let i = 0; i < 5; i++) await tick();
expect(document.documentElement.innerHTML).toBe(before);
expect(made.a1!.kill).not.toHaveBeenCalled();
});
it("rejects a swap another swap overtook while its caption overrides loaded", async () => {
const { swap, answer } = await bootWithPendingCaptions();
const B2: Scene = { ...B, body: "<p>B two</p>", label: "n1", hash: "hb2" };
await window.__hfSwapScenes!(preview([A1, B2]).html);
answer(new Response("null", { status: 404 }));
await expect(swap).rejects.toThrow("changed");
expect(sceneHost("a").textContent).toBe("A one");
expect(sceneHost("b").textContent).toBe("B two");
});
it("rejects a waiting swap after other swaps changed its scene and changed it back", async () => {
const { swap, answer } = await bootWithPendingCaptions();
const A3: Scene = { ...A1, body: "<p>A three</p>", label: "n1", hash: "ha3" };
await window.__hfSwapScenes!(preview([A3, B]).html);
await window.__hfSwapScenes!(preview([A1, B]).html);
answer(new Response("null", { status: 404 }));
await expect(swap).rejects.toThrow("changed");
});
it("warns about a data-var-src on a non-media tag once for the new element, as a load does", async () => {
const { root } = trackingRoot();
scoped.__hfVariablesByComp = { a: { page: "page.html" } };
const frame = (s: Scene): Scene => ({
...s,
body: `${s.body}<iframe data-var-src="page"></iframe>`,
});
boot([frame(A1), B], root);
await tick();
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await window.__hfSwapScenes!(preview([frame(A2), B]).html);
const ignored = warn.mock.calls.filter(([m]) => String(m).includes("Ignoring data-var-src"));
expect(ignored).toHaveLength(1);
});
it("offers no swap on a page served without a scene manifest", async () => {
const { root } = trackingRoot();
boot([A1, B], root);
await tick();
expect(window.__hfSwapScenes).toBeTypeOf("function");
window.__hfRuntimeTeardown?.();
document.querySelector('meta[name="hf-scene-parts"]')?.remove();
initSandboxRuntimeModular();
expect(window.__hfSwapScenes).toBeUndefined();
});
it("re-applies caption overrides only to the swapped scene's words", async () => {
const { root } = trackingRoot();
const set = vi.fn();
(window as unknown as { gsap: unknown }).gsap = { set, getTweensOf: () => [] };
vi.spyOn(globalThis, "fetch").mockImplementation(async () =>
Response.json([
{ wordIndex: 0, opacity: 0.5 },
{ wordIndex: 1, opacity: 0.5 },
]),
);
const words = (s: Scene, word: string): Scene => ({
...s,
body: `<div class="caption-group"><span>${word}</span></div>`,
});
boot([words(A1, "a"), words(B, "b")], root);
for (let i = 0; i < 5; i++) await tick();
const atBoot = set.mock.calls.map(([el]) => (el as Element).textContent);
expect(atBoot).toEqual(expect.arrayContaining(["a", "b"]));
set.mockClear();
await window.__hfSwapScenes!(
preview([{ ...words(A2, "a2"), hash: "hw2" }, words(B, "b")]).html,
);
const touched = set.mock.calls.map(([el]) => (el as Element).textContent);
expect(touched).toContain("a2");
expect(touched).not.toContain("b");
delete (window as unknown as { gsap?: unknown }).gsap;
});
});
+268 -20
View File
@@ -47,10 +47,21 @@ import { resolveCompositionDuration } from "@hyperframes/parsers/composition-dur
import { createRuntimeStartTimeResolver } from "./startResolver";
import { createClipTree } from "./clipTree";
import { loadExternalCompositions, loadInlineTemplateCompositions } from "./compositionLoader";
import { applyCaptionOverrides } from "./captionOverrides";
import {
applyCaptionOverrides,
applyFetchedCaptionOverrides,
fetchCaptionOverrides,
} from "./captionOverrides";
import {
SCENE_NO_SWAP_ATTR,
SCENE_PART_ATTR,
SCENE_PARTS_META,
type SceneParts,
} from "../sceneParts";
import { applyPositionEdits, installPositionEditsSeekReapply } from "./positionEdits";
import { applyVariableBindings } from "./applyVariableBindings";
import { applyVariableBindings, unproxiedMediaSrc } from "./applyVariableBindings";
import { createColorGradingRuntime, type RuntimeColorGradingApi } from "./colorGrading";
import { COLOR_GRADING_AUTHORED_OPACITY_ATTR } from "../colorGrading";
import { initVfx, paintVfx } from "./vfx";
import { TransportClock } from "./clock";
import { WebAudioTransport } from "./webAudioTransport";
@@ -202,10 +213,41 @@ function createSettledTracker(
};
}
function readSceneParts(doc: Document): SceneParts | null {
const content = doc.querySelector(`meta[name="${SCENE_PARTS_META}"]`)?.getAttribute("content");
if (!content) return null;
try {
return JSON.parse(content) as SceneParts;
} catch {
return null;
}
}
// A media element's attributes and content, less the grading capture stamped on it at parse time.
const authoredShape = (el: Element): string =>
JSON.stringify([
Array.from(el.attributes, (a) => [a.name, a.value]).filter(
([name]) => name !== COLOR_GRADING_AUTHORED_OPACITY_ATTR,
),
el.innerHTML,
]);
// URL attributes a scene swap checks besides src, poster and srcset, by tag.
const MEDIA_URL_ATTRS = new Map([
["image", ["href", "xlink:href"]],
["object", ["data"]],
]);
const SLOW_IDLE_HEARTBEAT_MS = 1000;
export function initSandboxRuntimeModular(): void {
const state = createRuntimeState();
// Each video and audio as written, captured before the runtime writes to it; a swap keeps only these.
const authoredMedia = new WeakMap<Element, string>();
if (readSceneParts(document)) {
for (const el of document.querySelectorAll("video, audio"))
authoredMedia.set(el, authoredShape(el));
}
// Runtime-data handlers may replace the timeline object they mutate. Keep the
// reconciliation callback late-bound because the reporter is installed before
// the timeline resolver/binder is declared below. Delivery cannot complete
@@ -2039,11 +2081,14 @@ export function initSandboxRuntimeModular(): void {
window.addEventListener("unhandledrejection", runtimeUnhandledRejectionListener);
};
const assetNodesWithDiagnostics = new WeakSet<Element>();
const installAssetFailureDiagnostics = () => {
const assetNodes = Array.from(
document.querySelectorAll("img, video, audio, source, link[rel='stylesheet']"),
);
for (const node of assetNodes) {
if (assetNodesWithDiagnostics.has(node)) continue;
assetNodesWithDiagnostics.add(node);
const onError = () => {
if (!isElementNode(node)) {
return;
@@ -2258,15 +2303,27 @@ export function initSandboxRuntimeModular(): void {
if (isMediaElement(target)) reportWebAudioRoute(target);
};
const unbindMedia = (mediaEl: HTMLMediaElement) => {
mediaEl.removeEventListener("loadedmetadata", scheduleMetadataDurationHydration);
mediaEl.removeEventListener("durationchange", scheduleMetadataDurationHydration);
mediaEl.removeEventListener("loadedmetadata", onMediaLoadedMetadataForProxy);
mediaEl.removeEventListener("loadedmetadata", onMediaLoadedMetadataForRoute);
mediaEl.removeEventListener("error", onMediaErrorForProxy);
metadataBoundMedia.delete(mediaEl);
};
const unbindMediaMetadataListeners = () => {
for (const mediaEl of metadataBoundMedia) unbindMedia(mediaEl);
};
// A swapped-out scene's media is detached but still buffering: stop it and drop its sources.
const releaseDetachedMedia = () => {
for (const mediaEl of metadataBoundMedia) {
mediaEl.removeEventListener("loadedmetadata", scheduleMetadataDurationHydration);
mediaEl.removeEventListener("durationchange", scheduleMetadataDurationHydration);
mediaEl.removeEventListener("loadedmetadata", onMediaLoadedMetadataForProxy);
mediaEl.removeEventListener("loadedmetadata", onMediaLoadedMetadataForRoute);
mediaEl.removeEventListener("error", onMediaErrorForProxy);
if (mediaEl.isConnected) continue;
unbindMedia(mediaEl);
mediaEl.pause();
for (const source of mediaEl.querySelectorAll("source")) source.remove();
mediaEl.removeAttribute("src");
mediaEl.load();
}
metadataBoundMedia.clear();
};
const bindMediaMetadataListeners = () => {
@@ -3007,6 +3064,18 @@ export function initSandboxRuntimeModular(): void {
(err) => swallow("runtime.init.buildReady", err),
);
// Passes that must see scene DOM, which arrives after init: when scenes mount, or when one is
// swapped. Resolves when caption overrides have landed.
const settleSceneDom = (): Promise<void> => {
bindMediaMetadataListeners();
installAssetFailureDiagnostics();
const captionsApplied = applyCaptionOverrides();
// Per-instance scoped values: data-var-* / --{id} bindings inside scenes. Idempotent.
applyVariableBindings(document);
// An unregistered vfx chain paints nothing and logs nothing, so re-scan the new DOM.
initVfx(document.body, state.canonicalFps);
return captionsApplied;
};
if (!externalCompositionsReady) {
const compositionLoaderParams = {
injectedStyles: state.injectedCompStyles,
@@ -3032,22 +3101,201 @@ export function initSandboxRuntimeModular(): void {
.then(() => loadInlineTemplateCompositions(compositionLoaderParams))
.finally(() => {
externalCompositionsReady = true;
bindMediaMetadataListeners();
installAssetFailureDiagnostics();
applyCaptionOverrides();
// Runtime-loaded sub-compositions (and their per-instance scoped
// values) don't exist at the init-time binding pass — re-apply so
// data-var-* / --{id} bindings inside them resolve. Idempotent.
applyVariableBindings(document);
// A vfx host inside a sub-composition enters the DOM only now, so the
// init-time pass below never saw it. Re-scan before readiness is
// published: an unregistered chain paints nothing and logs nothing.
initVfx(document.body, state.canonicalFps);
void settleSceneDom();
maybePublishRenderReady();
});
} else {
// No external/inline compositions to load — apply caption overrides immediately
applyCaptionOverrides();
void applyCaptionOverrides();
}
const sceneUrls = (parts: Element[]): Set<string> => {
const urls = new Set<string>();
const addCssUrls = (css: string | null) => {
for (const m of (css ?? "").matchAll(/url\(\s*(['"]?)(.*?)\1\s*\)|@import\s+(['"])(.*?)\3/gi))
urls.add(m[2] ?? m[4]!);
for (const set of (css ?? "").matchAll(/image-set\((?:[^()]|\([^()]*\))*\)/gi))
for (const m of set[0].matchAll(/(['"])(.*?)\1/g)) urls.add(m[2]!);
};
for (const part of parts) {
if (part.tagName === "STYLE") addCssUrls(part.textContent);
if (part.tagName === "STYLE" || part.tagName === "SCRIPT") continue;
for (const el of [part, ...part.querySelectorAll("*")]) {
const attrs = MEDIA_URL_ATTRS.get(el.localName) ?? [];
const values = [
unproxiedMediaSrc(el),
...[...attrs, "poster", "srcset"].map((a) => el.getAttribute(a)),
];
for (const value of values) if (value) urls.add(value);
addCssUrls(el.getAttribute("style"));
}
}
return urls;
};
let sceneSwapGeneration = 0;
// A video or audio the edit left as written keeps playing: the old element takes its copy's place.
const keepUnchangedMedia = (oldHost: Element, host: Element) => {
const byShape = new Map<string, Element[]>();
for (const el of oldHost.querySelectorAll("video, audio")) {
const shape = authoredMedia.get(el);
// Its grading canvas sits beside it in the old scene and cannot follow it.
if (!shape || colorGradingRuntime?.isGraded(el)) continue;
byShape.set(shape, [...(byShape.get(shape) ?? []), el]);
}
for (const el of host.querySelectorAll("video, audio")) {
const kept = byShape.get(authoredShape(el))?.shift();
if (kept) el.replaceWith(kept);
}
};
// Swap edited scenes in place from a rebuilt preview document. Refuses before changing anything unless
// the documents differ only inside existing scenes; a later failure is left to the caller's reload.
const swapScenes = async (html: string): Promise<void> => {
const generation = sceneSwapGeneration;
const next = new DOMParser().parseFromString(html, "text/html");
const liveParts = readSceneParts(document);
const nextParts = readSceneParts(next);
if (!liveParts || !nextParts) throw new Error("no scene manifest");
if (liveParts.shared !== nextParts.shared)
throw new Error("the film changed outside its scenes");
const names = Object.keys(nextParts.scenes);
if (
names.length !== Object.keys(liveParts.scenes).length ||
names.some((name) => !(name in liveParts.scenes))
) {
throw new Error("scenes were added or removed");
}
const changed = names.filter((name) => nextParts.scenes[name] !== liveParts.scenes[name]);
if (changed.length === 0) throw new Error("no scene changed");
const swaps = changed.map((name) => {
const partsIn = (doc: Document) =>
Array.from(doc.querySelectorAll(`[${SCENE_PART_ATTR}="${CSS.escape(name)}"]`));
const isHost = (el: Element) => el.tagName !== "STYLE" && el.tagName !== "SCRIPT";
const oldParts = partsIn(document);
const newParts = partsIn(next);
const oldHosts = oldParts.filter(isHost);
const newHosts = newParts.filter(isHost);
const oldHost = oldHosts[0];
const newHost = newHosts[0];
if (!oldHost || !newHost || oldHosts.length > 1 || newHosts.length > 1) {
throw new Error(`scene ${name} cannot be swapped: it has no single host`);
}
// A duplicated scene's script also registers under its shared original id.
if (oldHost.hasAttribute("data-hf-original-composition-id")) {
throw new Error(`scene ${name} cannot be swapped: it is a duplicated instance`);
}
const refusal =
oldHost.getAttribute(SCENE_NO_SWAP_ATTR) ?? newHost.getAttribute(SCENE_NO_SWAP_ATTR);
if (refusal !== null) throw new Error(`scene ${name} cannot be swapped: ${refusal}`);
const loaded = sceneUrls(oldParts);
if ([...sceneUrls(newParts)].some((url) => !loaded.has(url))) {
throw new Error(
`scene ${name} cannot be swapped: it loads media this scene has not loaded`,
);
}
const styleCount = (parts: Element[]) => parts.filter((el) => el.tagName === "STYLE").length;
if (styleCount(oldParts) !== styleCount(newParts)) {
throw new Error(`scene ${name} cannot be swapped: its styles moved`);
}
return { oldParts, newParts, oldHost, newHost };
});
// Fetched before the first write, so a stalled or failed request leaves the page as it was.
const captionOverrides = swaps.some(({ newHost }) => newHost.querySelector(".caption-group"))
? await fetchCaptionOverrides()
: [];
if (state.tornDown) throw new Error("the preview was torn down during the swap");
if (generation !== sceneSwapGeneration) {
throw new Error("the preview changed while this swap waited");
}
sceneSwapGeneration += 1;
const timelines = (window.__timelines ??= {}) as Record<
string,
RuntimeTimelineLike | undefined
>;
const root = state.capturedTimeline as
| (RuntimeTimelineLike & { remove?: (child: unknown) => unknown })
| null;
// Overrides re-dim every word they touch, so only the swapped scenes' captions get them.
const captionHosts: Element[] = [];
const swappedHosts: Element[] = [];
for (const { oldParts, newParts, oldHost, newHost } of swaps) {
for (const el of [oldHost, ...oldHost.querySelectorAll("[data-composition-id]")]) {
const id = el.getAttribute("data-composition-id");
const previous = id ? timelines[id] : undefined;
if (!id || !previous) continue;
const old = previous as { revert?: () => void; kill?: () => void };
if (old.revert) old.revert();
else previous.totalTime?.(0, true);
root?.remove?.(previous);
// revert() has already killed it; a second kill() fires onInterrupt again.
if (!old.revert) old.kill?.();
delete timelines[id];
}
// Each new style takes its own old one's place: same-named @keyframes resolve by order.
const newStyles = newParts.filter((el) => el.tagName === "STYLE");
oldParts
.filter((el) => el.tagName === "STYLE")
.forEach((el, i) => el.replaceWith(document.importNode(newStyles[i]!, true)));
for (const el of oldParts) if (el !== oldHost) el.remove();
const host = document.importNode(newHost, true);
keepUnchangedMedia(oldHost, host);
oldHost.replaceWith(host);
swappedHosts.push(host);
if (host.querySelector(".caption-group")) captionHosts.push(host);
for (const el of newParts) {
if (el.tagName !== "SCRIPT") continue;
// An imported <script> never runs; a created one does.
const script = document.createElement("script");
for (const attr of Array.from(el.attributes)) script.setAttribute(attr.name, attr.value);
script.textContent = el.textContent;
document.body.appendChild(script);
}
for (const el of host.querySelectorAll("video, audio"))
if (!authoredMedia.has(el)) authoredMedia.set(el, authoredShape(el));
}
document
.querySelector(`meta[name="${SCENE_PARTS_META}"]`)
?.setAttribute("content", JSON.stringify(nextParts));
// Boot's order: bindings settle each src before media binding proxies and loads it.
for (const host of swappedHosts) applyVariableBindings(document, host);
bindMediaMetadataListeners();
// Rewound before the rewrite, so each rewritten tween re-reads its start from the reset word.
const rewindCaptionTimelines = () => {
for (const host of captionHosts) {
for (const el of [host, ...host.querySelectorAll("[data-composition-id]")]) {
const id = el.getAttribute("data-composition-id");
if (id) timelines[id]?.totalTime?.(0, true);
}
}
};
initVfx(document.body, state.canonicalFps);
applyFetchedCaptionOverrides(captionOverrides, captionHosts, rewindCaptionTimelines);
releaseDetachedMedia();
childrenBound = false;
bindRootTimelineIfAvailable();
// Probed before the scene was nested, so the new media found no volume envelope.
for (const host of swappedHosts) {
for (const el of host.querySelectorAll<HTMLMediaElement>("video, audio")) {
volumeKeyframeCache.delete(el);
probeAndCacheVolumeKeyframes(el);
}
}
const duration = getSafeTimelineDurationSeconds(state.capturedTimeline, 0);
if (duration > 0) clock.setDuration(duration);
// Adapters drive only the elements they found at discover time; the new scene's are new.
runAdapters("discover", state.currentTime);
// The rebind above skips these when the root timeline object did not change.
applyPositionEdits(document);
// Redraw the current frame as a seek does, forcing a render at an unchanged time.
transport.seek(state.currentTime, { keepPlaying: true });
syncTimedElementVisibility(state.currentTime);
postTimeline();
};
// Only a preview served with a scene manifest can swap; elsewhere the caller reloads directly.
if (readSceneParts(document)) {
window.__hfSwapScenes = swapScenes;
registerRuntimeCleanup(() => {
delete window.__hfSwapScenes;
});
}
const picker = createPickerModule({
+4 -5
View File
@@ -4,6 +4,7 @@ import { evictMediaSyncState } from "./media";
import { findInjectedRenderFrame } from "./renderFrameSibling";
import type { RuntimeJson } from "./types";
import { isVideoElement } from "./domRealm";
import { swappedElements } from "./proxySrc";
/**
* One entry per project-root-relative asset pathname, injected by the
@@ -38,10 +39,8 @@ const DIAGNOSTIC_UNAVAILABLE_CODE = "runtime_media_proxy_unavailable";
type ProxyTrigger = "proactive" | "reactive" | "tertiary";
// Elements already swapped to their proxy src. Gates every trigger so a
// second undecodable-video signal (another zero-width metadata tick, a
// stray error event) never re-swaps or loops.
const swappedElements = new WeakSet<HTMLMediaElement>();
// `swappedElements` gates every trigger, so a second undecodable-video signal never re-swaps.
// Elements that already got the "can't help you" diagnostic (cross-origin,
// or the proxy itself failing) — one-shot per element, independent of
// `swappedElements` so the proxy-failed case (which fires AFTER a real swap)
@@ -225,7 +224,7 @@ export function swapToProxy(
emitUnavailableDiagnostic(el, "invalid_source_url", originalSrc);
return;
}
swappedElements.add(el);
swappedElements.set(el, el.getAttribute("src"));
// The swapped src points at a different file — sync state (drift offsets,
// seek-retry latches, volume tracking) computed against the original
// source must not carry over, or the next tick misreads a fresh file's
+6
View File
@@ -0,0 +1,6 @@
/** Media elements swapped to their proxy, each with the src attribute it had before the swap. */
export const swappedElements = new WeakMap<Element, string | null>();
export function unproxiedSrc(el: Element): string | null {
return swappedElements.has(el) ? (swappedElements.get(el) ?? null) : el.getAttribute("src");
}
+2
View File
@@ -76,6 +76,8 @@ declare global {
__playerReady?: boolean;
__renderReady?: boolean;
__hfRuntimeTeardown?: (() => void) | null;
/** Swap edited scenes from a rebuilt preview document; refuses before changing anything when it cannot. */
__hfSwapScenes?: (html: string) => Promise<void>;
__HF_EXPORT_RENDER_SEEK_CONFIG?: {
mode?: string;
diagnostics?: boolean;
+12
View File
@@ -0,0 +1,12 @@
export const SCENE_PART_ATTR = "data-hf-scene";
export const SCENE_PARTS_META = "hf-scene-parts";
/** On a scene's top-level host: why the scene cannot be swapped in place, so edits to it reload. */
export const SCENE_NO_SWAP_ATTR = "data-hf-scene-no-swap";
/** Hashes of one preview document: everything outside the scene parts, and each scene's parts. */
export interface SceneParts {
shared: string;
scenes: Record<string, string>;
}