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
2 changes: 1 addition & 1 deletion docs/catalog/components/motion-blur.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { InstallCommand } from "/snippets/install-command.jsx";
title="Motion Blur"
description="After Effects-style motion blur — integrates each element over a shutter window (shutter angle, phase, samples per frame) by sampling the GSAP timeline at sub-frame times and averaging equal-weight additive copies of the element along its real trajectory, so translation, scale, rotation, 3D rotation and skew all drive the smear"
variables={[]}
meta={{"category":"Effects","badge":"Stable","codeLines":406}}
meta={{"category":"Effects","badge":"Stable","codeLines":722}}
attribution={{"path":"registry/components/motion-blur","tags":["effect","motion-blur","shutter","after-effects","velocity","animation"]}}
hasCode
rawUrl="https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry/components/motion-blur/motion-blur.html"
Expand Down
2 changes: 1 addition & 1 deletion docs/catalog/components/shutter-slam.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { InstallCommand } from "/snippets/install-command.jsx";
title="Shutter Slam"
description="One word thrown through six single-property beats under integrated shutter blur — two translate slams, a scale punch, then a full turn about each axis — on the measured easing of the After Effects export the shutter model was fitted to"
variables={[{"id":"text","type":"string","role":"content","label":"Word","description":"The word driven through the six beats.","default":"MOTION"},{"id":"accent","type":"enum","role":"style","label":"Accent","description":"Word color.","default":"green","options":[{"value":"green","label":"Green"},{"value":"blue","label":"Blue"},{"value":"violet","label":"Violet"}]},{"id":"blur","type":"enum","role":"motion","label":"Blur","description":"Integrate each frame over the shutter window, or capture one instant per frame.","default":"shutter","options":[{"value":"shutter","label":"Shutter"},{"value":"none","label":"None"}]},{"id":"cues","type":"string","role":"motion","label":"Cues","description":"Comma seconds from mount start for each of the six beats; empty keeps the authored rhythm.","default":""},{"id":"exit","type":"enum","role":"motion","label":"Exit","description":"How the word leaves.","default":"none","options":[{"value":"none","label":"None"},{"value":"fade","label":"Fade"},{"value":"up","label":"Up"}]}]}
meta={{"category":"Effects","badge":"Stable","codeLines":541}}
meta={{"category":"Effects","badge":"Stable","codeLines":830}}
attribution={{"path":"registry/components/shutter-slam","tags":["effect","motion-blur","shutter","after-effects","slam","animation"]}}
hasCode
rawUrl="https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry/components/shutter-slam/shutter-slam.html"
Expand Down
2 changes: 1 addition & 1 deletion docs/public/catalog-index.json
Original file line number Diff line number Diff line change
Expand Up @@ -3682,7 +3682,7 @@
"sphere"
],
"href": "/catalog/blocks/orbit-card",
"preview": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/orbit-card.png"
"preview": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/orbit-card-v2.png"
},
{
"name": "ordered-dither-pass",
Expand Down
111 changes: 92 additions & 19 deletions docs/snippets/catalog-detail.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,64 @@ export const CatalogSlot = ({ slot, children }) => (
</div>
);

/** The Tune panel's variable list, with a pulsing cue while more rows sit below the fold. */
export const TuneList = ({ variables, values, notes, onValues, onNotes, onTyping, control, readout }) => {
// BEGIN hasMoreBelow: true once unseen content sits past the scrolled viewport (4px epsilon).
const hasMoreBelow = (scrollHeight, scrollTop, clientHeight) => scrollHeight - scrollTop - clientHeight > 4;
// END hasMoreBelow
const listRef = useRef(null);
const [moreBelow, setMoreBelow] = useState(false);
const check = () => {
const el = listRef.current;
if (el) setMoreBelow(hasMoreBelow(el.scrollHeight, el.scrollTop, el.clientHeight));
};
useEffect(() => {
const el = listRef.current;
if (!el) return;
check();
const observer = new ResizeObserver(check);
observer.observe(el);
for (const child of el.children) observer.observe(child);
return () => observer.disconnect();
}, []);
return (
<div className="hf-ve-tune-list-wrap">
<div className="hf-ve-tune-list" ref={listRef} onScroll={check}>
{variables.map((v) => (
<div key={v.id}>
<div className="hf-ve-row">
<label className="hf-ve-label">{v.label ?? v.id}</label>
<span className="hf-ve-value">{readout(v, values[v.id])}</span>
</div>
{control(
v,
values[v.id],
(next) => onValues((prev) => ({ ...prev, [v.id]: next })),
notes[v.id],
(note) => onNotes((prev) => ({ ...prev, [v.id]: note })),
onTyping,
)}
{v.description && <p className="hf-ve-desc">{v.description}</p>}
</div>
))}
</div>
{moreBelow && (
<div className="hf-ve-tune-more" aria-hidden="true">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path
d="M2.5 4.5L6 8L9.5 4.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
)}
</div>
);
};

export const CatalogDetail = ({
previewSrc,
compositionId,
Expand Down Expand Up @@ -668,6 +726,7 @@ export const CatalogDetail = ({
font-weight: 600;
}
.hf-ve-tune-head small { font-weight: 400; font-size: 13px; color: var(--ve-muted); }
.hf-ve-tune-list-wrap { position: relative; flex: 1; min-height: 0; display: flex; }
.hf-ve-tune-list {
flex: 1;
min-height: 0;
Expand All @@ -679,6 +738,29 @@ export const CatalogDetail = ({
align-content: start;
mask-image: linear-gradient(to bottom, transparent 0, #000 12px, #000 calc(100% - 12px), transparent 100%);
}
.hf-ve-tune-more {
position: absolute;
left: 50%;
bottom: 6px;
transform: translateX(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 9999px;
background: var(--ve-hover);
color: var(--ve-muted);
pointer-events: none;
animation: hf-ve-tune-more-pulse 1.6s ease-in-out infinite;
}
@keyframes hf-ve-tune-more-pulse {
0%, 100% { opacity: 1; transform: translateX(-50%) scale(1); }
50% { opacity: 0.55; transform: translateX(-50%) scale(1.15); }
}
@media (prefers-reduced-motion: reduce) {
.hf-ve-tune-more { animation: none; }
}
.hf-ve-tune-foot {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
Expand Down Expand Up @@ -2141,25 +2223,16 @@ export const CatalogDetail = ({
<div className="hf-ve-tune-head">
Tune <small>{variables.length} {variables.length === 1 ? "variable" : "variables"}</small>
</div>
<div className="hf-ve-tune-list">
{variables.map((v) => (
<div key={v.id}>
<div className="hf-ve-row">
<label className="hf-ve-label">{v.label ?? v.id}</label>
<span className="hf-ve-value">{readout(v, values[v.id])}</span>
</div>
{control(
v,
values[v.id],
(next) => setValues((prev) => ({ ...prev, [v.id]: next })),
notes[v.id],
(note) => setNotes((prev) => ({ ...prev, [v.id]: note })),
setTyping,
)}
{v.description && <p className="hf-ve-desc">{v.description}</p>}
</div>
))}
</div>
<TuneList
variables={variables}
values={values}
notes={notes}
onValues={setValues}
onNotes={setNotes}
onTyping={setTyping}
control={control}
readout={readout}
/>
<div className="hf-ve-tune-foot">
<button
type="button"
Expand Down
2 changes: 1 addition & 1 deletion docs/snippets/catalog-gallery-data.mdx

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion docs/snippets/catalog-gallery.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ export const CatalogGallery = ({ catalog, initialGroup = "", initialSection = ""
// still dedupes the actual script load across every instance).
const PLAYER_SCRIPT_URL = 'https://cdn.jsdelivr.net/npm/@hyperframes/player@latest/dist/hyperframes-player.global.js';
const REST_SECONDS = 3;
const MAX_DOM_PLAYERS = 6;
// The grid is 3 columns; the 200px prefetch margin keeps roughly a row above and below the
// viewport intersecting too. 24 covers every dom-tier tile a normal viewport can show at
// once, so a visible tile is never left on its dark fallback behind cheaper CSS-only cards.
const MAX_DOM_PLAYERS = 24;
const MAX_WEBGL_PLAYERS = 1;
const READY_TIMEOUT_MS = 6000;
// BEGIN revealWhenPainted: calls reveal one frame after the player's assets have settled (the player fires assetsready within 8 s).
Expand Down
2 changes: 1 addition & 1 deletion registry/blocks/orbit-card/registry-item.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,6 @@
],
"preview": {
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/orbit-card.mp4",
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/orbit-card.png"
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/orbit-card-v2.png"
}
}
32 changes: 31 additions & 1 deletion scripts/catalog-drift.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,18 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import { treeDifferences } from "./catalog-drift.ts";
import { codeLinesDrift, treeDifferences } from "./catalog-drift.ts";

// orbit-card is a real registry item, chosen because its source is short and stable, so the
// fixture below pins a real line count rather than a value nobody will notice drift from.
const REAL_ITEM = "orbit-card";
const REAL_CODE_LINES = 102;

function catalogDir(codeLines: number): string {
return tree({
[`blocks/${REAL_ITEM}.mdx`]: `---\ntitle: "Orbit Card"\n---\n<CatalogDetail meta={{"codeLines":${codeLines}}} hasCode />\n`,
});
}

function tree(files: Record<string, string>): string {
const root = mkdtempSync(join(tmpdir(), "catalog-drift-test-"));
Expand Down Expand Up @@ -33,3 +44,22 @@ test("a stale payload, a missing file and a leftover file each turn the check re
rmSync(generated, { recursive: true });
rmSync(committed, { recursive: true });
});

test("codeLinesDrift is clean when the committed page matches its own source", () => {
const committed = catalogDir(REAL_CODE_LINES);
assert.deepEqual(codeLinesDrift(committed), []);
rmSync(committed, { recursive: true });
});

test("a stale codeLines turns the check red, naming the page and both counts", () => {
const committed = catalogDir(REAL_CODE_LINES + 50);
const found = codeLinesDrift(committed);
assert.equal(found.length, 1);
assert.match(
found[0]!,
new RegExp(
`blocks/${REAL_ITEM}\\.mdx says ${REAL_CODE_LINES + 50}, source is ${REAL_CODE_LINES} lines`,
),
);
rmSync(committed, { recursive: true });
});
78 changes: 65 additions & 13 deletions scripts/catalog-drift.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
// Fails when the committed docs/public/catalog, which the docs build serves as-is, differs from generator output.
import { spawnSync } from "node:child_process";
import { mkdirSync, readdirSync, readFileSync, mkdtempSync, rmSync } from "node:fs";
import { existsSync, mkdirSync, readdirSync, readFileSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, relative, resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { runAsCommand } from "./entrypoint.ts";
import { discoverItems, primarySource } from "./generate-catalog-pages.ts";

const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const MAX_LISTED = 40;
Expand Down Expand Up @@ -34,6 +35,31 @@ export function treeDifferences(generatedRoot: string, committedRoot: string): s
];
}

/** One item's stale-codeLines message, or null when there's nothing to compare (no source file,
* no committed page, or a page with no code fence). */
function codeLinesMismatch(
item: ReturnType<typeof discoverItems>[number],
committedCatalogDir: string,
): string | null {
const file = primarySource(item.kind, item.manifest);
if (!file) return null;
const dir = item.kind === "block" ? "blocks" : "components";
const pagePath = join(committedCatalogDir, dir, `${item.manifest.name}.mdx`);
if (!existsSync(pagePath)) return null; // an absent page is treeDifferences' job, not this one's
const match = readFileSync(pagePath, "utf-8").match(/"codeLines":(\d+)/);
if (!match || !match[1]) return null; // page has no code fence, nothing to compare
const committed = Number(match[1]);
const real = file.source.split("\n").length;
if (committed === real) return null;
return `stale codeLines: catalog/${dir}/${item.manifest.name}.mdx says ${committed}, source is ${real} lines`;
}

export function codeLinesDrift(committedCatalogDir: string): string[] {
return discoverItems()
.map((item) => codeLinesMismatch(item, committedCatalogDir))
.filter((line) => line !== null);
}

function generateInto(outRoot: string): number {
const run = spawnSync("npx", ["tsx", "scripts/generate-catalog-payloads.ts"], {
cwd: repoRoot,
Expand All @@ -43,31 +69,57 @@ function generateInto(outRoot: string): number {
return run.status ?? 1;
}

/** The catalog as `git` holds it, so a file the working tree has but a .gitignore rule keeps out cannot hide drift. */
function extractCommittedCatalog(into: string): string {
const archive = spawnSync("git", ["archive", "HEAD", "docs/public/catalog"], {
/** A tree as `git` holds it, so a file the working tree has but a .gitignore rule keeps out cannot hide drift. */
function extractCommitted(gitPath: string, into: string): string {
const archive = spawnSync("git", ["archive", "HEAD", gitPath], {
cwd: repoRoot,
maxBuffer: 2 ** 31 - 1,
});
if (archive.status !== 0) throw new Error("git archive of docs/public/catalog failed.");
if (archive.status !== 0) throw new Error(`git archive of ${gitPath} failed.`);
mkdirSync(into, { recursive: true });
const untar = spawnSync("tar", ["-x", "-C", into], { input: archive.stdout });
if (untar.status !== 0) throw new Error("could not unpack the committed catalog.");
return join(into, "docs/public/catalog");
if (untar.status !== 0) throw new Error(`could not unpack the committed ${gitPath}.`);
return join(into, gitPath);
}

async function main(): Promise<void> {
const base = mkdtempSync(join(tmpdir(), "catalog-drift-"));
const outRoot = join(base, "generated");
try {
if (generateInto(outRoot) !== 0) throw new Error("The catalog payload generator failed.");
const committedRoot = extractCommittedCatalog(join(base, "committed"));
const differences = treeDifferences(outRoot, committedRoot);
if (differences.length === 0) return console.log("docs/public/catalog matches the generator.");
const listed = differences.slice(0, MAX_LISTED).map((line) => ` ${line}`);
throw new Error(
`docs/public/catalog differs from the generator in ${differences.length} file(s):\n${listed.join("\n")}\nRegenerate with \`tsx scripts/generate-catalog-payloads.ts\` and commit the result.`,
const committedPayloadRoot = extractCommitted(
"docs/public/catalog",
join(base, "committed-payload"),
);
const differences = treeDifferences(outRoot, committedPayloadRoot);
const committedCatalogDir = extractCommitted("docs/catalog", join(base, "committed-catalog"));
const staleCodeLines = codeLinesDrift(committedCatalogDir);
if (differences.length === 0 && staleCodeLines.length === 0) {
return console.log("docs/public/catalog and every page's codeLines match the generator.");
}
const sections = [
{
items: differences,
header: (n: number) => `docs/public/catalog differs from the generator in ${n} file(s):`,
regenCmd:
"Regenerate with `tsx scripts/generate-catalog-payloads.ts` and commit the result.",
},
{
items: staleCodeLines,
header: (n: number) => `${n} page(s) have a stale codeLines:`,
regenCmd: "Regenerate with `tsx scripts/generate-catalog-pages.ts` and commit the result.",
},
];
const lines = sections.flatMap(({ items, header, regenCmd }) =>
items.length === 0
? []
: [
header(items.length),
...items.slice(0, MAX_LISTED).map((line) => ` ${line}`),
regenCmd,
],
);
throw new Error(lines.join("\n"));
} finally {
rmSync(base, { recursive: true, force: true });
}
Expand Down
Loading
Loading