Skip to content
Open
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
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/cli/src/commands/layout-audit.browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -713,7 +713,7 @@
return colorAlpha(getComputedStyle(element).color) >= 0.35;
}

const RUNTIME_HIDDEN_CLIP = '[data-hf-clip-hidden][style*="visibility: hidden"]';
const RUNTIME_HIDDEN_CLIP = "[data-hf-clip-hidden]";

// Core's timedClipHide rule hides these clips' whole subtrees: skipping them bounds work by what is on screen.
function elementsOnScreenScenes(root) {
Expand Down
6 changes: 6 additions & 0 deletions packages/core/package-subpaths.json
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,12 @@
"types": "./dist/runtime/clipWindow.d.ts",
"environments": ["browser", "bun", "node"]
},
"./runtime/clip-reveal": {
"source": "./src/runtime/clipReveal.ts",
"runtime": "./dist/runtime/clipReveal.js",
"types": "./dist/runtime/clipReveal.d.ts",
"environments": ["browser", "bun", "node"]
},
"./runtime/stacking-context": {
"source": "./src/runtime/stackingContext.ts",
"runtime": "./dist/runtime/stackingContext.js",
Expand Down
10 changes: 10 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,12 @@
"import": "./src/runtime/clipWindow.ts",
"types": "./src/runtime/clipWindow.ts"
},
"./runtime/clip-reveal": {
"bun": "./src/runtime/clipReveal.ts",
"node": "./dist/runtime/clipReveal.js",
"import": "./src/runtime/clipReveal.ts",
"types": "./src/runtime/clipReveal.ts"
},
"./runtime/stacking-context": {
"bun": "./src/runtime/stackingContext.ts",
"node": "./dist/runtime/stackingContext.js",
Expand Down Expand Up @@ -803,6 +809,10 @@
"import": "./dist/runtime/clipWindow.js",
"types": "./dist/runtime/clipWindow.d.ts"
},
"./runtime/clip-reveal": {
"import": "./dist/runtime/clipReveal.js",
"types": "./dist/runtime/clipReveal.d.ts"
},
"./runtime/stacking-context": {
"import": "./dist/runtime/stackingContext.js",
"types": "./dist/runtime/stackingContext.d.ts"
Expand Down
30 changes: 30 additions & 0 deletions packages/core/src/runtime/clipReveal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/** The visibility pass's mark on a timed clip it hides; the runtime's rules hide the clip's contents by it. */
export const CLIP_HIDDEN_ATTR = "data-hf-clip-hidden";

type ShaderWindow = Window & {
HyperShader?: unknown;
__hf?: { transitions?: unknown };
__hfClipRevealAware?: boolean;
};

/** Shows a clip the visibility pass hid, contents included; returns a function that puts the mark back. */
export function revealClip(el: HTMLElement, priority = ""): () => void {
el.style.setProperty("visibility", "visible", priority);
if (!el.hasAttribute(CLIP_HIDDEN_ATTR)) return () => {};
el.removeAttribute(CLIP_HIDDEN_ATTR);
return function putClipHiddenMarkBack() {
el.setAttribute(CLIP_HIDDEN_ATTR, "");
};
}

/** Called by shader-transitions builds on load: their revealers go through revealClip. */
export function declareClipRevealAware(): void {
if (typeof window !== "undefined") (window as ShaderWindow).__hfClipRevealAware = true;
}

/** A shader-transitions build is on the page whose revealers predate revealClip. */
export function olderShaderBuildOnPage(): boolean {
const win = window as ShaderWindow;
const shaderOnPage = win.HyperShader !== undefined || win.__hf?.transitions !== undefined;
return shaderOnPage && win.__hfClipRevealAware !== true;
}
3 changes: 2 additions & 1 deletion packages/core/src/runtime/entry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { HF_COLOR_GRADING_ATTR, serializeHfColorGrading } from "../colorGrading";
import { STUDIO_PREVIEW_LAZY_ATTR, STUDIO_PREVIEW_MARK_META } from "../studioPreviewMark";
import { revealClip } from "./clipReveal";
import type { RuntimeTimelineLike } from "./types";

function pausedTimeline(duration: number): RuntimeTimelineLike {
Expand Down Expand Up @@ -242,7 +243,7 @@ describe("runtime entry", () => {
expect(imageSkipped(later, brief)).toEqual([false, false]);
window.__player?.seek(0);
// How Studio's layer reveal shows a hidden clip.
later.style.visibility = "visible";
revealClip(later);
expect(imageSkipped(later)).toEqual([false]);
});

Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/runtime/entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
} from "./init";
import { installAuthoredOpacityCapture } from "./colorGrading";
import { deferMediaUntilDue } from "./preloadMedia";
import { hideTimedClipsUntilFirstPass } from "./timedClipHide";
import { hideTimedClipsUntilFirstPass, keepInlineHideRulesForOlderShaders } from "./timedClipHide";
import { fitTextFontSize } from "../text/fitTextFontSize";
import { pretext } from "../text/pretext";
import { assetUrl } from "./assetUrl";
Expand Down Expand Up @@ -71,6 +71,7 @@ function bootstrapHyperframeRuntime(): void {
return;
}
win.__hyperframeRuntimeBootstrapped = true;
keepInlineHideRulesForOlderShaders();
initSandboxRuntimeModular();
}

Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/runtime/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -599,9 +599,10 @@ describe("initSandboxRuntimeModular", () => {
window.__player?.seek(1);
const copy = document.getElementById("scene")!.cloneNode(true) as HTMLElement;
document.body.append(copy);
// As showSceneAndClips forces a captured scene and its clips visible.
copy.style.setProperty("visibility", "visible", "important");
expect(copy.hasAttribute("data-hf-clip-hidden")).toBe(true);
// As showSceneAndClips forces a captured scene and its clips visible and drops the mark.
copy.style.setProperty("visibility", "visible", "important");
copy.removeAttribute("data-hf-clip-hidden");
const sheet = document.querySelector<HTMLStyleElement>(
"style[data-hf-hidden-clip-contents]",
)!.sheet!;
Expand Down
138 changes: 138 additions & 0 deletions packages/core/src/runtime/timedClipHide.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { afterEach, describe, expect, it } from "vitest";
import { STUDIO_PREVIEW_MARK_META } from "../studioPreviewMark";
import { revealClip } from "./clipReveal";
import {
CLIP_HIDDEN_ATTR,
hideTimedClipsUntilFirstPass,
keepInlineHideRulesForOlderShaders,
revealTimedClipsAfterFirstPass,
showHiddenClips,
} from "./timedClipHide";

const injectedSelectors = () =>
Array.from(document.head.querySelectorAll("style")).flatMap((style) =>
Array.from(style.sheet!.cssRules).map((rule) => (rule as CSSStyleRule).selectorText),
);

type ShaderWindow = Window & {
HyperShader?: unknown;
__hf?: object;
__hfClipRevealAware?: boolean;
};

afterEach(() => {
const win = window as ShaderWindow;
delete win.HyperShader;
delete win.__hf;
delete win.__hfClipRevealAware;
revealTimedClipsAfterFirstPass();
document.head.innerHTML = "";
document.body.innerHTML = "";
});

describe("hideTimedClipsUntilFirstPass", () => {
it("injects no rule that matches an element's inline style, in playback or Studio preview", () => {
document.head.append(
Object.assign(document.createElement("meta"), { name: STUDIO_PREVIEW_MARK_META }),
);
hideTimedClipsUntilFirstPass();
const selectors = injectedSelectors();
expect(selectors.length).toBeGreaterThan(2);
expect(selectors.filter((selector) => /\[style\b/.test(selector))).toEqual([]);
});

it("hides a hidden clip's contents by the runtime's mark alone", () => {
hideTimedClipsUntilFirstPass();
document.body.innerHTML = `<div data-start="5" ${CLIP_HIDDEN_ATTR}><p style="visibility: visible">Later</p></div>`;
expect(getComputedStyle(document.querySelector("p")!).visibility).toBe("hidden");
});

// jsdom resolves !important ties by order, not weight, so the weight is asserted on the selector.
it("keeps the contents rule at the weight of two attribute selectors", () => {
hideTimedClipsUntilFirstPass();
expect(injectedSelectors()).toContain(`[${CLIP_HIDDEN_ATTR}][${CLIP_HIDDEN_ATTR}] *`);
});
});

describe("keepInlineHideRulesForOlderShaders", () => {
// How a shader build that predates revealClip leaves a scene it reveals for capture.
const revealedByInlineStyleOnly = `<div data-start="5" ${CLIP_HIDDEN_ATTR} style="visibility: visible"><p>Title</p><img src="a.png"></div>`;
const previewWith = (win: Partial<ShaderWindow>) => {
Object.assign(window, win);
document.head.append(
Object.assign(document.createElement("meta"), { name: STUDIO_PREVIEW_MARK_META }),
);
hideTimedClipsUntilFirstPass();
keepInlineHideRulesForOlderShaders();
revealTimedClipsAfterFirstPass();
document.body.innerHTML = `${revealedByInlineStyleOnly}<div data-start="9" ${CLIP_HIDDEN_ATTR} style="visibility: hidden"><p>Later</p></div>`;
const [revealed, hidden] = Array.from(document.querySelectorAll("p"));
return {
revealed: getComputedStyle(revealed!).visibility,
revealedImage: getComputedStyle(document.querySelector("img")!).display,
hidden: getComputedStyle(hidden!).visibility,
};
};

it("shows a scene an older shader build revealed, found by its global or its transitions", () => {
for (const older of [{ HyperShader: {} }, { __hf: { transitions: [] } }]) {
expect(previewWith(older)).toEqual({
revealed: "visible",
revealedImage: "inline",
hidden: "hidden",
});
delete (window as ShaderWindow).HyperShader;
document.head.innerHTML = "";
}
});

it("keeps the mark-only rules for a shader build that declares it lifts the mark", () => {
expect(previewWith({ HyperShader: {}, __hfClipRevealAware: true })).toEqual({
revealed: "hidden",
revealedImage: "none",
hidden: "hidden",
});
expect(injectedSelectors().filter((selector) => /\[style\b/.test(selector))).toEqual([]);
});
});

describe("showHiddenClips", () => {
it("lifts the mark while the clips show and puts it back with their inline hide", () => {
hideTimedClipsUntilFirstPass();
document.body.innerHTML = `<div data-start="5" ${CLIP_HIDDEN_ATTR} style="visibility: hidden"><p>Later</p></div>`;
const clip = document.querySelector<HTMLElement>("[data-start]")!;

const hideAgain = showHiddenClips();
expect([clip.hasAttribute(CLIP_HIDDEN_ATTR), clip.style.visibility]).toEqual([
false,
"visible",
]);
hideAgain();
expect([clip.hasAttribute(CLIP_HIDDEN_ATTR), clip.style.visibility]).toEqual([true, "hidden"]);
});
});

describe("revealClip", () => {
it("shows a clip and lifts its mark until put back, and leaves an unmarked element unmarked", () => {
document.body.innerHTML = `<div id="marked" ${CLIP_HIDDEN_ATTR} style="visibility: hidden"></div><div id="plain"></div>`;
const marked = document.getElementById("marked")!;
const plain = document.getElementById("plain")!;

const putMarkedBack = revealClip(marked, "important");
const putPlainBack = revealClip(plain);
expect([marked.style.cssText, marked.hasAttribute(CLIP_HIDDEN_ATTR)]).toEqual([
"visibility: visible !important;",
false,
]);
expect([plain.style.visibility, plain.hasAttribute(CLIP_HIDDEN_ATTR)]).toEqual([
"visible",
false,
]);
putMarkedBack();
putPlainBack();
expect([marked.hasAttribute(CLIP_HIDDEN_ATTR), plain.hasAttribute(CLIP_HIDDEN_ATTR)]).toEqual([
true,
false,
]);
});
});
38 changes: 26 additions & 12 deletions packages/core/src/runtime/timedClipHide.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { CLIP_HIDDEN_ATTR, olderShaderBuildOnPage, revealClip } from "./clipReveal";
import {
RUNTIME_STAMPED_ATTR,
STUDIO_PREVIEW_CAPTURE_MODE,
Expand All @@ -13,23 +14,28 @@ const HIDE_UNTIL_FIRST_PASS =
"[data-start]:not(video, audio, img) { visibility: hidden !important; }";
const PREVIEW_HIDE_UNTIL_FIRST_PASS = 'img[loading="lazy"] { display: none !important; }';
const SKIP_ATTR = "data-hf-skip-hidden-images";
export const SKIPPED_CLIP = `[data-start]:not(video, audio, img, [${STUDIO_PREVIEW_UPCOMING_ATTR}], [${RUNTIME_STAMPED_ATTR}])[style*="visibility: hidden"]`;
const SKIP_HIDDEN_IMAGES = `${SKIPPED_CLIP} img { display: none !important; }`;
export { CLIP_HIDDEN_ATTR };
const SKIPPABLE_CLIP = `[data-start]:not(video, audio, img, [${STUDIO_PREVIEW_UPCOMING_ATTR}], [${RUNTIME_STAMPED_ATTR}])`;
export const SKIPPED_CLIP = `${SKIPPABLE_CLIP}[${CLIP_HIDDEN_ATTR}]`;
const STUDIO_PREVIEW_MARK = `meta[name="${STUDIO_PREVIEW_MARK_META}"]`;
export const CLIP_HIDDEN_ATTR = "data-hf-clip-hidden";
const CONTENTS_ATTR = "data-hf-hidden-clip-contents";
const HIDDEN_CLIP = `[${CLIP_HIDDEN_ATTR}][style*="visibility: hidden"]`;
const HIDE_HIDDEN_CLIP_CONTENTS = `${HIDDEN_CLIP} * { visibility: hidden !important; }`;
const FILTER_OUTPUT_LAYERS = [HIDDEN_CLIP, `${HIDDEN_CLIP} *`]
.flatMap((layer) => [layer, `${layer}::before`, `${layer}::after`])
.join(", ");
const HIDE_FILTER_OUTPUT = `${FILTER_OUTPUT_LAYERS} { filter: none !important; }`;
const HIDDEN_CLIP_DOUBLED_FOR_WEIGHT = `[${CLIP_HIDDEN_ATTR}][${CLIP_HIDDEN_ATTR}]`;
const HIDDEN_CLIP_BY_INLINE_STYLE = `[${CLIP_HIDDEN_ATTR}][style*="visibility: hidden"]`;
const skipHiddenImages = (clip: string) =>
`${SKIPPABLE_CLIP}${clip} img { display: none !important; }`;
const hideHiddenClipContents = (clip: string) => {
const filterOutputLayers = [clip, `${clip} *`]
.flatMap((layer) => [layer, `${layer}::before`, `${layer}::after`])
.join(", ");
return `${clip} * { visibility: hidden !important; } ${filterOutputLayers} { filter: none !important; }`;
};

export function showHiddenClips(): () => void {
const hidden = Array.from(document.querySelectorAll<HTMLElement>(`[${CLIP_HIDDEN_ATTR}]`));
for (const el of hidden) el.style.visibility = "visible";
const putMarksBack = hidden.map((el) => revealClip(el));
return () => {
for (const el of hidden) el.style.visibility = "hidden";
for (const putMarkBack of putMarksBack) putMarkBack();
};
}

Expand Down Expand Up @@ -62,9 +68,9 @@ export function hideTimedClipsUntilFirstPass(): void {
const preview = mark !== null;
const capture = mark?.getAttribute("content") === STUDIO_PREVIEW_CAPTURE_MODE;
if (preview && !capture && !skipsHiddenImages())
appendStyle(parent, SKIP_ATTR, SKIP_HIDDEN_IMAGES);
appendStyle(parent, SKIP_ATTR, skipHiddenImages(HIDDEN_CLIP_DOUBLED_FOR_WEIGHT));
if (!document.querySelector(`style[${CONTENTS_ATTR}]`))
appendStyle(parent, CONTENTS_ATTR, `${HIDE_HIDDEN_CLIP_CONTENTS} ${HIDE_FILTER_OUTPUT}`);
appendStyle(parent, CONTENTS_ATTR, hideHiddenClipContents(HIDDEN_CLIP_DOUBLED_FOR_WEIGHT));
const win = window as FirstPassWindow;
// A runtime that already initialised may never run another pass to lift a new rule.
if (win.__hfFirstPassHidden || win.__hyperframeRuntimeBootstrapped) return;
Expand All @@ -83,3 +89,11 @@ export function revealTimedClipsAfterFirstPass(): boolean {
for (const style of document.querySelectorAll(`style[${HIDE_ATTR}]`)) style.remove();
return true;
}

export function keepInlineHideRulesForOlderShaders(): void {
if (!olderShaderBuildOnPage()) return;
const skip = document.querySelector(`style[${SKIP_ATTR}]`);
if (skip) skip.textContent = skipHiddenImages(HIDDEN_CLIP_BY_INLINE_STYLE);
const contents = document.querySelector(`style[${CONTENTS_ATTR}]`);
if (contents) contents.textContent = hideHiddenClipContents(HIDDEN_CLIP_BY_INLINE_STYLE);
}
1 change: 1 addition & 0 deletions packages/core/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
},
"files": [
"src/runtime/clipTree.ts",
"src/runtime/clipReveal.ts",
"src/runtime/compositionLength.ts",
"src/runtime/customEase.ts",
"src/runtime/mediaVolumeEnvelope.ts",
Expand Down
Loading
Loading