Skip to content

Commit f02ecc6

Browse files
committed
feat(registry): add media treatment overlays
1 parent d91c2f4 commit f02ecc6

23 files changed

Lines changed: 1055 additions & 2 deletions
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { describe, expect, it } from "vitest";
2+
import { Hono } from "hono";
3+
import type { StudioApiAdapter } from "../types";
4+
import { registerRegistryRoutes } from "./registry";
5+
6+
function createAdapter(): StudioApiAdapter {
7+
return {
8+
listProjects: () => [],
9+
resolveProject: async () => null,
10+
bundle: async () => null,
11+
lint: async () => ({ findings: [] }),
12+
runtimeUrl: "/api/runtime.js",
13+
rendersDir: () => "/tmp/renders",
14+
startRender: () => ({
15+
id: "job-1",
16+
status: "rendering",
17+
progress: 0,
18+
outputPath: "/tmp/out.mp4",
19+
}),
20+
readRegistryPreview: async ({ itemName, kind }) =>
21+
itemName === "camcorder-hud"
22+
? {
23+
content: Buffer.from(`${itemName}:${kind}`),
24+
contentType: kind === "poster" ? "image/png" : "video/mp4",
25+
}
26+
: null,
27+
};
28+
}
29+
30+
describe("registerRegistryRoutes", () => {
31+
it("serves generated Registry preview media", async () => {
32+
const app = new Hono();
33+
registerRegistryRoutes(app, createAdapter());
34+
35+
const response = await app.request("http://localhost/registry/previews/camcorder-hud/poster");
36+
37+
expect(response.status).toBe(200);
38+
expect(response.headers.get("Content-Type")).toBe("image/png");
39+
expect(await response.text()).toBe("camcorder-hud:poster");
40+
});
41+
42+
it("rejects invalid preview kinds and missing media", async () => {
43+
const app = new Hono();
44+
registerRegistryRoutes(app, createAdapter());
45+
46+
const invalid = await app.request("http://localhost/registry/previews/camcorder-hud/source");
47+
const invalidName = await app.request(
48+
"http://localhost/registry/previews/%2E%2E%2Fsecret/poster",
49+
);
50+
const missing = await app.request("http://localhost/registry/previews/unknown/video");
51+
52+
expect(invalid.status).toBe(400);
53+
expect(invalidName.status).toBe(400);
54+
expect(missing.status).toBe(404);
55+
});
56+
57+
it("reports preview read failures without throwing from the route", async () => {
58+
const app = new Hono();
59+
registerRegistryRoutes(app, {
60+
...createAdapter(),
61+
readRegistryPreview: async () => {
62+
throw new Error("disk unavailable");
63+
},
64+
});
65+
66+
const response = await app.request("http://localhost/registry/previews/camcorder-hud/poster");
67+
68+
expect(response.status).toBe(500);
69+
expect(await response.json()).toEqual({ error: "Registry preview unavailable" });
70+
});
71+
});

packages/studio-server/src/routes/registry.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,35 @@ export function registerRegistryRoutes(api: Hono, adapter: StudioApiAdapter): vo
1010
return c.json(items);
1111
});
1212

