feat(registry): bring back the video-primitive moves (#3169)

Restores the 208 catalog items reverted after their previews 404'd in
production, this time on the payload mechanism rather than the .html files
that caused the outage.

The generator no longer writes a preview document to docs/public. That writer,
and the machinery under it, existed only to produce files the docs host
discards, so it is gone rather than bypassed. Items now embed the composition
itself via a payload, which is what the previous change already does for the
items that were already in the catalog.

The variables explorer is parked, not restored: it drove its preview through
the same unpublished .html path, so it would have shown an empty frame. Items
that declare variables get the live player plus the static variables table, and
reconnecting the explorer to payloads is a follow-up.
This commit is contained in:
Miguel Ángel
2026-08-10 18:46:03 -04:00
committed by GitHub
parent 0f76305191
commit 9734578e60
1383 changed files with 255573 additions and 5760 deletions
+118
View File
@@ -0,0 +1,118 @@
<!doctype html>
<html lang="en" data-composition-id="device-frame-stage-demo" data-composition-duration="5">
<!--
Device Frame Stage - Demo. Mounts the primitive via a clip with
data-composition-src instead of inlining its markup, proving the
sub-composition mount contract (template + #root + host-driven sizing;
see skills/hyperframes-core/references/sub-compositions.md).
The mount clip is deliberately sized to a 960x540 slot inside this
1920x1080 stage (not full-bleed): the primitive must size the device off
that HOST box, not off an assumed 1920x1080 stage, or it renders 2x too
large and bleeds past the slot's border.
Non-default variables (tablet / island / silver) exercise all three
variables away from the primitive's defaults (phone / none / graphite);
see device-frame-stage.html for the full header (concept, envelope, sync
points, sound cues).
-->
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>Device Frame Stage - Demo</title>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
}
#root {
/* 17-token dark theme contract, matching the other test-scene hosts. */
--bg: #06080b;
--fg: #f4f6f8;
--muted: #8b95a4;
--surface: #12161d;
--border: rgba(244, 246, 248, 0.16);
--brand: #ff7a33;
--accent: #22d3ee;
--font-display:
-apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", Roboto, sans-serif;
--font-body:
-apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, sans-serif;
--font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
--radius: 16px;
--space-1: 8px;
--space-2: 16px;
--space-3: 32px;
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--bg);
color: var(--fg);
font-family: var(--font-body);
}
.demo-backdrop {
position: absolute;
inset: 0;
background: radial-gradient(120% 120% at 50% 18%, #222b40 0%, #05070d 65%);
}
/* The mount slot: 960x540, centered. Border makes any overflow past
this box immediately visible in a snapshot. */
.demo-slot {
position: absolute;
left: 480px;
top: 270px;
width: 960px;
height: 540px;
border: 1px dashed var(--border);
border-radius: var(--radius);
overflow: hidden;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="device-frame-stage-demo"
data-start="0"
data-width="1920"
data-height="1080"
data-duration="5"
>
<div class="demo-backdrop"></div>
<div
id="device-frame-stage-mount"
class="clip demo-slot"
data-composition-id="device-frame-stage"
data-composition-src="./device-frame-stage.html"
data-variable-values='{"device":"tablet","cutout":"island","body":"silver"}'
data-start="0"
data-duration="5"
data-track-index="0"
data-width="960"
data-height="540"
></div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
window.__timelines["device-frame-stage-demo"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,466 @@
<!doctype html>
<html
lang="en"
data-composition-id="device-frame-stage"
data-composition-duration="5"
data-composition-variables='[
{ "id": "device", "type": "enum", "role": "layout", "label": "Device kind", "default": "phone", "options": [{ "value": "phone", "label": "Phone" }, { "value": "tablet", "label": "Tablet" }] },
{ "id": "cutout", "type": "enum", "role": "layout", "label": "Camera cutout", "default": "none", "options": [{ "value": "none", "label": "None" }, { "value": "island", "label": "Island" }, { "value": "notch", "label": "Notch" }] },
{ "id": "body", "type": "enum", "role": "style", "label": "Body material", "default": "graphite", "options": [{ "value": "graphite", "label": "Graphite" }, { "value": "silver", "label": "Silver" }] }
]'
>
<!--
Device Frame Stage, a HyperFrames video primitive.
Concept: a phone or tablet mockup treated as a physical scene prop, not a
screenshot border. It stages arbitrary reconstructed UI in a screen slot,
rises onto the stage as one rigid prop, settles, idles, and holds. The
frame serves the screen (device-frame-spec's governing law): no spinning
hardware reveal, glare sweep, or camera-lens tour, ever.
This file is a mountable HyperFrames sub-composition (see
skills/hyperframes-core/references/sub-compositions.md): a host
composition embeds it via a clip carrying data-composition-src, a
matching data-composition-id, and its own data-start / data-duration /
data-track-index / data-width / data-height. Everything the render needs
lives inside <template> below; the #root div's own data-width /
data-height are declarative reference metadata only (the linter's
root-dimensions contract) -- the CSS never reads them, and the device
sizes relative to whatever host box it actually lands in (cqh/cqw), so
it never needs to be opened or rendered standalone. See demo.html for
the reference mount.
Compiled from: fixture + mobile mechanics inventory #1 + device-frame-spec.md
(~/dev/hyperframes-corpus-data/mobile/device-frame-spec.md), the
authoritative geometry / material / entry-law source for this primitive.
Use when: a scene needs to stage mobile or tablet UI as a physical
product (hero product demo, gesture-led interaction, screen swap) rather
than a bare floating screenshot.
Variables (data-composition-variables above):
device enum phone|tablet default "phone" canonical outer ratio + logical viewport
cutout enum none|island|notch default "none" camera cutout; must match the installed screen content's safe area
body enum graphite|silver default "graphite" device material tone
Envelope (IN / HOLD / OUT, elasticity lives in HOLD only, never gsap.timeScale):
IN 1.08s fixed rise-and-settle: 0.18s pre-roll, 0.72s power3.out rise past rest, 0.18s power2.inOut settle
HOLD elastic idle float loop (period 3.2s, +/-0.6cqh), stretches or compresses to fill whatever duration remains
OUT 0.56s fixed sink, power2.in
Sync points (fixed offsets into IN/OUT, never inside the elastic HOLD):
device-settled t = IN (end of the settle segment) — screen content may start animating here
Sound cues: one SFX hook, "device-settle-soft", fired as an `hf:sfx`
CustomEvent at the device-settled sync point (t = IN). The primitive
never plays audio itself; the host scene mixes the cue.
-->
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>Device Frame Stage</title>
<!-- head is metadata for the source file only; the runtime only clones
<template> contents on mount (see sub-compositions.md), so the gsap
loader here exists purely for opening this file directly during
authoring -- a host composition already provides window.gsap. -->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
</head>
<body>
<template>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
}
/* INVARIANT: root is styled by #root, never a class -- the compiler
scopes CSS to [data-composition-id="device-frame-stage"] as a
descendant selector, so a rule keyed on the root's own class would
never match the root itself (see sub-compositions.md, Pitfall 3).
This primitive is elastic and fills whatever box a host clip
gives it, from a 960x540 slot up to a full 1920x1080 frame.
The width/height !important below is load-bearing, not decoration:
when this root gets inlined into a host, the compiler's
flatten step (prepareFlattenedInnerRoot) reads this element's own
data-width/data-height (required by the root_missing_dimensions
lint rule) and force-writes them back as an inline
style="width:1920px;height:1080px". Inline style beats a plain
stylesheet rule, so without !important the root would render at a
fixed 1920x1080 instead of filling the host box -- exactly the
2x-oversized-and-bleeding bug this primitive was fixed for. */
#root {
position: absolute;
inset: 0;
width: 100% !important;
height: 100% !important;
container-type: size;
isolation: isolate;
}
.device-stage {
--stage-perspective: 1600px;
position: absolute;
inset: 0;
perspective: var(--stage-perspective);
perspective-origin: 50% 50%;
}
.device-frame {
/* INVARIANT: geometry below is physical-material law from
device-frame-spec.md (corner/bezel/aspect percentages, gradient,
shadow), not brand paint. It may carry literal fallbacks per the
primitive's material exception; do not swap in --radius/--space-*. */
--device-color: #1b1d22;
--device-color-hi: #343740;
--device-color-dark: #111318;
--device-radius: 46px;
--device-bezel: 9px;
--screen-radius: calc(var(--device-radius) - var(--device-bezel));
--device-aspect: 9 / 19.5;
--device-y: 18cqh;
--device-scale: 0.94;
position: absolute;
left: 50%;
top: 50%;
height: 90cqh;
aspect-ratio: var(--device-aspect);
border-radius: var(--device-radius);
padding: var(--device-bezel);
background: linear-gradient(
145deg,
var(--device-color-hi),
var(--device-color) 42%,
var(--device-color-dark)
);
border: 1px solid rgba(255, 255, 255, 0.22);
box-shadow:
0 52px 110px rgba(7, 10, 20, 0.3),
0 18px 38px rgba(7, 10, 20, 0.22),
0 3px 9px rgba(7, 10, 20, 0.16),
inset 0 1px 0 rgba(255, 255, 255, 0.24),
inset 0 0 0 1px rgba(0, 0, 0, 0.48);
transform: translate(-50%, calc(-50% + var(--device-y))) scale(var(--device-scale));
transform-style: preserve-3d;
transform-origin: 50% 88%;
will-change: transform;
}
/* EDIT ZONE: kind/cutout/body are driven by data-composition-variables
(see JS below); an installing agent may also override
--device-color / --device-color-hi / --device-color-dark inline for
a bespoke finish without touching the geometry laws. */
.device-frame[data-kind="tablet"] {
--device-radius: 48px;
--device-bezel: 16px;
--screen-radius: 34px;
--device-aspect: 3 / 4;
height: 82cqh;
}
.device-frame[data-body="silver"] {
--device-color: #c9cdd3;
--device-color-hi: #eef1f4;
--device-color-dark: #9a9ea6;
}
.device-screen {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
border-radius: var(--screen-radius);
background: var(--surface, #f5f6f8);
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.42);
isolation: isolate;
container-type: size;
}
.screen-viewport {
/* Logical-pixel authoring space (390x844 phone / 768x1024 tablet),
uniformly scaled to the actual screen box by JS-measured
--screen-scale (device-frame-spec, "Screen content contract"). The
scale is scene setup, not animation. */
position: absolute;
left: 0;
top: 0;
width: calc(var(--logical-width, 390) * 1px);
height: calc(var(--logical-height, 844) * 1px);
transform-origin: 0 0;
transform: scale(var(--screen-scale, 1));
}
.screen-safe-area {
height: 100%;
box-sizing: border-box;
padding-top: calc(var(--logical-height, 844) * 1px * 0.03);
}
.device-frame[data-cutout="island"] .screen-safe-area {
padding-top: calc(var(--logical-height, 844) * 1px * 0.075);
}
.device-frame[data-cutout="notch"] .screen-safe-area {
padding-top: calc(var(--logical-height, 844) * 1px * 0.06);
}
.device-screen-slot {
position: relative;
width: 100%;
height: 100%;
}
/* Visible only while the slot is empty (comments do not count toward
:empty); vanishes the moment an installing agent drops real content
in, no cleanup required. */
.device-screen-slot:empty {
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed var(--border, rgba(15, 23, 42, 0.16));
border-radius: calc(var(--screen-radius) * 0.4);
}
.device-screen-slot:empty::before {
content: "screen slot: replace with reconstructed UI";
padding: var(--space-2, 16px);
color: var(--muted, #94a3b8);
font-family: var(--font-body, Inter, system-ui, sans-serif);
font-size: 15px;
text-align: center;
}
.device-cutout {
position: absolute;
left: 50%;
top: 0;
z-index: 50;
width: 0;
height: 0;
background: #050607;
pointer-events: none;
transform: translateX(-50%);
}
.device-frame[data-cutout="island"] .device-cutout {
top: 1.4%;
width: 28%;
height: 3.2%;
border-radius: 999px;
}
.device-frame[data-cutout="notch"] .device-cutout {
top: 0;
width: 52%;
height: 3.8%;
border-radius: 0 0 18px 18px;
}
</style>
<!-- data-width / data-height here satisfy the linter's root-dimensions
contract (root_missing_dimensions) and match every other
sub-composition's #root -- see sub-compositions.md's own
file-shape example. The compiler's flatten step also reads these
two attributes and force-writes them back as an inline
width/height style on mount, so #root's width/height !important
above is required to keep the rendered box elastic (fills the
host clip's actual box, from a 960x540 slot up to a full
1920x1080 frame) instead of a fixed 1920x1080. -->
<div
id="root"
data-composition-id="device-frame-stage"
data-width="1920"
data-height="1080"
data-duration="5"
>
<div class="device-stage">
<div class="device-frame" data-kind="phone" data-cutout="none" data-body="graphite">
<div class="device-screen">
<div class="screen-viewport">
<div class="screen-safe-area">
<div class="device-screen-slot" data-screen-slot>
<!-- EDIT ZONE: replace with reconstructed product UI, or
nest a <div data-composition-src="..." data-duration="...">
sub-composition host. Author content at the device's
logical viewport (390x844 phone, 768x1024 tablet);
this slot is pre-scaled for you, so keep coordinates
in logical screen pixels, never canvas pixels. -->
</div>
</div>
</div>
</div>
<div class="device-cutout" aria-hidden="true"></div>
</div>
</div>
</div>
<script>
(function () {
"use strict";
var html = document.documentElement;
var root = document.getElementById("root");
var frame = root.querySelector(".device-frame");
var screen = root.querySelector(".device-screen");
var viewport = root.querySelector(".screen-viewport");
var declared = {};
try {
JSON.parse(html.getAttribute("data-composition-variables") || "[]").forEach(
function (variable) {
declared[variable.id] = variable.default;
},
);
} catch (error) {}
var vars = Object.assign({}, declared, window.__hfVariables || {});
var device = vars.device === "tablet" ? "tablet" : "phone";
var cutout = vars.cutout === "island" || vars.cutout === "notch" ? vars.cutout : "none";
var body = vars.body === "silver" ? "silver" : "graphite";
frame.setAttribute("data-kind", device);
frame.setAttribute("data-cutout", cutout);
frame.setAttribute("data-body", body);
var LOGICAL = { phone: [390, 844], tablet: [768, 1024] };
var logicalWidth = LOGICAL[device][0];
var logicalHeight = LOGICAL[device][1];
viewport.style.setProperty("--logical-width", String(logicalWidth));
viewport.style.setProperty("--logical-height", String(logicalHeight));
function updateScreenScale() {
var rect = screen.getBoundingClientRect();
if (rect.width > 0)
viewport.style.setProperty("--screen-scale", String(rect.width / logicalWidth));
}
updateScreenScale();
if (typeof ResizeObserver !== "undefined") {
new ResizeObserver(updateScreenScale).observe(screen);
}
// NOTE: the timeline registration key below is a literal string,
// not read from root.getAttribute("data-composition-id") -- when
// this file is mounted via data-composition-src, the compiler
// strips data-composition-id from the inlined root (the HOST
// clip keeps its own copy instead), so a live read here would
// return null and the host would never find this timeline (see
// sub-compositions.md, Pitfall 2). registry/test-scenes/slots/empty.html
// models the same literal-key pattern.
var compositionId = "device-frame-stage";
var duration = Math.max(
0.001,
parseFloat(
root.dataset.duration || html.getAttribute("data-composition-duration") || "5",
),
);
// INVARIANT: IN/OUT durations are fixed by device-frame-spec's entry
// and exit law; only HOLD stretches or compresses to fit whatever
// duration the host gives this instance. Never gsap.timeScale().
var IN_DELAY = 0.18;
var IN_RISE = 0.72;
var IN_SETTLE = 0.18;
var IN_BASE = IN_DELAY + IN_RISE + IN_SETTLE; // 1.08s
var OUT_BASE = 0.56;
var totalBase = IN_BASE + OUT_BASE;
var IN = IN_BASE;
var OUT = OUT_BASE;
if (duration < totalBase) {
var shrink = duration / totalBase;
IN = IN_BASE * shrink;
OUT = OUT_BASE * shrink;
}
var HOLD = Math.max(0, duration - (IN + OUT));
var OUT_AT = IN + HOLD;
// SYNC POINT: device-settled fires at t = IN (fixed offset into IN,
// never inside the elastic HOLD).
function easeOutCubic(x) {
return 1 - Math.pow(1 - x, 3);
}
function easeInOutQuad(x) {
return x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2;
}
function easeInQuad(x) {
return x * x;
}
function setPose(yCqh, scaleValue) {
frame.style.setProperty("--device-y", yCqh.toFixed(3) + "cqh");
frame.style.setProperty("--device-scale", scaleValue.toFixed(4));
}
function renderIn(t) {
var delayEnd = IN_DELAY * (IN / IN_BASE);
var riseEnd = delayEnd + IN_RISE * (IN / IN_BASE);
if (t <= delayEnd) {
setPose(18, 0.94);
return;
}
if (t <= riseEnd) {
var p = easeOutCubic(
Math.min(1, (t - delayEnd) / Math.max(0.0001, riseEnd - delayEnd)),
);
setPose(18 + (-1.2 - 18) * p, 0.94 + (1.01 - 0.94) * p);
return;
}
var p2 = easeInOutQuad(Math.min(1, (t - riseEnd) / Math.max(0.0001, IN - riseEnd)));
setPose(-1.2 + (0 - -1.2) * p2, 1.01 + (1 - 1.01) * p2);
}
// RETIME RANGE: the idle float is the only elastic segment. It
// repeats for as long as HOLD lasts, so stretching the host duration
// never re-times the fixed IN or OUT phases above.
function renderHold(holdT) {
var period = 3.2;
var amp = 0.6;
var phase = ((holdT % period) / period) * Math.PI * 2;
setPose(Math.sin(phase) * amp, 1);
}
function renderOut(p) {
var e = easeInQuad(Math.min(1, p));
setPose(0 + 18 * e, 1 + (0.96 - 1) * e);
}
function render(t) {
if (t <= IN) renderIn(t);
else if (t < OUT_AT) renderHold(t - IN);
else renderOut((t - OUT_AT) / Math.max(0.0001, OUT));
}
function fireSfx(id, atTime) {
root.dispatchEvent(
new CustomEvent("hf:sfx", { detail: { id: id, t: atTime }, bubbles: true }),
);
}
var proxy = { t: 0 };
var tl = gsap.timeline({ paused: true });
tl.to(proxy, {
t: duration,
duration: duration,
ease: "none",
onUpdate: function () {
render(proxy.t);
},
});
tl.call(
function () {
fireSfx("device-settle-soft", IN);
},
[],
IN,
);
tl.to({}, { duration: Math.max(0, duration - tl.duration()) });
render(0);
tl.seek(0);
window.__timelines = window.__timelines || {};
window.__timelines[compositionId] = tl;
})();
</script>
</template>
</body>
</html>
@@ -0,0 +1,55 @@
{
"$schema": "https://hyperframes.heygen.com/schema/registry-item.json",
"name": "device-frame-stage",
"type": "hyperframes:component",
"title": "Device Frame Stage",
"description": "A phone or tablet mockup staged as a physical scene prop with a screen slot for arbitrary reconstructed UI, rising onto the stage, settling, and holding an idle float",
"tags": ["prop", "device", "mobile", "tablet", "ui-props", "holdable"],
"jobs": ["demonstrate"],
"family": "ui-props",
"profile": "holdable",
"evidence": "fixture + mobile inventory #1 + device-frame-spec.md",
"files": [
{
"path": "device-frame-stage.html",
"target": "compositions/components/device-frame-stage.html",
"type": "hyperframes:snippet"
}
],
"variables": [
{
"id": "device",
"type": "enum",
"role": "layout",
"label": "Device kind",
"default": "phone",
"options": [
{ "value": "phone", "label": "Phone" },
{ "value": "tablet", "label": "Tablet" }
]
},
{
"id": "cutout",
"type": "enum",
"role": "layout",
"label": "Camera cutout",
"default": "none",
"options": [
{ "value": "none", "label": "None" },
{ "value": "island", "label": "Island" },
{ "value": "notch", "label": "Notch" }
]
},
{
"id": "body",
"type": "enum",
"role": "style",
"label": "Body material",
"default": "graphite",
"options": [
{ "value": "graphite", "label": "Graphite" },
{ "value": "silver", "label": "Silver" }
]
}
]
}