mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
feat(studio): full Blocks panel — browse, search, add, drag-and-drop registry items
Adds a Blocks tab to the Studio left sidebar with the full 78-item registry
catalog (58 blocks + 20 components). Users can browse by category, search by
title/description, preview CDN-hosted poster thumbnails with video-on-hover,
and install items on-demand with one click or drag-to-timeline.
Core changes:
- BlockCategory type + resolveBlockCategory() for 7 categories (Captions, VFX,
Transitions, Effects, Social, Data, Scenes)
- Registry API routes: GET /api/registry/blocks (catalog) + POST install
- StudioApiAdapter extended with listRegistryCatalog + installRegistryBlock
- Vite adapter reads from disk; CLI adapter fetches from GitHub (24h cache)
- BlockParam interface + params on 6 blocks for future parameter controls
Studio UI:
- 4th sidebar tab "Blocks" with responsive grid, category pills, search bar
- BlockCard: CDN poster thumbnail, video autoplay on hover, duration + WebGL badges
- On-demand install: blocks append as sub-compositions on timeline; components
overlay at start=0 spanning full duration with transparent background patching
- TIMELINE_BLOCK_MIME drag-and-drop to timeline
- BlockParamsPanel (Phase 3 scaffold) auto-opens for parameterized blocks
Registry manifests:
- All 58 blocks backfilled with preview: { video, poster } CDN URLs
- All 20 components normalized to object format + poster URLs added
- 6 blocks annotated with params (Liquid Glass/Background, Portal, Chart,
Logo Outro, Magnetic)
- flowchart-vertical preview generated and uploaded to CDN
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
import type { RegistryItem } from "@hyperframes/core/registry";
|
||||
import type { TimelineElement } from "../player";
|
||||
import {
|
||||
insertTimelineAssetIntoSource,
|
||||
resolveTimelineAssetInitialGeometry,
|
||||
} from "./timelineAssetDrop";
|
||||
import { collectHtmlIds } from "./studioHelpers";
|
||||
import {
|
||||
buildTrackZIndexMap,
|
||||
formatTimelineAttributeNumber,
|
||||
} from "../player/components/timelineEditing";
|
||||
import { saveProjectFilesWithHistory } from "./studioFileHistory";
|
||||
import type { EditHistoryKind } from "./editHistory";
|
||||
|
||||
interface AddBlockOptions {
|
||||
projectId: string;
|
||||
blockName: string;
|
||||
activeCompPath: string | null;
|
||||
placement?: { start: number; track: number };
|
||||
timelineElements: TimelineElement[];
|
||||
readProjectFile: (path: string) => Promise<string>;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
recordEdit: (entry: {
|
||||
label: string;
|
||||
kind: EditHistoryKind;
|
||||
coalesceKey?: string;
|
||||
files: Record<string, { before: string; after: string }>;
|
||||
}) => Promise<void>;
|
||||
refreshFileTree: () => Promise<void>;
|
||||
reloadPreview: () => void;
|
||||
showToast: (msg: string) => void;
|
||||
}
|
||||
|
||||
function buildUniqueCompositionId(baseName: string, existingIds: Iterable<string>): string {
|
||||
const idSet = new Set(existingIds);
|
||||
if (!idSet.has(baseName)) return baseName;
|
||||
let i = 2;
|
||||
while (idSet.has(`${baseName}_${i}`)) i++;
|
||||
return `${baseName}_${i}`;
|
||||
}
|
||||
|
||||
export async function addBlockToProject(
|
||||
opts: AddBlockOptions,
|
||||
): Promise<{ block: RegistryItem; compositionPath: string } | null> {
|
||||
const {
|
||||
projectId,
|
||||
blockName,
|
||||
activeCompPath,
|
||||
placement,
|
||||
timelineElements,
|
||||
readProjectFile,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
refreshFileTree,
|
||||
reloadPreview,
|
||||
showToast,
|
||||
} = opts;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}/registry/install`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ blockName }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: "Install failed" }));
|
||||
showToast((err as { error?: string }).error || "Failed to install block");
|
||||
return null;
|
||||
}
|
||||
|
||||
const { written, block } = (await res.json()) as {
|
||||
written: string[];
|
||||
block: RegistryItem;
|
||||
};
|
||||
|
||||
const compositionFile = written.find((f) => f.endsWith(".html")) ?? written[0];
|
||||
if (!compositionFile) {
|
||||
showToast("Installed but no composition file was written");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (block.type === "hyperframes:component") {
|
||||
const compContent = await readProjectFile(compositionFile);
|
||||
const transparentContent = compContent.replace(
|
||||
/background:\s*(?:#(?:0a0a0a|000000|000|0a0805)|rgba?\([^)]*\))\s*;/g,
|
||||
"background: transparent;",
|
||||
);
|
||||
if (transparentContent !== compContent) {
|
||||
await writeProjectFile(compositionFile, transparentContent);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const targetPath = activeCompPath || "index.html";
|
||||
const originalContent = await readProjectFile(targetPath);
|
||||
const existingIds = collectHtmlIds(originalContent);
|
||||
const compId = buildUniqueCompositionId(block.name, existingIds);
|
||||
|
||||
const resolvedTargetPath = targetPath || "index.html";
|
||||
const relevantElements = timelineElements.filter(
|
||||
(te) => (te.sourceFile || activeCompPath || "index.html") === resolvedTargetPath,
|
||||
);
|
||||
|
||||
const isBlock = block.type === "hyperframes:block";
|
||||
const hostDims = resolveTimelineAssetInitialGeometry(originalContent);
|
||||
|
||||
const start = placement
|
||||
? Number(formatTimelineAttributeNumber(placement.start))
|
||||
: isBlock
|
||||
? relevantElements.reduce(
|
||||
(max, te) => Math.max(max, (te.start ?? 0) + (te.duration ?? 0)),
|
||||
0,
|
||||
)
|
||||
: 0;
|
||||
const duration = isBlock
|
||||
? (block as { duration: number }).duration
|
||||
: relevantElements.reduce(
|
||||
(max, te) => Math.max(max, (te.start ?? 0) + (te.duration ?? 0)),
|
||||
10,
|
||||
);
|
||||
const track =
|
||||
placement?.track ??
|
||||
(isBlock
|
||||
? 0
|
||||
: relevantElements.length > 0
|
||||
? Math.max(...relevantElements.map((te) => te.track)) + 1
|
||||
: 1);
|
||||
|
||||
const trackZIndices = buildTrackZIndexMap([...relevantElements.map((te) => te.track), track]);
|
||||
const zIndex = trackZIndices.get(track) ?? 1;
|
||||
|
||||
const width = isBlock
|
||||
? (block as { dimensions: { width: number } }).dimensions.width
|
||||
: hostDims.width;
|
||||
const height = isBlock
|
||||
? (block as { dimensions: { height: number } }).dimensions.height
|
||||
: hostDims.height;
|
||||
|
||||
const subCompHtml =
|
||||
`<div data-composition-id="${compId}" ` +
|
||||
`data-composition-src="${compositionFile}" ` +
|
||||
`data-start="${formatTimelineAttributeNumber(start)}" ` +
|
||||
`data-duration="${formatTimelineAttributeNumber(duration)}" ` +
|
||||
`data-track-index="${track}" ` +
|
||||
`data-width="${width}" data-height="${height}" ` +
|
||||
`style="position: absolute; left: 0px; top: 0px; width: ${width}px; height: ${height}px; z-index: ${zIndex}">` +
|
||||
`</div>`;
|
||||
|
||||
const patchedContent = insertTimelineAssetIntoSource(originalContent, subCompHtml);
|
||||
|
||||
await saveProjectFilesWithHistory({
|
||||
projectId,
|
||||
label: `Add ${isBlock ? "block" : "component"}: ${block.title}`,
|
||||
kind: "timeline",
|
||||
files: { [targetPath]: patchedContent },
|
||||
readFile: async () => originalContent,
|
||||
writeFile: writeProjectFile,
|
||||
recordEdit,
|
||||
});
|
||||
}
|
||||
|
||||
await refreshFileTree();
|
||||
reloadPreview();
|
||||
|
||||
return { block, compositionPath: compositionFile };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to add block";
|
||||
showToast(message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user