13+
api.get("/registry/previews/:name/:kind", async (c) => {
14+
if (!adapter.readRegistryPreview) {
15+
return c.json({ error: "Registry previews not available" }, 501);
16+
}
17+
const kind = c.req.param("kind");
18+
if (kind !== "poster" && kind !== "video") {
19+
return c.json({ error: "Invalid Registry preview kind" }, 400);
20+
}
21+
const itemName = c.req.param("name");
22+
if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(itemName)) {
23+
return c.json({ error: "Invalid Registry item name" }, 400);
24+
}
25+
const result = await adapter
26+
.readRegistryPreview({ itemName, kind })
27+
.then((preview) => ({ preview }))
28+
.catch(() => null);
29+
if (!result) {
30+
return c.json({ error: "Registry preview unavailable" }, 500);
31+
}
32+
const { preview } = result;
33+
if (!preview) return c.json({ error: "Registry preview not found" }, 404);
34+
return new Response(new Uint8Array(preview.content), {
35+
headers: {
36+
"Content-Type": preview.contentType,
37+
"Cache-Control": "no-cache",
38+
},
39+
});
40+
});
41+
1342
// fallow-ignore-next-line complexity
1443
api.post("/projects/:id/registry/install", async (c) => {
1544
if (!adapter.installRegistryBlock) {

packages/studio-server/src/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,12 @@ export interface StudioApiAdapter {
198198
/** Optional: list all registry items (blocks + components) for the catalog. */
199199
listRegistryCatalog?(): Promise<RegistryItem[]>;
200200

201+
/** Optional: serve a generated local poster/video for Registry catalog cards. */
202+
readRegistryPreview?(opts: {
203+
itemName: string;
204+
kind: "poster" | "video";
205+
}): Promise<{ content: Buffer; contentType: "image/png" | "video/mp4" } | null>;
206+
201207
/** Optional: install a registry item into a project directory. */
202208
installRegistryBlock?(opts: {
203209
project: ResolvedProject;

packages/studio/vite.adapter.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,7 @@ export function createViteAdapter(dataDir: string, server: ViteDevServer): Studi
331331
// fallow-ignore-next-line complexity
332332
async listRegistryCatalog(): Promise<RegistryItem[]> {
333333
const registryRoot = resolve(__dirname, "../../registry");
334+
const generatedPreviewRoot = resolve(__dirname, "../../docs/images/catalog");
334335
const items: RegistryItem[] = [];
335336
for (const subdir of ["blocks", "components"]) {
336337
const dir = join(registryRoot, subdir);
@@ -341,8 +342,23 @@ export function createViteAdapter(dataDir: string, server: ViteDevServer): Studi
341342
if (!existsSync(manifestPath)) continue;
342343
try {
343344
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as RegistryItem;
344-
if (manifest.type === "hyperframes:block" || manifest.type === "hyperframes:component")
345-
items.push(manifest);
345+
if (manifest.type !== "hyperframes:block" && manifest.type !== "hyperframes:component")
346+
continue;
347+
const generatedDir = join(generatedPreviewRoot, subdir);
348+
const poster = join(generatedDir, `${manifest.name}.png`);
349+
const video = join(generatedDir, `${manifest.name}.mp4`);
350+
items.push({
351+
...manifest,
352+
preview: {
353+
...(manifest.preview ?? {}),
354+
...(existsSync(poster)
355+
? { poster: `/api/registry/previews/${encodeURIComponent(manifest.name)}/poster` }
356+
: {}),
357+
...(existsSync(video)
358+
? { video: `/api/registry/previews/${encodeURIComponent(manifest.name)}/video` }
359+
: {}),
360+
},
361+
});
346362
} catch {
347363
/* skip malformed manifests */
348364
}
@@ -351,6 +367,19 @@ export function createViteAdapter(dataDir: string, server: ViteDevServer): Studi
351367
return items;
352368
},
353369

370+
async readRegistryPreview({ itemName, kind }) {
371+
const extension = kind === "poster" ? "png" : "mp4";
372+
const contentType = kind === "poster" ? "image/png" : "video/mp4";
373+
const previewRoot = resolve(__dirname, "../../docs/images/catalog");
374+
for (const subdir of ["blocks", "components"]) {
375+
const previewPath = join(previewRoot, subdir, `${itemName}.${extension}`);
376+
if (existsSync(previewPath)) {
377+
return { content: readFileSync(previewPath), contentType };
378+
}
379+
}
380+
return null;
381+
},
382+
354383
// fallow-ignore-next-line complexity
355384
async installRegistryBlock(opts: {
356385
project: ResolvedProject;
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
<!--
2+
Camcorder HUD
3+
4+
Paste inside a positioned composition stage and edit the displayed date,
5+
time, mode, and counter start. The registered timeline advances the counter
6+
and REC blink from composition time, so play, scrub, and render agree.
7+
8+
CSS variables:
9+
- --hf-camcorder-color (default #fff)
10+
- --hf-camcorder-accent (default #f12c2c)
11+
- --hf-camcorder-inset (default 5vmin)
12+
- --hf-camcorder-font-size (default 3.7vmin)
13+
- --hf-camcorder-z-index (default 90)
14+
-->
15+
16+
<div id="ch-hud" data-hf-counter-start="0" aria-hidden="true">
17+
<div class="ch-corner ch-record"><span class="ch-record-dot"></span><span>REC</span></div>
18+
<div class="ch-corner ch-battery"></div>
19+
<div class="ch-corner ch-date">JUL 20 2026<br />12:42 PM</div>
20+
<div class="ch-corner ch-counter">
21+
<span class="ch-mode">SP · 16:9</span><span class="ch-counter-value">00:00:00</span>
22+
</div>
23+
</div>
24+
25+
<style>
26+
#ch-hud {
27+
--hf-camcorder-color: #fff;
28+
--hf-camcorder-accent: #f12c2c;
29+
--hf-camcorder-inset: 5vmin;
30+
--hf-camcorder-font-size: 3.7vmin;
31+
--hf-camcorder-z-index: 90;
32+
position: absolute;
33+
inset: 0;
34+
z-index: var(--hf-camcorder-z-index);
35+
color: var(--hf-camcorder-color);
36+
font-family: monospace;
37+
font-size: var(--hf-camcorder-font-size);
38+
font-weight: 700;
39+
letter-spacing: 0;
40+
line-height: 1.25;
41+
pointer-events: none;
42+
-webkit-text-stroke: 0.12vmin rgb(0 0 0 / 92%);
43+
text-shadow: 0 0.2vmin 0.6vmin rgb(0 0 0 / 90%);
44+
}
45+
46+
#ch-hud .ch-corner {
47+
position: absolute;
48+
}
49+
50+
#ch-hud .ch-record {
51+
top: var(--hf-camcorder-inset);
52+
left: var(--hf-camcorder-inset);
53+
display: flex;
54+
align-items: center;
55+
gap: 1.8vmin;
56+
}
57+
58+
#ch-hud .ch-record-dot {
59+
width: 2.2vmin;
60+
height: 2.2vmin;
61+
border-radius: 50%;
62+
background: var(--hf-camcorder-accent);
63+
box-shadow: 0 0 0.9vmin color-mix(in srgb, var(--hf-camcorder-accent) 55%, transparent);
64+
}
65+
66+
#ch-hud .ch-battery {
67+
top: var(--hf-camcorder-inset);
68+
right: var(--hf-camcorder-inset);
69+
width: 7vmin;
70+
height: 3.5vmin;
71+
border: 0.35vmin solid currentcolor;
72+
}
73+
74+
#ch-hud .ch-battery::before {
75+
position: absolute;
76+
top: 0.7vmin;
77+
left: 0.7vmin;
78+
width: 4.7vmin;
79+
height: 1.4vmin;
80+
background: currentcolor;
81+
content: "";
82+
}
83+
84+
#ch-hud .ch-battery::after {
85+
position: absolute;
86+
top: 0.7vmin;
87+
right: -1vmin;
88+
width: 0.7vmin;
89+
height: 1.4vmin;
90+
background: currentcolor;
91+
content: "";
92+
}
93+
94+
#ch-hud .ch-date {
95+
bottom: var(--hf-camcorder-inset);
96+
left: var(--hf-camcorder-inset);
97+
}
98+
99+
#ch-hud .ch-counter {
100+
right: var(--hf-camcorder-inset);
101+
bottom: var(--hf-camcorder-inset);
102+
text-align: right;
103+
}
104+
105+
#ch-hud .ch-mode {
106+
display: block;
107+
margin-bottom: 0.7vmin;
108+
font-size: 2.6vmin;
109+
}
110+
</style>
111+
112+
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
113+
<script>
114+
(function () {
115+
window.__timelines = window.__timelines || {};
116+
117+
var hud = document.getElementById("ch-hud");
118+
var counter = hud && hud.querySelector(".ch-counter-value");
119+
var recordDot = hud && hud.querySelector(".ch-record-dot");
120+
if (!hud || !counter || !recordDot) return;
121+
122+
var host = hud.closest("[data-composition-id]");
123+
var duration = Number(host && host.getAttribute("data-duration")) || 10;
124+
var counterStart = Number(hud.getAttribute("data-hf-counter-start")) || 0;
125+
var state = { seconds: 0 };
126+
127+
function formatCounter(value) {
128+
var seconds = Math.max(0, Math.floor(value));
129+
var hours = Math.floor(seconds / 3600);
130+
var minutes = Math.floor((seconds % 3600) / 60);
131+
var remainder = seconds % 60;
132+
return [hours, minutes, remainder]
133+
.map(function (part) {
134+
return String(part).padStart(2, "0");
135+
})
136+
.join(":");
137+
}
138+
139+
function updateHud() {
140+
counter.textContent = formatCounter(counterStart + state.seconds);
141+
recordDot.style.opacity = Math.floor(state.seconds / 0.55) % 2 === 0 ? "1" : "0.25";
142+
}
143+
144+
var tl = gsap.timeline({ paused: true });
145+
tl.to(state, { seconds: duration, duration: duration, ease: "none", onUpdate: updateHud }, 0);
146+
updateHud();
147+
tl.seek(0);
148+
window.__timelines["camcorder-hud"] = tl;
149+
})();
150+
</script>
181 KB
Loading
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=1920, height=1080" />
6+
<title>Camcorder HUD - Demo</title>
7+
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
8+
<style>
9+
* {
10+
box-sizing: border-box;
11+
}
12+
13+
html,
14+
body,
15+
#camcorder-hud-demo {
16+
width: 1920px;
17+
height: 1080px;
18+
margin: 0;
19+
overflow: hidden;
20+
background: #050505;
21+
}
22+
23+
#ch-demo-backdrop,
24+
#ch-demo-overlay {
25+
position: absolute;
26+
inset: 0;
27+
width: 100%;
28+
height: 100%;
29+
}
30+
31+
#ch-demo-backdrop {
32+
object-fit: cover;
33+
}
34+
</style>
35+
</head>
36+
<body>
37+
<div
38+
id="camcorder-hud-demo"
39+
data-composition-id="camcorder-hud-demo"
40+
data-start="0"
41+
data-duration="4"
42+
data-width="1920"
43+
data-height="1080"
44+
>
45+
<img
46+
id="ch-demo-backdrop"
47+
class="clip"
48+
src="demo-backdrop.jpg"
49+
data-start="0"
50+
data-duration="4"
51+
data-track-index="0"
52+
alt="Creator recording a video"
53+
/>
54+
<div
55+
id="ch-demo-overlay"
56+
data-composition-id="camcorder-hud"
57+
data-composition-src="camcorder-hud.html"
58+
data-start="0"
59+
data-duration="4"
60+
data-track-index="1"
61+
data-width="1920"
62+
data-height="1080"
63+
></div>
64+
</div>
65+
66+
<script>
67+
const tl = gsap.timeline({ paused: true });
68+
tl.to("#ch-demo-backdrop", { scale: 1.02, duration: 4, ease: "none" }, 0);
69+
window.__timelines = { "camcorder-hud-demo": tl };
70+
</script>
71+
</body>
72+
</html>

0 commit comments

Comments
 (0)