diff --git a/packages/cli/src/registry/motionBlurShutter.browser.test.ts b/packages/cli/src/registry/motionBlurShutter.browser.test.ts index fef71d3558..e0dd1d2873 100644 --- a/packages/cli/src/registry/motionBlurShutter.browser.test.ts +++ b/packages/cli/src/registry/motionBlurShutter.browser.test.ts @@ -116,6 +116,14 @@ function makeTimeline(onTo?: () => void): Fake { return { tl, currentTime: () => now, fire: () => onUpdate?.(), trackers: () => trackers }; } +/** What the browser resolves for the target: an inline !important, else the marker's rule, else the inline value. */ +function wordVisibility(word: Element): string { + const style = (word as HTMLElement).style; + if (style.getPropertyPriority("visibility") === "important") return style.visibility; + if (word.hasAttribute(HIDING)) return "hidden"; + return style.visibility || "visible"; +} + /** * happy-dom resolves no transforms of its own, so the element's computed style is the trajectory. * The stub also answers the declaration enumeration the style replay uses, and the perspective. @@ -135,6 +143,7 @@ function installComputedStyle( transformOrigin: "50% 50%", opacity: element === word ? opacity() : "1", perspective: element === word ? "none" : perspective(), + visibility: element === word ? wordVisibility(word) : "visible", }) as unknown as CSSStyleDeclaration) as typeof globalThis.getComputedStyle; } @@ -232,6 +241,8 @@ function copyPitches(offsets: number[]): number[] { } const originalGetComputedStyle = globalThis.getComputedStyle; +/** The marker sharp 0 sets on a moving target; a stylesheet rule does the hiding. */ +const HIDING = "data-hf-motion-blur-hiding"; beforeEach(() => { // The snippet polls the registry on a timer for seconds. Fake timers let a test reach the @@ -255,12 +266,13 @@ afterEach(async () => { describe("motion-blur snippet copies", () => { // The demos and the example composition have to inline the snippet — a catalog plate is a single - // self-contained file and cannot import one. That makes four copies of the same shutter model, so + // self-contained file and cannot import one. That makes five copies of the same shutter model, so // the copies are asserted equal here rather than left to drift silently. it.each([ "registry/components/motion-blur/demo.html", "registry/components/shutter-slam/shutter-slam.html", "registry/components/shutter-slam/demo.html", + "registry/blocks/gooey-split/gooey-split.html", ])("%s inlines the installable snippet verbatim", (relativePath) => { expect(snippetBody(readRepoFile(relativePath))).toBe(snippetBody(snippetHtml)); }); @@ -314,6 +326,87 @@ describe("motion-blur shutter matches the After Effects reference", () => { // group has to precede the element rather than follow it. expect(group.nextElementSibling).toBe(word); expect(word.style.opacity).toBe(""); + expect(word.hasAttribute(HIDING)).toBe(false); + expect(word.style.visibility).toBe(""); + }); + + it("leaves only the shutter average while moving at sharp 0, and the element at rest", async () => { + let speed = 1; + const { group, word, fire } = await attach({ sharp: 0 }, (df) => translating(df * speed)); + + expect(group.style.display).toBe(""); + expect(word.hasAttribute(HIDING)).toBe(true); + + speed = 0; + fire(); + await Promise.resolve(); + + expect(group.style.display).toBe("none"); + expect(word.hasAttribute(HIDING)).toBe(false); + }); + + it("leaves the element's own visibility to the timeline at sharp 0", async () => { + // An autoAlpha fade writes visibility itself; hiding through it would keep the element + // hidden after the fade brings it back. + let speed = 1; + const { word, fire } = await attach({ sharp: 0 }, (df) => translating(df * speed)); + word.style.visibility = "inherit"; + + speed = 0; + fire(); + await Promise.resolve(); + + expect(word.style.visibility).toBe("inherit"); + }); + + it("smears in the element's current visibility, read without its own marker", async () => { + // A second frame starts with the marker set, so a read that did not lift it would hide + // the smear along with the element. + const { group, word, fire } = await attach({ sharp: 0 }); + fire(); + await Promise.resolve(); + + expect(word.hasAttribute(HIDING)).toBe(true); + expect(group.style.visibility).toBe(""); + }); + + it("leaves the smear's visibility to an ancestor while the element shares it", async () => { + // A clip that ends hides its subtree after the last moving frame; an explicit visible on + // the group would keep the copies on screen past it. + const { group } = await attach(); + + expect(group.style.display).toBe(""); + expect(group.style.visibility).toBe(""); + }); + + it("hides the smear with an element the timeline hides", async () => { + const { group, word, fire } = await attach(); + word.style.visibility = "hidden"; + fire(); + await Promise.resolve(); + + expect(group.style.visibility).toBe("hidden"); + }); + + it("does not freeze the element's visibility into the copies", async () => { + // An element attached while an autoAlpha entrance still hides it would otherwise smear + // as nothing for the rest of the timeline. + const observer = installResizeObserver(); + const { word, copies } = await attach(); + const mocked = globalThis.getComputedStyle; + globalThis.getComputedStyle = ((element: Element) => { + if (element !== word) return mocked(element); + const values: Record = { visibility: "hidden", color: "red" }; + return Object.assign(Object.create(mocked(element)), Object.keys(values), { + length: 2, + getPropertyValue: (name: string) => values[name] ?? "", + }); + }) as typeof globalThis.getComputedStyle; + + observer.resize(); + + expect(copies[0]?.style.color).toBe("red"); + expect(copies[0]?.style.visibility).toBe(""); }); it("carries the element's own opacity on the smear", async () => { @@ -501,6 +594,61 @@ describe("motion-blur declarative attribute", () => { expect(target.groups()[0]?.children).toHaveLength(5); }); + it("keeps a descendant's own hiding in a copy of a target hidden at attach", async () => { + // An autoAlpha entrance hides the target when it is blurred. A child hidden by its own + // rule must still differ from it, or the smear would show what the element hides. + const target = declare(""); + const own = document.createElement("span"); + const inheriting = document.createElement("span"); + target.word.append(own, inheriting); + target.word.style.setProperty("visibility", "hidden", "important"); + const mocked = globalThis.getComputedStyle; + const resolved = (element: Element, value: string) => + Object.assign(Object.create(mocked(element)), ["visibility"], { + length: 1, + visibility: value, + getPropertyValue: (name: string) => (name === "visibility" ? value : ""), + }); + globalThis.getComputedStyle = ((element: Element) => { + if (element === own) return resolved(element, "hidden"); + if (element === inheriting || element === target.word) { + return resolved(element, wordVisibility(target.word)); + } + return mocked(element); + }) as typeof globalThis.getComputedStyle; + + target.register(); + target.fire(); + await Promise.resolve(); + + const copy = target.groups()[0]?.children[0]; + if (!copy) throw new Error("motion-blur group carries no copies"); + expect((copy.children[0] as HTMLElement).style.visibility).toBe("hidden"); + expect((copy.children[1] as HTMLElement).style.visibility).toBe(""); + expect(target.word.style.visibility).toBe("hidden"); + expect(target.word.style.getPropertyPriority("visibility")).toBe("important"); + }); + + it("puts a hidden target's visibility back when copying its styles throws", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const target = declare(""); + const child = document.createElement("span"); + target.word.append(child); + target.word.style.visibility = "hidden"; + const mocked = globalThis.getComputedStyle; + globalThis.getComputedStyle = ((element: Element) => { + if (element === child) throw new Error("no style"); + return mocked(element); + }) as typeof globalThis.getComputedStyle; + + target.register(); + + expect(target.groups()).toHaveLength(0); + expect(target.word.style.visibility).toBe("hidden"); + expect(target.word.style.getPropertyPriority("visibility")).toBe(""); + warn.mockRestore(); + }); + it("leaves a target alone when another composition registers", async () => { // One page can hold several compositions, and a target belongs to the nearest one. const target = declare(""); @@ -764,6 +912,18 @@ describe("motion-blur declarative attribute, the cases only executing found", () expect(leading).toBeCloseTo(reference.leadingDisplacementPx, 1); }); + it("refuses a sharp other than 0 or 1 instead of reading it as crisp", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const target = declare('{"sharp": 0.5}'); + + target.register(); + await target.settle(); + + expect(target.groups()).toHaveLength(0); + expect(String(warn.mock.calls[0]?.[0])).toContain("needs 0 or 1 for sharp"); + warn.mockRestore(); + }); + it("refuses an option name it does not know instead of rendering with the defaults", async () => { // The likeliest typo in a JSON object is the key, and reading it as defaults is exactly // the silent no-op the attribute exists to remove. diff --git a/registry/blocks/gooey-split/gooey-split.html b/registry/blocks/gooey-split/gooey-split.html new file mode 100644 index 0000000000..b4037d1081 --- /dev/null +++ b/registry/blocks/gooey-split/gooey-split.html @@ -0,0 +1,957 @@ + + + + + + + + + + +
+ +
+
+
+
+
+
+
+
+ +
+
+ +
+
+ +
+
+
+
+
+ + + + diff --git a/registry/blocks/gooey-split/registry-item.json b/registry/blocks/gooey-split/registry-item.json new file mode 100644 index 0000000000..65a4fb138e --- /dev/null +++ b/registry/blocks/gooey-split/registry-item.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://hyperframes.heygen.com/schema/registry-item.json", + "name": "gooey-split", + "type": "hyperframes:block", + "title": "Gooey Split", + "description": "A dot splits into a three-dot menu with a gooey metaball neck, spins into a swirl mark under directional shutter motion blur, then breaks back into three dots and merges into one. The goo is an SVG blur-and-threshold filter on the dots; the blur integrates each part along its real trajectory, so the smear follows the direction and speed of the spin.", + "dimensions": { "width": 1920, "height": 1080 }, + "duration": 4.7, + "tags": ["effect", "gooey", "metaball", "motion-blur", "morph", "logo", "icon"], + "files": [ + { + "path": "gooey-split.html", + "target": "compositions/gooey-split.html", + "type": "hyperframes:composition" + } + ] +} diff --git a/registry/components/motion-blur/demo.html b/registry/components/motion-blur/demo.html index 8a8eac71b2..025e427ffe 100644 --- a/registry/components/motion-blur/demo.html +++ b/registry/components/motion-blur/demo.html @@ -274,12 +274,24 @@ var POLL_MS = 32; var POLL_TICKS = 250; var SWEEP_SELECTOR = "[" + SWEEP_ATTR + "]"; - var OPTION_KEYS = ["shutterAngle", "shutterPhase", "samplesPerFrame", "fps"]; + var OPTION_KEYS = ["shutterAngle", "shutterPhase", "samplesPerFrame", "fps", "sharp"]; // A copy is a deep clone, so the attribute rides along on the root AND on every marked // descendant. Any one left behind is swept as a target of its own, which clones again. var COPY_STRIP = [SWEEP_ATTR, "data-composition-id", "data-fps"]; + // sharp 0 hides a moving target through this marker rather than its own visibility, + // which the author's timeline (autoAlpha) may be writing too. + var HIDE_ATTR = "data-hf-motion-blur-hiding"; + + function installHideRule() { + if (document.getElementById("hf-mb-hide")) return; + var rule = document.createElement("style"); + rule.id = "hf-mb-hide"; + rule.textContent = "[" + HIDE_ATTR + "]{visibility:hidden!important}"; + (document.head || document.documentElement).appendChild(rule); + } + function stripAttrs(el) { for (var a = 0; a < COPY_STRIP.length; a++) el.removeAttribute(COPY_STRIP[a]); } @@ -366,6 +378,8 @@ var shutterAngle = numOption(opts.shutterAngle, 720); var shutterPhase = numOption(opts.shutterPhase, -360); var requestedSamples = numOption(opts.samplesPerFrame, 16); + var sharp = opts.sharp !== 0; + if (!sharp) installHideRule(); var samples = Number.isFinite(requestedSamples) ? Math.max(2, Math.min(64, Math.round(requestedSamples))) : 16; @@ -392,17 +406,23 @@ // drops every `#id` rule that gave it size, colour and font. So each copy carries // its own resolved style inline. Chrome returns "" for a computed style's cssText, // so the declarations are enumerated rather than taken wholesale. - function resolvedStyles(source, out) { + // Visibility is not frozen into a copy: its root takes the element's current one from + // the group each frame, and a descendant keeps its own only where it differs from its + // parent's. A target hidden at attach (autoAlpha) would otherwise never smear. + function resolvedStyles(source, out, parentVisibility) { var cs = getComputedStyle(source); var text = ""; for (var i = 0; i < cs.length; i++) { - text += cs[i] + ":" + cs.getPropertyValue(cs[i]) + ";"; + var value = cs.getPropertyValue(cs[i]); + var inherited = parentVisibility === undefined || value === parentVisibility; + if (cs[i] === "visibility" && inherited) continue; + text += cs[i] + ":" + value + ";"; } // A copy is a still of one instant. Left live, an inherited transition or // keyframe animation would carry it somewhere the timeline never sampled. out.push(text + "transition:none;animation:none;"); for (var c = 0; c < source.children.length; c++) { - resolvedStyles(source.children[c], out); + resolvedStyles(source.children[c], out, cs.getPropertyValue("visibility")); } return out; } @@ -451,7 +471,18 @@ // that would walk the whole subtree once per copy; on resize it costs nothing // on a fixed-size render and keeps a live preview honest. function snapshot() { - var styles = resolvedStyles(el, []); + // Read a target hidden right now (an autoAlpha entrance) as shown, so a descendant + // hidden by its own rule still differs from it; the inline value goes back exactly. + var hidden = getComputedStyle(el).visibility !== "visible"; + var inline = el.style.getPropertyValue("visibility"); + var priority = el.style.getPropertyPriority("visibility"); + if (hidden) el.style.setProperty("visibility", "visible", "important"); + var styles; + try { + styles = resolvedStyles(el, []); + } finally { + if (hidden) el.style.setProperty("visibility", inline, priority); + } s.perspective = parentPerspective(el); for (var c = 0; c < clones.length; c++) { paintResolvedStyles(clones[c], styles, { i: 0 }); @@ -515,6 +546,7 @@ css: cs.transform, origin: cs.transformOrigin, opacity: cs.opacity, + visibility: cs.visibility, parsed: parseTransform(cs.transform), }; } @@ -550,6 +582,8 @@ } state.forEach(function (s) { + // Read the element as the timeline left it: the marker is this snippet's own. + if (!sharp) s.el.removeAttribute(HIDE_ATTR); var current = readTransform(s.el); var moved = false; for (var i = 0; i < copies; i++) { @@ -580,12 +614,17 @@ clone.style.transform = s.perspective + (s.samples[j].css === "none" ? "" : s.samples[j].css); } - // The copies are the element's own ink, so they have to carry its opacity too: + // The copies are the element's own ink, so they carry its opacity and visibility: // a beat that moves and fades at once would otherwise leave a full-strength // smear behind a vanishing element. It rides the group, not the copies, whose // own opacity is the 1/N shutter weight. s.group.style.opacity = current.opacity; + // Set only where the element differs from its parent: an explicit value would + // outlive an ancestor that hides later, like a clip that has ended. + var shared = getComputedStyle(s.el.parentNode).visibility; + s.group.style.visibility = current.visibility === shared ? "" : current.visibility; s.group.style.display = ""; + if (!sharp) s.el.setAttribute(HIDE_ATTR, ""); }); } @@ -639,6 +678,9 @@ if (bad !== null) return refuse(el, "names no such option: " + bad); var wrong = nonNumber(parsed); if (wrong !== null) return refuse(el, "needs a number for " + wrong); + if (parsed.sharp !== undefined && parsed.sharp !== 0 && parsed.sharp !== 1) { + return refuse(el, "needs 0 or 1 for sharp"); + } return parsed; } diff --git a/registry/components/motion-blur/motion-blur.html b/registry/components/motion-blur/motion-blur.html index b07c9118d4..aebd983b8f 100644 --- a/registry/components/motion-blur/motion-blur.html +++ b/registry/components/motion-blur/motion-blur.html @@ -33,7 +33,8 @@ No single copy ever exceeds 1/N, so a moving edge resolves as a staircase of ghosts behind a solid frame-time instance. The group carries the element's own opacity, so a beat that moves and fades at - once fades its smear with it. + once fades its smear with it. With `sharp: 0` the element is hidden + while it moves instead, leaving only the shutter average. 4. When every sampled transform collapses to the current one (element at rest), the group is hidden so the element renders sharp. @@ -111,8 +112,14 @@ target's own composition root, else 30). Pass it explicitly when rendering with an fps override (`hyperframes render --fps`). + sharp — 1 (default) paints the element itself, crisp, over its + smear; 0 hides it while it moves, leaving only the + shutter average: a soft blur with no frame-time edge. + It hides through a marker attribute, never the + element's own visibility, so autoAlpha still works + (an inline visibility !important on it wins). - A key that is none of the four, or a value that is not a finite number, is + A key that is none of the five, or a value that is not a finite number, is refused by name rather than read as defaults. Both halves matter: a non-numeric angle hides the smear, and a non-numeric phase seeks every copy to NaN, which parks them at the timeline's start. @@ -136,12 +143,24 @@ var POLL_MS = 32; var POLL_TICKS = 250; var SWEEP_SELECTOR = "[" + SWEEP_ATTR + "]"; - var OPTION_KEYS = ["shutterAngle", "shutterPhase", "samplesPerFrame", "fps"]; + var OPTION_KEYS = ["shutterAngle", "shutterPhase", "samplesPerFrame", "fps", "sharp"]; // A copy is a deep clone, so the attribute rides along on the root AND on every marked // descendant. Any one left behind is swept as a target of its own, which clones again. var COPY_STRIP = [SWEEP_ATTR, "data-composition-id", "data-fps"]; + // sharp 0 hides a moving target through this marker rather than its own visibility, + // which the author's timeline (autoAlpha) may be writing too. + var HIDE_ATTR = "data-hf-motion-blur-hiding"; + + function installHideRule() { + if (document.getElementById("hf-mb-hide")) return; + var rule = document.createElement("style"); + rule.id = "hf-mb-hide"; + rule.textContent = "[" + HIDE_ATTR + "]{visibility:hidden!important}"; + (document.head || document.documentElement).appendChild(rule); + } + function stripAttrs(el) { for (var a = 0; a < COPY_STRIP.length; a++) el.removeAttribute(COPY_STRIP[a]); } @@ -228,6 +247,8 @@ var shutterAngle = numOption(opts.shutterAngle, 720); var shutterPhase = numOption(opts.shutterPhase, -360); var requestedSamples = numOption(opts.samplesPerFrame, 16); + var sharp = opts.sharp !== 0; + if (!sharp) installHideRule(); var samples = Number.isFinite(requestedSamples) ? Math.max(2, Math.min(64, Math.round(requestedSamples))) : 16; @@ -254,17 +275,23 @@ // drops every `#id` rule that gave it size, colour and font. So each copy carries // its own resolved style inline. Chrome returns "" for a computed style's cssText, // so the declarations are enumerated rather than taken wholesale. - function resolvedStyles(source, out) { + // Visibility is not frozen into a copy: its root takes the element's current one from + // the group each frame, and a descendant keeps its own only where it differs from its + // parent's. A target hidden at attach (autoAlpha) would otherwise never smear. + function resolvedStyles(source, out, parentVisibility) { var cs = getComputedStyle(source); var text = ""; for (var i = 0; i < cs.length; i++) { - text += cs[i] + ":" + cs.getPropertyValue(cs[i]) + ";"; + var value = cs.getPropertyValue(cs[i]); + var inherited = parentVisibility === undefined || value === parentVisibility; + if (cs[i] === "visibility" && inherited) continue; + text += cs[i] + ":" + value + ";"; } // A copy is a still of one instant. Left live, an inherited transition or // keyframe animation would carry it somewhere the timeline never sampled. out.push(text + "transition:none;animation:none;"); for (var c = 0; c < source.children.length; c++) { - resolvedStyles(source.children[c], out); + resolvedStyles(source.children[c], out, cs.getPropertyValue("visibility")); } return out; } @@ -313,7 +340,18 @@ // that would walk the whole subtree once per copy; on resize it costs nothing // on a fixed-size render and keeps a live preview honest. function snapshot() { - var styles = resolvedStyles(el, []); + // Read a target hidden right now (an autoAlpha entrance) as shown, so a descendant + // hidden by its own rule still differs from it; the inline value goes back exactly. + var hidden = getComputedStyle(el).visibility !== "visible"; + var inline = el.style.getPropertyValue("visibility"); + var priority = el.style.getPropertyPriority("visibility"); + if (hidden) el.style.setProperty("visibility", "visible", "important"); + var styles; + try { + styles = resolvedStyles(el, []); + } finally { + if (hidden) el.style.setProperty("visibility", inline, priority); + } s.perspective = parentPerspective(el); for (var c = 0; c < clones.length; c++) { paintResolvedStyles(clones[c], styles, { i: 0 }); @@ -377,6 +415,7 @@ css: cs.transform, origin: cs.transformOrigin, opacity: cs.opacity, + visibility: cs.visibility, parsed: parseTransform(cs.transform), }; } @@ -412,6 +451,8 @@ } state.forEach(function (s) { + // Read the element as the timeline left it: the marker is this snippet's own. + if (!sharp) s.el.removeAttribute(HIDE_ATTR); var current = readTransform(s.el); var moved = false; for (var i = 0; i < copies; i++) { @@ -442,12 +483,17 @@ clone.style.transform = s.perspective + (s.samples[j].css === "none" ? "" : s.samples[j].css); } - // The copies are the element's own ink, so they have to carry its opacity too: + // The copies are the element's own ink, so they carry its opacity and visibility: // a beat that moves and fades at once would otherwise leave a full-strength // smear behind a vanishing element. It rides the group, not the copies, whose // own opacity is the 1/N shutter weight. s.group.style.opacity = current.opacity; + // Set only where the element differs from its parent: an explicit value would + // outlive an ancestor that hides later, like a clip that has ended. + var shared = getComputedStyle(s.el.parentNode).visibility; + s.group.style.visibility = current.visibility === shared ? "" : current.visibility; s.group.style.display = ""; + if (!sharp) s.el.setAttribute(HIDE_ATTR, ""); }); } @@ -501,6 +547,9 @@ if (bad !== null) return refuse(el, "names no such option: " + bad); var wrong = nonNumber(parsed); if (wrong !== null) return refuse(el, "needs a number for " + wrong); + if (parsed.sharp !== undefined && parsed.sharp !== 0 && parsed.sharp !== 1) { + return refuse(el, "needs 0 or 1 for sharp"); + } return parsed; } diff --git a/registry/components/shutter-slam/demo.html b/registry/components/shutter-slam/demo.html index 49c51c7f5d..9aa2ced4d2 100644 --- a/registry/components/shutter-slam/demo.html +++ b/registry/components/shutter-slam/demo.html @@ -104,12 +104,24 @@ var POLL_MS = 32; var POLL_TICKS = 250; var SWEEP_SELECTOR = "[" + SWEEP_ATTR + "]"; - var OPTION_KEYS = ["shutterAngle", "shutterPhase", "samplesPerFrame", "fps"]; + var OPTION_KEYS = ["shutterAngle", "shutterPhase", "samplesPerFrame", "fps", "sharp"]; // A copy is a deep clone, so the attribute rides along on the root AND on every marked // descendant. Any one left behind is swept as a target of its own, which clones again. var COPY_STRIP = [SWEEP_ATTR, "data-composition-id", "data-fps"]; + // sharp 0 hides a moving target through this marker rather than its own visibility, + // which the author's timeline (autoAlpha) may be writing too. + var HIDE_ATTR = "data-hf-motion-blur-hiding"; + + function installHideRule() { + if (document.getElementById("hf-mb-hide")) return; + var rule = document.createElement("style"); + rule.id = "hf-mb-hide"; + rule.textContent = "[" + HIDE_ATTR + "]{visibility:hidden!important}"; + (document.head || document.documentElement).appendChild(rule); + } + function stripAttrs(el) { for (var a = 0; a < COPY_STRIP.length; a++) el.removeAttribute(COPY_STRIP[a]); } @@ -196,6 +208,8 @@ var shutterAngle = numOption(opts.shutterAngle, 720); var shutterPhase = numOption(opts.shutterPhase, -360); var requestedSamples = numOption(opts.samplesPerFrame, 16); + var sharp = opts.sharp !== 0; + if (!sharp) installHideRule(); var samples = Number.isFinite(requestedSamples) ? Math.max(2, Math.min(64, Math.round(requestedSamples))) : 16; @@ -222,17 +236,23 @@ // drops every `#id` rule that gave it size, colour and font. So each copy carries // its own resolved style inline. Chrome returns "" for a computed style's cssText, // so the declarations are enumerated rather than taken wholesale. - function resolvedStyles(source, out) { + // Visibility is not frozen into a copy: its root takes the element's current one from + // the group each frame, and a descendant keeps its own only where it differs from its + // parent's. A target hidden at attach (autoAlpha) would otherwise never smear. + function resolvedStyles(source, out, parentVisibility) { var cs = getComputedStyle(source); var text = ""; for (var i = 0; i < cs.length; i++) { - text += cs[i] + ":" + cs.getPropertyValue(cs[i]) + ";"; + var value = cs.getPropertyValue(cs[i]); + var inherited = parentVisibility === undefined || value === parentVisibility; + if (cs[i] === "visibility" && inherited) continue; + text += cs[i] + ":" + value + ";"; } // A copy is a still of one instant. Left live, an inherited transition or // keyframe animation would carry it somewhere the timeline never sampled. out.push(text + "transition:none;animation:none;"); for (var c = 0; c < source.children.length; c++) { - resolvedStyles(source.children[c], out); + resolvedStyles(source.children[c], out, cs.getPropertyValue("visibility")); } return out; } @@ -281,7 +301,18 @@ // that would walk the whole subtree once per copy; on resize it costs nothing // on a fixed-size render and keeps a live preview honest. function snapshot() { - var styles = resolvedStyles(el, []); + // Read a target hidden right now (an autoAlpha entrance) as shown, so a descendant + // hidden by its own rule still differs from it; the inline value goes back exactly. + var hidden = getComputedStyle(el).visibility !== "visible"; + var inline = el.style.getPropertyValue("visibility"); + var priority = el.style.getPropertyPriority("visibility"); + if (hidden) el.style.setProperty("visibility", "visible", "important"); + var styles; + try { + styles = resolvedStyles(el, []); + } finally { + if (hidden) el.style.setProperty("visibility", inline, priority); + } s.perspective = parentPerspective(el); for (var c = 0; c < clones.length; c++) { paintResolvedStyles(clones[c], styles, { i: 0 }); @@ -345,6 +376,7 @@ css: cs.transform, origin: cs.transformOrigin, opacity: cs.opacity, + visibility: cs.visibility, parsed: parseTransform(cs.transform), }; } @@ -380,6 +412,8 @@ } state.forEach(function (s) { + // Read the element as the timeline left it: the marker is this snippet's own. + if (!sharp) s.el.removeAttribute(HIDE_ATTR); var current = readTransform(s.el); var moved = false; for (var i = 0; i < copies; i++) { @@ -410,12 +444,18 @@ clone.style.transform = s.perspective + (s.samples[j].css === "none" ? "" : s.samples[j].css); } - // The copies are the element's own ink, so they have to carry its opacity too: + // The copies are the element's own ink, so they carry its opacity and visibility: // a beat that moves and fades at once would otherwise leave a full-strength // smear behind a vanishing element. It rides the group, not the copies, whose // own opacity is the 1/N shutter weight. s.group.style.opacity = current.opacity; + // Set only where the element differs from its parent: an explicit value would + // outlive an ancestor that hides later, like a clip that has ended. + var shared = getComputedStyle(s.el.parentNode).visibility; + s.group.style.visibility = + current.visibility === shared ? "" : current.visibility; s.group.style.display = ""; + if (!sharp) s.el.setAttribute(HIDE_ATTR, ""); }); } @@ -469,6 +509,9 @@ if (bad !== null) return refuse(el, "names no such option: " + bad); var wrong = nonNumber(parsed); if (wrong !== null) return refuse(el, "needs a number for " + wrong); + if (parsed.sharp !== undefined && parsed.sharp !== 0 && parsed.sharp !== 1) { + return refuse(el, "needs 0 or 1 for sharp"); + } return parsed; } diff --git a/registry/components/shutter-slam/shutter-slam.html b/registry/components/shutter-slam/shutter-slam.html index de2aadaab5..e74355141b 100644 --- a/registry/components/shutter-slam/shutter-slam.html +++ b/registry/components/shutter-slam/shutter-slam.html @@ -138,12 +138,24 @@ var POLL_MS = 32; var POLL_TICKS = 250; var SWEEP_SELECTOR = "[" + SWEEP_ATTR + "]"; - var OPTION_KEYS = ["shutterAngle", "shutterPhase", "samplesPerFrame", "fps"]; + var OPTION_KEYS = ["shutterAngle", "shutterPhase", "samplesPerFrame", "fps", "sharp"]; // A copy is a deep clone, so the attribute rides along on the root AND on every marked // descendant. Any one left behind is swept as a target of its own, which clones again. var COPY_STRIP = [SWEEP_ATTR, "data-composition-id", "data-fps"]; + // sharp 0 hides a moving target through this marker rather than its own visibility, + // which the author's timeline (autoAlpha) may be writing too. + var HIDE_ATTR = "data-hf-motion-blur-hiding"; + + function installHideRule() { + if (document.getElementById("hf-mb-hide")) return; + var rule = document.createElement("style"); + rule.id = "hf-mb-hide"; + rule.textContent = "[" + HIDE_ATTR + "]{visibility:hidden!important}"; + (document.head || document.documentElement).appendChild(rule); + } + function stripAttrs(el) { for (var a = 0; a < COPY_STRIP.length; a++) el.removeAttribute(COPY_STRIP[a]); } @@ -230,6 +242,8 @@ var shutterAngle = numOption(opts.shutterAngle, 720); var shutterPhase = numOption(opts.shutterPhase, -360); var requestedSamples = numOption(opts.samplesPerFrame, 16); + var sharp = opts.sharp !== 0; + if (!sharp) installHideRule(); var samples = Number.isFinite(requestedSamples) ? Math.max(2, Math.min(64, Math.round(requestedSamples))) : 16; @@ -256,17 +270,23 @@ // drops every `#id` rule that gave it size, colour and font. So each copy carries // its own resolved style inline. Chrome returns "" for a computed style's cssText, // so the declarations are enumerated rather than taken wholesale. - function resolvedStyles(source, out) { + // Visibility is not frozen into a copy: its root takes the element's current one from + // the group each frame, and a descendant keeps its own only where it differs from its + // parent's. A target hidden at attach (autoAlpha) would otherwise never smear. + function resolvedStyles(source, out, parentVisibility) { var cs = getComputedStyle(source); var text = ""; for (var i = 0; i < cs.length; i++) { - text += cs[i] + ":" + cs.getPropertyValue(cs[i]) + ";"; + var value = cs.getPropertyValue(cs[i]); + var inherited = parentVisibility === undefined || value === parentVisibility; + if (cs[i] === "visibility" && inherited) continue; + text += cs[i] + ":" + value + ";"; } // A copy is a still of one instant. Left live, an inherited transition or // keyframe animation would carry it somewhere the timeline never sampled. out.push(text + "transition:none;animation:none;"); for (var c = 0; c < source.children.length; c++) { - resolvedStyles(source.children[c], out); + resolvedStyles(source.children[c], out, cs.getPropertyValue("visibility")); } return out; } @@ -315,7 +335,18 @@ // that would walk the whole subtree once per copy; on resize it costs nothing // on a fixed-size render and keeps a live preview honest. function snapshot() { - var styles = resolvedStyles(el, []); + // Read a target hidden right now (an autoAlpha entrance) as shown, so a descendant + // hidden by its own rule still differs from it; the inline value goes back exactly. + var hidden = getComputedStyle(el).visibility !== "visible"; + var inline = el.style.getPropertyValue("visibility"); + var priority = el.style.getPropertyPriority("visibility"); + if (hidden) el.style.setProperty("visibility", "visible", "important"); + var styles; + try { + styles = resolvedStyles(el, []); + } finally { + if (hidden) el.style.setProperty("visibility", inline, priority); + } s.perspective = parentPerspective(el); for (var c = 0; c < clones.length; c++) { paintResolvedStyles(clones[c], styles, { i: 0 }); @@ -379,6 +410,7 @@ css: cs.transform, origin: cs.transformOrigin, opacity: cs.opacity, + visibility: cs.visibility, parsed: parseTransform(cs.transform), }; } @@ -414,6 +446,8 @@ } state.forEach(function (s) { + // Read the element as the timeline left it: the marker is this snippet's own. + if (!sharp) s.el.removeAttribute(HIDE_ATTR); var current = readTransform(s.el); var moved = false; for (var i = 0; i < copies; i++) { @@ -444,12 +478,18 @@ clone.style.transform = s.perspective + (s.samples[j].css === "none" ? "" : s.samples[j].css); } - // The copies are the element's own ink, so they have to carry its opacity too: + // The copies are the element's own ink, so they carry its opacity and visibility: // a beat that moves and fades at once would otherwise leave a full-strength // smear behind a vanishing element. It rides the group, not the copies, whose // own opacity is the 1/N shutter weight. s.group.style.opacity = current.opacity; + // Set only where the element differs from its parent: an explicit value would + // outlive an ancestor that hides later, like a clip that has ended. + var shared = getComputedStyle(s.el.parentNode).visibility; + s.group.style.visibility = + current.visibility === shared ? "" : current.visibility; s.group.style.display = ""; + if (!sharp) s.el.setAttribute(HIDE_ATTR, ""); }); } @@ -503,6 +543,9 @@ if (bad !== null) return refuse(el, "names no such option: " + bad); var wrong = nonNumber(parsed); if (wrong !== null) return refuse(el, "needs a number for " + wrong); + if (parsed.sharp !== undefined && parsed.sharp !== 0 && parsed.sharp !== 1) { + return refuse(el, "needs 0 or 1 for sharp"); + } return parsed; } diff --git a/skills-manifest.json b/skills-manifest.json index ab4d95cff4..581b3c40e8 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -22,7 +22,7 @@ "files": 29 }, "hyperframes-animation": { - "hash": "6af72990949385ca", + "hash": "179715a416750b26", "files": 122 }, "hyperframes-audio": { diff --git a/skills/hyperframes-animation/references/motion-blur.md b/skills/hyperframes-animation/references/motion-blur.md index e15b32ff41..1ae967a5e4 100644 --- a/skills/hyperframes-animation/references/motion-blur.md +++ b/skills/hyperframes-animation/references/motion-blur.md @@ -61,14 +61,15 @@ that is already in the document, use the attribute. ## Options -| Option | Default | Meaning | -| ----------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `shutterAngle` | 720 | Degrees of the frame interval the shutter is open. 720 is two frames, measured off a real After Effects export. 360 is one frame. 0 disables the smear | -| `shutterPhase` | -360 | Degrees the window start sits from the frame time. -360 centres the window on the frame | -| `samplesPerFrame` | 16 | Sub-intervals of the window, so this many plus one copies, each at 1 over this many opacity. Max 64 | -| `fps` | the `data-fps` of the target's own composition root, else 30 | Composition frame rate. Pass it explicitly when rendering with an fps override | +| Option | Default | Meaning | +| ----------------- | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `shutterAngle` | 720 | Degrees of the frame interval the shutter is open. 720 is two frames, measured off a real After Effects export. 360 is one frame. 0 disables the smear | +| `shutterPhase` | -360 | Degrees the window start sits from the frame time. -360 centres the window on the frame | +| `samplesPerFrame` | 16 | Sub-intervals of the window, so this many plus one copies, each at 1 over this many opacity. Max 64 | +| `fps` | the `data-fps` of the target's own composition root, else 30 | Composition frame rate. Pass it explicitly when rendering with an fps override | +| `sharp` | 1 | 1 paints the element itself, crisp, over its smear. 0 hides it while it moves, leaving only the shutter average: a soft blur with no frame-time edge. It hides through a marker attribute, never the element's own visibility, so autoAlpha still works (an inline `visibility: visible !important` on it wins) | -A key that is none of these four, and a value that is not a finite number, are +A key that is none of these five, and a value that is not a finite number, are both refused by name rather than read as defaults. `{"shutterAngle": "720deg"}` is a refusal, not a 720 degree shutter. @@ -87,7 +88,7 @@ nothing except where noted: | Console warns that no composition registered a timeline for an element | The element is outside every `data-composition-id`, or that composition never registers a timeline | Put it inside the composition's root, or register the timeline | | Console warns the attribute is not JSON | Single quotes, unquoted keys, a trailing comma | Double-quoted JSON, or an empty attribute for defaults | | Console warns the attribute is not a JSON object | `null`, a bare number, a quoted string, an array | An object, or an empty attribute for defaults | -| Console warns the attribute names no such option | A misspelled key, for example `samplesperframe` | Use one of the four names above, case-sensitive | +| Console warns the attribute names no such option | A misspelled key, for example `samplesperframe` | Use one of the five names above, case-sensitive | | Console warns the attribute needs a number for an option | A quoted or unit-suffixed value, for example `"720deg"` | A bare JSON number | | Console warns it cannot blur a target inside another target | Both an element and one of its ancestors carry the attribute | Mark one of them, the one that moves | | Console warns one call cannot blur compositions at different frame rates | One `attachMotionBlur` call named elements in two compositions whose `data-fps` differ | One call per composition |