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:
@@ -5,3 +5,5 @@ packages/studio/src/components/editor/manualEdits.test.ts
|
||||
packages/studio/src/player/hooks/useTimelinePlayer.test.ts
|
||||
packages/studio/src/components/editor/manualEditsDom.ts
|
||||
packages/studio/src/utils/sourcePatcher.ts
|
||||
packages/studio/src/App.tsx
|
||||
packages/studio/src/player/components/Timeline.tsx
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hyperframes/aws-lambda",
|
||||
"version": "0.6.22",
|
||||
"version": "0.6.23",
|
||||
"description": "AWS Lambda adapter for HyperFrames distributed rendering — handler, client-side SDK, and CDK construct.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hyperframes/cli",
|
||||
"version": "0.6.22",
|
||||
"version": "0.6.23",
|
||||
"description": "HyperFrames CLI — create, preview, and render HTML video compositions",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -376,6 +376,27 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
await page?.close().catch(() => {});
|
||||
}
|
||||
},
|
||||
|
||||
async listRegistryCatalog() {
|
||||
const { listRegistryItems, loadAllItems } = await import("../registry/resolver.js");
|
||||
const entries = await listRegistryItems();
|
||||
const blockAndComponentEntries = entries.filter(
|
||||
(e) => e.type === "hyperframes:block" || e.type === "hyperframes:component",
|
||||
);
|
||||
return loadAllItems(blockAndComponentEntries);
|
||||
},
|
||||
|
||||
async installRegistryBlock(opts) {
|
||||
const { resolveItem } = await import("../registry/resolver.js");
|
||||
const { installItem } = await import("../registry/installer.js");
|
||||
const item = await resolveItem(opts.blockName);
|
||||
const { written } = await installItem(item, { destDir: opts.project.dir });
|
||||
const relativePaths = written.map((abs) => {
|
||||
const rel = abs.startsWith(opts.project.dir) ? abs.slice(opts.project.dir.length + 1) : abs;
|
||||
return rel;
|
||||
});
|
||||
return { written: relativePaths, block: item };
|
||||
},
|
||||
};
|
||||
|
||||
// ── Build the Hono app ─────────────────────────────────────────────────
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hyperframes/core",
|
||||
"version": "0.6.22",
|
||||
"version": "0.6.23",
|
||||
"description": "",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -10,12 +10,17 @@ export type {
|
||||
ComponentItem,
|
||||
RegistryManifestEntry,
|
||||
RegistryManifest,
|
||||
BlockCategory,
|
||||
BlockCategoryMeta,
|
||||
BlockParam,
|
||||
} from "./types.js";
|
||||
|
||||
export {
|
||||
ITEM_TYPES,
|
||||
FILE_TYPES,
|
||||
ITEM_TYPE_DIRS,
|
||||
BLOCK_CATEGORIES,
|
||||
resolveBlockCategory,
|
||||
isExampleItem,
|
||||
isBlockItem,
|
||||
isComponentItem,
|
||||
|
||||
@@ -77,6 +77,17 @@ export interface ExampleItem extends RegistryItemBase {
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface BlockParam {
|
||||
key: string;
|
||||
label: string;
|
||||
type: "color" | "text" | "number" | "select";
|
||||
default: string;
|
||||
options?: { label: string; value: string }[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
}
|
||||
|
||||
/** Sub-composition block — installed by `hyperframes add <name>`. */
|
||||
export interface BlockItem extends RegistryItemBase {
|
||||
type: "hyperframes:block";
|
||||
@@ -84,6 +95,8 @@ export interface BlockItem extends RegistryItemBase {
|
||||
dimensions: RegistryItemDimensions;
|
||||
/** Duration in seconds (required for blocks). */
|
||||
duration: number;
|
||||
/** Customizable parameters with CSS variable mapping. */
|
||||
params?: BlockParam[];
|
||||
}
|
||||
|
||||
/** Effect / snippet — merged into an existing composition. */
|
||||
@@ -159,6 +172,45 @@ const _fileTypesExhaustive: _AssertFileTypesExhaustive = true;
|
||||
void _itemTypesExhaustive;
|
||||
void _fileTypesExhaustive;
|
||||
|
||||
// ── Block categories ───────────────────────────────────────────────────────
|
||||
|
||||
export type BlockCategory =
|
||||
| "vfx"
|
||||
| "transitions"
|
||||
| "social"
|
||||
| "data"
|
||||
| "scenes"
|
||||
| "captions"
|
||||
| "effects";
|
||||
|
||||
export interface BlockCategoryMeta {
|
||||
id: BlockCategory;
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export const BLOCK_CATEGORIES: BlockCategoryMeta[] = [
|
||||
{ id: "captions", label: "Captions", color: "cyan" },
|
||||
{ id: "vfx", label: "VFX", color: "purple" },
|
||||
{ id: "transitions", label: "Transitions", color: "blue" },
|
||||
{ id: "effects", label: "Effects", color: "rose" },
|
||||
{ id: "social", label: "Social", color: "pink" },
|
||||
{ id: "data", label: "Data", color: "green" },
|
||||
{ id: "scenes", label: "Scenes", color: "amber" },
|
||||
];
|
||||
|
||||
export function resolveBlockCategory(tags: string[] | undefined): BlockCategory {
|
||||
if (!tags || tags.length === 0) return "scenes";
|
||||
const set = new Set(tags);
|
||||
if (set.has("captions") || set.has("caption-style")) return "captions";
|
||||
if (set.has("transition")) return "transitions";
|
||||
if (set.has("social") || set.has("overlay")) return "social";
|
||||
if (set.has("data") || set.has("chart") || set.has("map")) return "data";
|
||||
if (set.has("html-in-canvas") || set.has("webgl") || set.has("shader")) return "vfx";
|
||||
if (set.has("effect") || set.has("grain") || set.has("vignette")) return "effects";
|
||||
return "scenes";
|
||||
}
|
||||
|
||||
// ── Type guards ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function isExampleItem(item: RegistryItem): item is ExampleItem {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { registerRenderRoutes } from "./routes/render.js";
|
||||
import { registerThumbnailRoutes } from "./routes/thumbnail.js";
|
||||
import { registerWaveformRoutes } from "./routes/waveform.js";
|
||||
import { registerFontRoutes } from "./routes/fonts.js";
|
||||
import { registerRegistryRoutes } from "./routes/registry.js";
|
||||
|
||||
/**
|
||||
* Create a Hono sub-app with all studio API routes.
|
||||
@@ -26,6 +27,7 @@ export function createStudioApi(adapter: StudioApiAdapter): Hono {
|
||||
registerThumbnailRoutes(api, adapter);
|
||||
registerWaveformRoutes(api, adapter);
|
||||
registerFontRoutes(api);
|
||||
registerRegistryRoutes(api, adapter);
|
||||
|
||||
return api;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Hono } from "hono";
|
||||
import type { StudioApiAdapter } from "../types.js";
|
||||
|
||||
export function registerRegistryRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
api.get("/registry/blocks", async (c) => {
|
||||
if (!adapter.listRegistryCatalog) {
|
||||
return c.json({ error: "Registry not available" }, 501);
|
||||
}
|
||||
const items = await adapter.listRegistryCatalog();
|
||||
return c.json(items);
|
||||
});
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
api.post("/projects/:id/registry/install", async (c) => {
|
||||
if (!adapter.installRegistryBlock) {
|
||||
return c.json({ error: "Registry install not available" }, 501);
|
||||
}
|
||||
const project = await adapter.resolveProject(c.req.param("id"));
|
||||
if (!project) return c.json({ error: "Project not found" }, 404);
|
||||
|
||||
const body = await c.req.json<{ blockName?: string }>().catch(() => null);
|
||||
if (!body?.blockName) {
|
||||
return c.json({ error: "blockName is required" }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await adapter.installRegistryBlock({ project, blockName: body.blockName });
|
||||
return c.json(result);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Install failed";
|
||||
return c.json({ error: message }, 500);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CanvasResolution } from "../core.types.js";
|
||||
import type { RegistryItem } from "../registry/types.js";
|
||||
|
||||
/** Resolved info about a single project. */
|
||||
export interface ResolvedProject {
|
||||
@@ -107,4 +108,13 @@ export interface StudioApiAdapter {
|
||||
|
||||
/** Optional: resolve session ID to project (multi-project mode). */
|
||||
resolveSession?: (sessionId: string) => Promise<{ projectId: string; title: string } | null>;
|
||||
|
||||
/** Optional: list all registry items (blocks + components) for the catalog. */
|
||||
listRegistryCatalog?(): Promise<RegistryItem[]>;
|
||||
|
||||
/** Optional: install a registry item into a project directory. */
|
||||
installRegistryBlock?(opts: {
|
||||
project: ResolvedProject;
|
||||
blockName: string;
|
||||
}): Promise<{ written: string[]; block: RegistryItem }>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hyperframes/engine",
|
||||
"version": "0.6.22",
|
||||
"version": "0.6.23",
|
||||
"description": "Seekable web page to video rendering engine (Puppeteer + FFmpeg)",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hyperframes/player",
|
||||
"version": "0.6.22",
|
||||
"version": "0.6.23",
|
||||
"description": "Embeddable web component for HyperFrames compositions",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hyperframes/producer",
|
||||
"version": "0.6.22",
|
||||
"version": "0.6.23",
|
||||
"description": "HTML-to-video rendering engine using Chrome's BeginFrame API",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hyperframes/shader-transitions",
|
||||
"version": "0.6.22",
|
||||
"version": "0.6.23",
|
||||
"description": "WebGL shader transitions for HyperFrames compositions",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hyperframes/studio",
|
||||
"version": "0.6.22",
|
||||
"version": "0.6.23",
|
||||
"description": "",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -10,6 +10,8 @@ import { usePanelLayout } from "./hooks/usePanelLayout";
|
||||
import { useFileManager } from "./hooks/useFileManager";
|
||||
import { useManifestPersistence } from "./hooks/useManifestPersistence";
|
||||
import { useTimelineEditing } from "./hooks/useTimelineEditing";
|
||||
import { addBlockToProject } from "./utils/blockInstaller";
|
||||
import type { BlockParam } from "@hyperframes/core/registry";
|
||||
import { useDomEditSession } from "./hooks/useDomEditSession";
|
||||
import { useAppHotkeys } from "./hooks/useAppHotkeys";
|
||||
import { useClipboard } from "./hooks/useClipboard";
|
||||
@@ -59,6 +61,12 @@ export function StudioApp() {
|
||||
const [compositionLoading, setCompositionLoading] = useState(true);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [, setPreviewDocumentVersion] = useState(0);
|
||||
const [activeBlockParams, setActiveBlockParams] = useState<{
|
||||
blockName: string;
|
||||
blockTitle: string;
|
||||
params: BlockParam[];
|
||||
compositionPath: string;
|
||||
} | null>(null);
|
||||
|
||||
const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const activeCompPathRef = useRef(activeCompPath);
|
||||
@@ -161,6 +169,79 @@ export function StudioApp() {
|
||||
uploadProjectFiles: fileManager.uploadProjectFiles,
|
||||
});
|
||||
|
||||
const handleAddBlock = useCallback(
|
||||
(blockName: string) => {
|
||||
if (!projectId) return;
|
||||
void (async () => {
|
||||
const result = await addBlockToProject({
|
||||
projectId,
|
||||
blockName,
|
||||
activeCompPath,
|
||||
timelineElements,
|
||||
readProjectFile: fileManager.readProjectFile,
|
||||
writeProjectFile: fileManager.writeProjectFile,
|
||||
recordEdit: editHistory.recordEdit,
|
||||
refreshFileTree: fileManager.refreshFileTree,
|
||||
reloadPreview,
|
||||
showToast,
|
||||
});
|
||||
const params = result?.block.type === "hyperframes:block" ? result.block.params : undefined;
|
||||
if (params?.length) {
|
||||
setActiveBlockParams({
|
||||
blockName: result!.block.name,
|
||||
blockTitle: result!.block.title,
|
||||
params,
|
||||
compositionPath: result!.compositionPath,
|
||||
});
|
||||
panelLayout.setRightCollapsed(false);
|
||||
panelLayout.setRightPanelTab("block-params");
|
||||
}
|
||||
})();
|
||||
},
|
||||
[
|
||||
projectId,
|
||||
activeCompPath,
|
||||
timelineElements,
|
||||
fileManager.readProjectFile,
|
||||
fileManager.writeProjectFile,
|
||||
fileManager.refreshFileTree,
|
||||
editHistory.recordEdit,
|
||||
reloadPreview,
|
||||
showToast,
|
||||
panelLayout,
|
||||
],
|
||||
);
|
||||
|
||||
const handleTimelineBlockDrop = useCallback(
|
||||
(blockName: string, placement: { start: number; track: number }) => {
|
||||
if (!projectId) return;
|
||||
void addBlockToProject({
|
||||
projectId,
|
||||
blockName,
|
||||
activeCompPath,
|
||||
placement,
|
||||
timelineElements,
|
||||
readProjectFile: fileManager.readProjectFile,
|
||||
writeProjectFile: fileManager.writeProjectFile,
|
||||
recordEdit: editHistory.recordEdit,
|
||||
refreshFileTree: fileManager.refreshFileTree,
|
||||
reloadPreview,
|
||||
showToast,
|
||||
});
|
||||
},
|
||||
[
|
||||
projectId,
|
||||
activeCompPath,
|
||||
timelineElements,
|
||||
fileManager.readProjectFile,
|
||||
fileManager.writeProjectFile,
|
||||
fileManager.refreshFileTree,
|
||||
editHistory.recordEdit,
|
||||
reloadPreview,
|
||||
showToast,
|
||||
],
|
||||
);
|
||||
|
||||
const clearDomSelectionRef = useRef<() => void>(() => {});
|
||||
const domEditSelectionBridgeRef = useRef<DomEditSelection | null>(null);
|
||||
const handleDomEditElementDeleteRef = useRef<(s: DomEditSelection) => Promise<void>>(
|
||||
@@ -427,6 +508,7 @@ export function StudioApp() {
|
||||
<StudioLeftSidebar
|
||||
leftSidebarRef={leftSidebarRef}
|
||||
onSelectComposition={handleSelectComposition}
|
||||
onAddBlock={handleAddBlock}
|
||||
onLint={handleLint}
|
||||
linting={linting}
|
||||
/>
|
||||
@@ -435,6 +517,7 @@ export function StudioApp() {
|
||||
renderClipContent={renderClipContent}
|
||||
handleTimelineElementDelete={timelineEditing.handleTimelineElementDelete}
|
||||
handleTimelineAssetDrop={timelineEditing.handleTimelineAssetDrop}
|
||||
handleTimelineBlockDrop={handleTimelineBlockDrop}
|
||||
handleTimelineFileDrop={timelineEditing.handleTimelineFileDrop}
|
||||
handleTimelineElementMove={timelineEditing.handleTimelineElementMove}
|
||||
handleTimelineElementResize={timelineEditing.handleTimelineElementResize}
|
||||
@@ -449,6 +532,11 @@ export function StudioApp() {
|
||||
selectedStudioMotion={selectedStudioMotion}
|
||||
designPanelActive={designPanelActive}
|
||||
motionPanelActive={motionPanelActive}
|
||||
activeBlockParams={activeBlockParams}
|
||||
onCloseBlockParams={() => {
|
||||
setActiveBlockParams(null);
|
||||
panelLayout.setRightPanelTab("design");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,13 +11,16 @@ import { getPersistedRenderSettings } from "./renders/renderSettings";
|
||||
export interface StudioLeftSidebarProps {
|
||||
leftSidebarRef: RefObject<LeftSidebarHandle | null>;
|
||||
onSelectComposition: (comp: string) => void;
|
||||
onAddBlock: (blockName: string) => void;
|
||||
onLint: () => void;
|
||||
linting: boolean;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function StudioLeftSidebar({
|
||||
leftSidebarRef,
|
||||
onSelectComposition,
|
||||
onAddBlock,
|
||||
onLint,
|
||||
linting,
|
||||
}: StudioLeftSidebarProps) {
|
||||
@@ -124,6 +127,7 @@ export function StudioLeftSidebar({
|
||||
onLint={onLint}
|
||||
linting={linting}
|
||||
onToggleCollapse={toggleLeftSidebar}
|
||||
onAddBlock={onAddBlock}
|
||||
/>
|
||||
<div
|
||||
className="group w-2 flex-shrink-0 cursor-col-resize flex items-center justify-center"
|
||||
|
||||
@@ -25,6 +25,10 @@ export interface StudioPreviewAreaProps {
|
||||
assetPath: string,
|
||||
placement: Pick<TimelineElement, "start" | "track">,
|
||||
) => Promise<void> | void;
|
||||
handleTimelineBlockDrop?: (
|
||||
blockName: string,
|
||||
placement: Pick<TimelineElement, "start" | "track">,
|
||||
) => Promise<void> | void;
|
||||
handleTimelineFileDrop: (
|
||||
files: File[],
|
||||
placement?: Pick<TimelineElement, "start" | "track">,
|
||||
@@ -48,6 +52,7 @@ export function StudioPreviewArea({
|
||||
renderClipContent,
|
||||
handleTimelineElementDelete,
|
||||
handleTimelineAssetDrop,
|
||||
handleTimelineBlockDrop,
|
||||
handleTimelineFileDrop,
|
||||
handleTimelineElementMove,
|
||||
handleTimelineElementResize,
|
||||
@@ -98,6 +103,7 @@ export function StudioPreviewArea({
|
||||
renderClipContent={renderClipContent}
|
||||
onDeleteElement={handleTimelineElementDelete}
|
||||
onAssetDrop={handleTimelineAssetDrop}
|
||||
onBlockDrop={handleTimelineBlockDrop}
|
||||
onFileDrop={handleTimelineFileDrop}
|
||||
onMoveElement={handleTimelineElementMove}
|
||||
onResizeElement={handleTimelineElementResize}
|
||||
|
||||
@@ -2,9 +2,11 @@ import { PropertyPanel } from "./editor/PropertyPanel";
|
||||
import { MotionPanel } from "./editor/MotionPanel";
|
||||
import { LayersPanel } from "./editor/LayersPanel";
|
||||
import { CaptionPropertyPanel } from "../captions/components/CaptionPropertyPanel";
|
||||
import { BlockParamsPanel } from "./editor/BlockParamsPanel";
|
||||
import { RenderQueue } from "./renders/RenderQueue";
|
||||
import type { RenderJob } from "./renders/useRenderQueue";
|
||||
import type { StudioGsapMotion } from "./editor/studioMotion";
|
||||
import type { BlockParam } from "@hyperframes/core/registry";
|
||||
import {
|
||||
STUDIO_INSPECTOR_PANELS_ENABLED,
|
||||
STUDIO_MOTION_PANEL_ENABLED,
|
||||
@@ -22,12 +24,21 @@ export interface StudioRightPanelProps {
|
||||
selectedStudioMotion: StudioMotionData | null;
|
||||
designPanelActive: boolean;
|
||||
motionPanelActive: boolean;
|
||||
activeBlockParams?: {
|
||||
blockName: string;
|
||||
blockTitle: string;
|
||||
params: BlockParam[];
|
||||
compositionPath: string;
|
||||
} | null;
|
||||
onCloseBlockParams?: () => void;
|
||||
}
|
||||
|
||||
export function StudioRightPanel({
|
||||
selectedStudioMotion,
|
||||
designPanelActive,
|
||||
motionPanelActive,
|
||||
activeBlockParams,
|
||||
onCloseBlockParams,
|
||||
}: StudioRightPanelProps) {
|
||||
const {
|
||||
rightWidth,
|
||||
@@ -145,7 +156,15 @@ export function StudioRightPanel({
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
{rightPanelTab === "layers" ? (
|
||||
{rightPanelTab === "block-params" && activeBlockParams ? (
|
||||
<BlockParamsPanel
|
||||
blockName={activeBlockParams.blockName}
|
||||
blockTitle={activeBlockParams.blockTitle}
|
||||
params={activeBlockParams.params}
|
||||
compositionPath={activeBlockParams.compositionPath}
|
||||
onClose={onCloseBlockParams ?? (() => {})}
|
||||
/>
|
||||
) : rightPanelTab === "layers" ? (
|
||||
<LayersPanel />
|
||||
) : designPanelActive ? (
|
||||
<PropertyPanel
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { memo, useState, useCallback } from "react";
|
||||
import type { BlockParam } from "@hyperframes/core/registry";
|
||||
|
||||
interface BlockParamsPanelProps {
|
||||
blockName: string;
|
||||
blockTitle: string;
|
||||
params: BlockParam[];
|
||||
compositionPath: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const BlockParamsPanel = memo(function BlockParamsPanel({
|
||||
blockTitle,
|
||||
params,
|
||||
compositionPath,
|
||||
onClose,
|
||||
}: BlockParamsPanelProps) {
|
||||
const [values, setValues] = useState<Record<string, string>>(() => {
|
||||
const initial: Record<string, string> = {};
|
||||
for (const p of params) {
|
||||
initial[p.key] = p.default;
|
||||
}
|
||||
return initial;
|
||||
});
|
||||
|
||||
const handleChange = useCallback(
|
||||
(key: string, value: string) => {
|
||||
setValues((prev) => ({ ...prev, [key]: value }));
|
||||
console.log(`[BlockParams] ${compositionPath} ${key}: ${value}`);
|
||||
},
|
||||
[compositionPath],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-neutral-800">
|
||||
<div className="text-[11px] font-semibold text-neutral-200 truncate">{blockTitle}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-neutral-500 hover:text-neutral-300 transition-colors"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-3">
|
||||
<div className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider">
|
||||
Parameters
|
||||
</div>
|
||||
{params.map((param) => (
|
||||
<ParamControl
|
||||
key={param.key}
|
||||
param={param}
|
||||
value={values[param.key] ?? param.default}
|
||||
onChange={(v) => handleChange(param.key, v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function ParamControl({
|
||||
param,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
param: BlockParam;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-medium text-neutral-400">{param.label}</label>
|
||||
|
||||
{param.type === "color" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-7 h-7 rounded border border-neutral-700 bg-transparent cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="flex-1 bg-neutral-900 border border-neutral-800 rounded px-2 py-1 text-[10px] text-neutral-200 font-mono focus:outline-none focus:border-neutral-700"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{param.type === "number" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min={param.min ?? 0}
|
||||
max={param.max ?? 100}
|
||||
step={param.step ?? 1}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-[10px] text-neutral-400 w-8 text-right tabular-nums">{value}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{param.type === "text" && (
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full bg-neutral-900 border border-neutral-800 rounded px-2 py-1 text-[10px] text-neutral-200 focus:outline-none focus:border-neutral-700"
|
||||
/>
|
||||
)}
|
||||
|
||||
{param.type === "select" && param.options && (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full bg-neutral-900 border border-neutral-800 rounded px-2 py-1 text-[10px] text-neutral-200 focus:outline-none focus:border-neutral-700"
|
||||
>
|
||||
{param.options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -60,6 +60,12 @@ export const STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED =
|
||||
true,
|
||||
);
|
||||
|
||||
export const STUDIO_BLOCKS_PANEL_ENABLED = resolveStudioBooleanEnvFlag(
|
||||
env,
|
||||
["VITE_STUDIO_ENABLE_BLOCKS_PANEL", "VITE_STUDIO_BLOCKS_PANEL_ENABLED"],
|
||||
false,
|
||||
);
|
||||
|
||||
export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED;
|
||||
|
||||
export const STUDIO_MANUAL_EDITING_ENABLED = STUDIO_PREVIEW_MANUAL_EDITING_ENABLED;
|
||||
|
||||
@@ -42,6 +42,10 @@ interface NLELayoutProps {
|
||||
assetPath: string,
|
||||
placement: Pick<TimelineElement, "start" | "track">,
|
||||
) => Promise<void> | void;
|
||||
onBlockDrop?: (
|
||||
blockName: string,
|
||||
placement: Pick<TimelineElement, "start" | "track">,
|
||||
) => Promise<void> | void;
|
||||
/** Persist timeline move actions back into source HTML */
|
||||
onMoveElement?: (
|
||||
element: TimelineElement,
|
||||
@@ -85,6 +89,7 @@ export const NLELayout = memo(function NLELayout({
|
||||
onFileDrop,
|
||||
onDeleteElement,
|
||||
onAssetDrop,
|
||||
onBlockDrop,
|
||||
onMoveElement,
|
||||
onResizeElement,
|
||||
onBlockedEditAttempt,
|
||||
@@ -371,6 +376,7 @@ export const NLELayout = memo(function NLELayout({
|
||||
onFileDrop={onFileDrop}
|
||||
onDeleteElement={onDeleteElement}
|
||||
onAssetDrop={onAssetDrop}
|
||||
onBlockDrop={onBlockDrop}
|
||||
onMoveElement={onMoveElement}
|
||||
onResizeElement={onResizeElement}
|
||||
onBlockedEditAttempt={onBlockedEditAttempt}
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
import { memo, useState, useCallback, useRef, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useBlockCatalog } from "../../hooks/useBlockCatalog";
|
||||
import {
|
||||
BLOCK_CATEGORIES,
|
||||
getCategoryColors,
|
||||
type BlockCategory,
|
||||
} from "../../utils/blockCategories";
|
||||
import { TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
|
||||
|
||||
interface BlocksTabProps {
|
||||
onAddBlock: (blockName: string) => void;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export const BlocksTab = memo(function BlocksTab({ onAddBlock }: BlocksTabProps) {
|
||||
const { loading, error, search, setSearch, category, setCategory, filteredBlocks } =
|
||||
useBlockCatalog();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center text-neutral-600 text-xs">
|
||||
Loading blocks…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center text-red-400 text-xs px-4 text-center">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{/* Search */}
|
||||
<div className="px-3 pt-2 pb-1 flex-shrink-0">
|
||||
<div className="relative">
|
||||
<svg
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 text-neutral-500"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search blocks…"
|
||||
className="w-full bg-neutral-900 border border-neutral-800 rounded-md pl-7 pr-2 py-1.5 text-[11px] text-neutral-200 placeholder:text-neutral-600 focus:outline-none focus:border-neutral-700 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category pills */}
|
||||
<div className="px-3 pt-1 pb-2 flex-shrink-0 overflow-x-auto">
|
||||
<div className="flex gap-1">
|
||||
<CategoryPill label="All" active={category === null} onClick={() => setCategory(null)} />
|
||||
{BLOCK_CATEGORIES.map((cat) => (
|
||||
<CategoryPill
|
||||
key={cat.id}
|
||||
label={cat.label}
|
||||
category={cat.id}
|
||||
active={category === cat.id}
|
||||
onClick={() => setCategory(category === cat.id ? null : cat.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Block grid */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0 px-2 pb-2">
|
||||
{category === "vfx" && (
|
||||
<div className="mb-2 px-2 py-1.5 rounded-md bg-purple-500/10 border border-purple-500/20 text-[9px] text-purple-300 leading-relaxed">
|
||||
VFX blocks use WebGL via HTML-in-Canvas. Enable{" "}
|
||||
<span className="font-mono text-purple-200">chrome://flags/#html-in-canvas</span> for
|
||||
preview.
|
||||
</div>
|
||||
)}
|
||||
{filteredBlocks.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-32 text-neutral-600 text-xs">
|
||||
No blocks match your search
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="grid gap-1.5"
|
||||
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(120px, 1fr))" }}
|
||||
>
|
||||
{filteredBlocks.map((block) => {
|
||||
const dur = "duration" in block ? (block.duration as number) : undefined;
|
||||
const dims =
|
||||
"dimensions" in block
|
||||
? (block.dimensions as { width: number; height: number })
|
||||
: undefined;
|
||||
return (
|
||||
<BlockCard
|
||||
key={block.name}
|
||||
name={block.name}
|
||||
title={block.title}
|
||||
duration={dur}
|
||||
category={block.category}
|
||||
tags={block.tags}
|
||||
posterUrl={block.preview?.poster}
|
||||
videoUrl={block.preview?.video}
|
||||
dimensions={dims}
|
||||
onAdd={() => onAddBlock(block.name)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function CategoryPill({
|
||||
label,
|
||||
category,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
category?: BlockCategory;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const colors = category ? getCategoryColors(category) : null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`flex-shrink-0 px-2 py-1 rounded-full text-[10px] font-medium transition-colors ${
|
||||
active
|
||||
? colors
|
||||
? `${colors.bg} ${colors.text}`
|
||||
: "bg-neutral-700 text-neutral-200"
|
||||
: "bg-neutral-900 text-neutral-500 hover:text-neutral-300"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function BlockCard({
|
||||
name,
|
||||
title,
|
||||
duration,
|
||||
category,
|
||||
tags,
|
||||
posterUrl,
|
||||
videoUrl,
|
||||
dimensions,
|
||||
onAdd,
|
||||
}: {
|
||||
name: string;
|
||||
title: string;
|
||||
duration?: number;
|
||||
category: BlockCategory;
|
||||
tags?: string[];
|
||||
posterUrl?: string;
|
||||
videoUrl?: string;
|
||||
dimensions?: { width: number; height: number };
|
||||
onAdd: () => void;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const leaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const colors = getCategoryColors(category);
|
||||
const needsWebGL = tags?.includes("html-in-canvas") || tags?.includes("webgl");
|
||||
|
||||
const cancelLeave = useCallback(() => {
|
||||
if (leaveTimer.current) {
|
||||
clearTimeout(leaveTimer.current);
|
||||
leaveTimer.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleEnter = useCallback(() => {
|
||||
cancelLeave();
|
||||
hoverTimer.current = setTimeout(() => setHovered(true), 500);
|
||||
}, [cancelLeave]);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
if (hoverTimer.current) {
|
||||
clearTimeout(hoverTimer.current);
|
||||
hoverTimer.current = null;
|
||||
}
|
||||
cancelLeave();
|
||||
setHovered(false);
|
||||
}, [cancelLeave]);
|
||||
|
||||
const handleLeave = useCallback(() => {
|
||||
if (hoverTimer.current) {
|
||||
clearTimeout(hoverTimer.current);
|
||||
hoverTimer.current = null;
|
||||
}
|
||||
leaveTimer.current = setTimeout(() => setHovered(false), 150);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hovered) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") dismiss();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [hovered, dismiss]);
|
||||
|
||||
const handleAdd = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (adding) return;
|
||||
setAdding(true);
|
||||
onAdd();
|
||||
setTimeout(() => setAdding(false), 1000);
|
||||
},
|
||||
[onAdd, adding],
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
e.dataTransfer.setData(TIMELINE_BLOCK_MIME, JSON.stringify({ name, duration, dimensions }));
|
||||
},
|
||||
[name, duration, dimensions],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group/card rounded-md overflow-hidden cursor-pointer transition-colors bg-neutral-900 hover:bg-neutral-800"
|
||||
onPointerEnter={handleEnter}
|
||||
onPointerLeave={handleLeave}
|
||||
draggable
|
||||
onDragStart={handleDragStart}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div className="aspect-video w-full overflow-hidden relative">
|
||||
{hovered && videoUrl ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoUrl}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : posterUrl ? (
|
||||
<img src={posterUrl} alt={title} loading="lazy" className="w-full h-full object-cover" />
|
||||
) : videoUrl ? (
|
||||
<video
|
||||
src={videoUrl}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className={`w-full h-full flex items-center justify-center ${colors.bg}`}>
|
||||
<span className={`text-[9px] font-medium ${colors.text}`}>
|
||||
{category.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add button overlay */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAdd}
|
||||
className="absolute inset-0 flex items-center justify-center bg-black/60 opacity-0 group-hover/card:opacity-100 transition-opacity"
|
||||
>
|
||||
<span className="text-[10px] font-semibold text-white">{adding ? "Added" : "Add"}</span>
|
||||
</button>
|
||||
|
||||
{/* Badges */}
|
||||
<div className="absolute top-1 right-1 flex items-center gap-0.5 pointer-events-none">
|
||||
{needsWebGL && (
|
||||
<span className="px-1 py-px rounded text-[7px] font-semibold text-purple-300 bg-purple-900/70">
|
||||
WebGL
|
||||
</span>
|
||||
)}
|
||||
{duration != null && (
|
||||
<span className="px-1 py-px rounded text-[8px] font-medium text-white/80 bg-black/50">
|
||||
{duration}s
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="px-1.5 py-1.5">
|
||||
<div className="text-[10px] font-medium text-neutral-200 truncate leading-tight">
|
||||
{title}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${colors.dot}`} />
|
||||
<span className={`text-[8px] ${colors.text}`}>
|
||||
{BLOCK_CATEGORIES.find((c) => c.id === category)?.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fullscreen hover preview */}
|
||||
{hovered &&
|
||||
(videoUrl || posterUrl) &&
|
||||
createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center cursor-pointer"
|
||||
onClick={dismiss}
|
||||
onPointerEnter={cancelLeave}
|
||||
onPointerLeave={handleLeave}
|
||||
>
|
||||
<div className="bg-black/80 absolute inset-0" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
className="absolute top-4 right-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-neutral-800/80 text-neutral-400 hover:text-white hover:bg-neutral-700 transition-colors"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
className="relative rounded-xl overflow-hidden shadow-2xl border border-neutral-600/30 cursor-default"
|
||||
style={{ width: "80vw", maxWidth: 1200, maxHeight: "80vh" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="aspect-video bg-neutral-950">
|
||||
{videoUrl ? (
|
||||
<video
|
||||
src={videoUrl}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<img src={posterUrl} alt={title} className="w-full h-full object-contain" />
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-neutral-900/95 px-4 py-3">
|
||||
<div className="text-[14px] font-semibold text-neutral-100">{title}</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={`w-2 h-2 rounded-full ${colors.dot}`} />
|
||||
<span className={`text-[11px] ${colors.text}`}>
|
||||
{BLOCK_CATEGORIES.find((c) => c.id === category)?.label}
|
||||
</span>
|
||||
{duration != null && (
|
||||
<span className="text-[11px] text-neutral-500">{duration}s</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,9 +8,11 @@ import {
|
||||
} from "react";
|
||||
import { CompositionsTab } from "./CompositionsTab";
|
||||
import { AssetsTab } from "./AssetsTab";
|
||||
import { BlocksTab } from "./BlocksTab";
|
||||
import { FileTree } from "../editor/FileTree";
|
||||
import { STUDIO_BLOCKS_PANEL_ENABLED } from "../editor/manualEditingAvailability";
|
||||
|
||||
export type SidebarTab = "compositions" | "assets" | "code";
|
||||
export type SidebarTab = "compositions" | "assets" | "code" | "blocks";
|
||||
|
||||
export interface LeftSidebarHandle {
|
||||
selectTab: (tab: SidebarTab) => void;
|
||||
@@ -22,6 +24,7 @@ function getPersistedTab(): SidebarTab {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === "assets") return "assets";
|
||||
if (stored === "code") return "code";
|
||||
if (stored === "blocks") return "blocks";
|
||||
return "compositions";
|
||||
}
|
||||
|
||||
@@ -48,6 +51,7 @@ interface LeftSidebarProps {
|
||||
onLint?: () => void;
|
||||
linting?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
onAddBlock?: (blockName: string) => void;
|
||||
takeoverContent?: ReactNode;
|
||||
}
|
||||
|
||||
@@ -76,6 +80,7 @@ export const LeftSidebar = memo(
|
||||
onLint,
|
||||
linting,
|
||||
onToggleCollapse,
|
||||
onAddBlock,
|
||||
takeoverContent,
|
||||
},
|
||||
ref,
|
||||
@@ -103,7 +108,11 @@ export const LeftSidebar = memo(
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="grid min-w-0 flex-1 gap-0.5 rounded-[18px] bg-neutral-900 p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
|
||||
style={{ gridTemplateColumns: "1fr 1fr 1fr" }}
|
||||
style={{
|
||||
gridTemplateColumns: STUDIO_BLOCKS_PANEL_ENABLED
|
||||
? "1fr 1fr 1fr 1fr"
|
||||
: "1fr 1fr 1fr",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -138,6 +147,19 @@ export const LeftSidebar = memo(
|
||||
>
|
||||
Assets
|
||||
</button>
|
||||
{STUDIO_BLOCKS_PANEL_ENABLED && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectTab("blocks")}
|
||||
className={`rounded-[14px] px-1.5 py-2 text-[10px] font-semibold truncate transition-all ${
|
||||
tab === "blocks"
|
||||
? "bg-neutral-800 text-white"
|
||||
: "text-neutral-500 hover:text-neutral-200"
|
||||
}`}
|
||||
>
|
||||
Blocks
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{onToggleCollapse && (
|
||||
<button
|
||||
@@ -214,6 +236,10 @@ export const LeftSidebar = memo(
|
||||
</div>
|
||||
)}
|
||||
|
||||
{STUDIO_BLOCKS_PANEL_ENABLED && tab === "blocks" && onAddBlock && (
|
||||
<BlocksTab onAddBlock={onAddBlock} />
|
||||
)}
|
||||
|
||||
{/* Lint button pinned at the bottom */}
|
||||
{onLint && (
|
||||
<div className="border-t border-neutral-800 p-2 flex-shrink-0">
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import type { RegistryItem } from "@hyperframes/core/registry";
|
||||
import { type BlockCategory, resolveBlockCategory } from "../utils/blockCategories";
|
||||
|
||||
export type CatalogItem = RegistryItem & {
|
||||
category: BlockCategory;
|
||||
};
|
||||
|
||||
export function useBlockCatalog() {
|
||||
const [blocks, setBlocks] = useState<CatalogItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [category, setCategory] = useState<BlockCategory | null>(null);
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
useEffect(() => {
|
||||
const CATEGORY_ORDER: Record<BlockCategory, number> = {
|
||||
captions: 0,
|
||||
vfx: 1,
|
||||
transitions: 2,
|
||||
effects: 3,
|
||||
social: 4,
|
||||
data: 5,
|
||||
scenes: 6,
|
||||
};
|
||||
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/registry/blocks");
|
||||
if (!res.ok) throw new Error("Failed to load catalog");
|
||||
const data = (await res.json()) as RegistryItem[];
|
||||
if (cancelled) return;
|
||||
const items = data
|
||||
.map((b) => ({ ...b, category: resolveBlockCategory(b.tags) }))
|
||||
.sort((a, b) => (CATEGORY_ORDER[a.category] ?? 9) - (CATEGORY_ORDER[b.category] ?? 9));
|
||||
setBlocks(items);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setError(err instanceof Error ? err.message : "Failed to load catalog");
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const filteredBlocks = useMemo(() => {
|
||||
let result = blocks;
|
||||
if (category) {
|
||||
result = result.filter((b) => b.category === category);
|
||||
}
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
result = result.filter(
|
||||
(b) => b.title.toLowerCase().includes(q) || b.description.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}, [blocks, category, search]);
|
||||
|
||||
return {
|
||||
blocks,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
setSearch,
|
||||
category,
|
||||
setCategory,
|
||||
filteredBlocks,
|
||||
};
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
|
||||
import { useTimelinePlayhead } from "./useTimelinePlayhead";
|
||||
import { type TrackVisualStyle, getTrackStyle } from "./timelineIcons";
|
||||
import { getTimelinePixelsPerSecond } from "./timelineZoom";
|
||||
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
|
||||
import { TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
|
||||
import { TimelineEmptyState } from "./TimelineEmptyState";
|
||||
import { TimelineCanvas } from "./TimelineCanvas";
|
||||
import { useTimelineClipDrag } from "./useTimelineClipDrag";
|
||||
@@ -52,6 +52,10 @@ interface TimelineProps {
|
||||
assetPath: string,
|
||||
placement: { start: number; track: number },
|
||||
) => Promise<void> | void;
|
||||
onBlockDrop?: (
|
||||
blockName: string,
|
||||
placement: { start: number; track: number },
|
||||
) => Promise<void> | void;
|
||||
onDeleteElement?: (element: TimelineElement) => Promise<void> | void;
|
||||
onMoveElement?: (
|
||||
element: TimelineElement,
|
||||
@@ -73,6 +77,7 @@ export const Timeline = memo(function Timeline({
|
||||
renderClipOverlay,
|
||||
onFileDrop,
|
||||
onAssetDrop,
|
||||
onBlockDrop,
|
||||
onDeleteElement: _onDeleteElement,
|
||||
onMoveElement,
|
||||
onResizeElement,
|
||||
@@ -335,10 +340,12 @@ export const Timeline = memo(function Timeline({
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const handleAssetDragOver = useCallback((e: React.DragEvent) => {
|
||||
const hasFiles = e.dataTransfer.files.length > 0;
|
||||
const hasAsset = Array.from(e.dataTransfer.types).includes(TIMELINE_ASSET_MIME);
|
||||
if (!hasFiles && !hasAsset) return;
|
||||
const types = Array.from(e.dataTransfer.types);
|
||||
const hasAsset = types.includes(TIMELINE_ASSET_MIME);
|
||||
const hasBlock = types.includes(TIMELINE_BLOCK_MIME);
|
||||
if (!hasFiles && !hasAsset && !hasBlock) return;
|
||||
e.preventDefault();
|
||||
if (hasAsset) e.dataTransfer.dropEffect = "copy";
|
||||
if (hasAsset || hasBlock) e.dataTransfer.dropEffect = "copy";
|
||||
setIsDragOver(true);
|
||||
}, []);
|
||||
|
||||
@@ -366,16 +373,34 @@ export const Timeline = memo(function Timeline({
|
||||
return;
|
||||
}
|
||||
const assetPayload = e.dataTransfer.getData(TIMELINE_ASSET_MIME);
|
||||
if (!assetPayload || !onAssetDrop || !scroll || !rect) return;
|
||||
try {
|
||||
const parsed = JSON.parse(assetPayload) as { path?: string };
|
||||
if (parsed.path)
|
||||
void onAssetDrop(parsed.path, resolveTimelineAssetDrop(dropInput, e.clientX, e.clientY));
|
||||
} catch {
|
||||
/* ignore malformed drag payloads */
|
||||
if (assetPayload && onAssetDrop && scroll && rect) {
|
||||
try {
|
||||
const parsed = JSON.parse(assetPayload) as { path?: string };
|
||||
if (parsed.path)
|
||||
void onAssetDrop(
|
||||
parsed.path,
|
||||
resolveTimelineAssetDrop(dropInput, e.clientX, e.clientY),
|
||||
);
|
||||
} catch {
|
||||
/* ignore malformed drag payloads */
|
||||
}
|
||||
return;
|
||||
}
|
||||
const blockPayload = e.dataTransfer.getData(TIMELINE_BLOCK_MIME);
|
||||
if (blockPayload && onBlockDrop && scroll && rect) {
|
||||
try {
|
||||
const parsed = JSON.parse(blockPayload) as { name?: string };
|
||||
if (parsed.name)
|
||||
void onBlockDrop(
|
||||
parsed.name,
|
||||
resolveTimelineAssetDrop(dropInput, e.clientX, e.clientY),
|
||||
);
|
||||
} catch {
|
||||
/* ignore malformed drag payloads */
|
||||
}
|
||||
}
|
||||
},
|
||||
[onAssetDrop, onFileDrop],
|
||||
[onAssetDrop, onBlockDrop, onFileDrop],
|
||||
);
|
||||
|
||||
if (!timelineReady || elements.length === 0) {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
type BlockCategory,
|
||||
type BlockCategoryMeta,
|
||||
BLOCK_CATEGORIES,
|
||||
resolveBlockCategory,
|
||||
} from "@hyperframes/core/registry";
|
||||
|
||||
export type { BlockCategory, BlockCategoryMeta };
|
||||
export { BLOCK_CATEGORIES, resolveBlockCategory };
|
||||
|
||||
const COLOR_MAP: Record<BlockCategory, { bg: string; text: string; dot: string }> = {
|
||||
transitions: { bg: "bg-blue-500/15", text: "text-blue-400", dot: "bg-blue-400" },
|
||||
vfx: { bg: "bg-purple-500/15", text: "text-purple-400", dot: "bg-purple-400" },
|
||||
social: { bg: "bg-pink-500/15", text: "text-pink-400", dot: "bg-pink-400" },
|
||||
data: { bg: "bg-green-500/15", text: "text-green-400", dot: "bg-green-400" },
|
||||
scenes: { bg: "bg-amber-500/15", text: "text-amber-400", dot: "bg-amber-400" },
|
||||
captions: { bg: "bg-cyan-500/15", text: "text-cyan-400", dot: "bg-cyan-400" },
|
||||
effects: { bg: "bg-rose-500/15", text: "text-rose-400", dot: "bg-rose-400" },
|
||||
};
|
||||
|
||||
export function getCategoryColors(category: BlockCategory) {
|
||||
return COLOR_MAP[category];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export interface AppToast {
|
||||
tone: "error" | "info";
|
||||
}
|
||||
|
||||
export type RightPanelTab = "layers" | "design" | "motion" | "renders";
|
||||
export type RightPanelTab = "layers" | "design" | "motion" | "renders" | "block-params";
|
||||
|
||||
export interface AgentModalAnchorPoint {
|
||||
x: number;
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface StudioUiPreferences {
|
||||
playbackRate?: number;
|
||||
audioMuted?: boolean;
|
||||
previewZoom?: StoredPreviewZoomState;
|
||||
recentBlocks?: string[];
|
||||
}
|
||||
|
||||
const STUDIO_UI_PREFERENCES_KEY = "hf-studio-ui-preferences";
|
||||
@@ -61,6 +62,11 @@ function readStorage(storage: Storage | null): StudioUiPreferences {
|
||||
preferences.previewZoom = { zoomPercent, panX, panY };
|
||||
}
|
||||
}
|
||||
if (Array.isArray(parsed.recentBlocks)) {
|
||||
preferences.recentBlocks = parsed.recentBlocks.filter(
|
||||
(v: unknown): v is string => typeof v === "string",
|
||||
);
|
||||
}
|
||||
return preferences;
|
||||
} catch {
|
||||
return {};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AUDIO_EXT, IMAGE_EXT, VIDEO_EXT } from "./mediaTypes";
|
||||
|
||||
export const TIMELINE_ASSET_MIME = "application/x-hyperframes-asset";
|
||||
export const TIMELINE_BLOCK_MIME = "application/x-hyperframes-block";
|
||||
const FALLBACK_TIMELINE_FILE_DROP_DURATION = 5;
|
||||
|
||||
export type TimelineAssetKind = "image" | "video" | "audio";
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
// Vite adapter that wires the shared Studio API to the local filesystem and build tools.
|
||||
|
||||
import { readFileSync, readdirSync, existsSync, writeFileSync, realpathSync } from "node:fs";
|
||||
import { join, relative, resolve, isAbsolute } from "node:path";
|
||||
import {
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
existsSync,
|
||||
writeFileSync,
|
||||
realpathSync,
|
||||
mkdirSync,
|
||||
copyFileSync,
|
||||
} from "node:fs";
|
||||
import { join, relative, resolve, isAbsolute, dirname } from "node:path";
|
||||
import type { ViteDevServer } from "vite";
|
||||
import {
|
||||
type ResolvedProject,
|
||||
type RenderJobState,
|
||||
type StudioApiAdapter,
|
||||
} from "@hyperframes/core/studio-api";
|
||||
import type { RegistryItem } from "@hyperframes/core/registry";
|
||||
import { createProjectSignature } from "../core/src/studio-api/helpers/projectSignature";
|
||||
import { createRetryingModuleLoader, ensureProducerDist } from "./vite.producer";
|
||||
import { createStudioDevRenderBodyScripts } from "./vite.studioMotion";
|
||||
@@ -250,5 +259,70 @@ export function createViteAdapter(dataDir: string, server: ViteDevServer): Studi
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
async listRegistryCatalog(): Promise<RegistryItem[]> {
|
||||
const registryRoot = resolve(__dirname, "../../registry");
|
||||
const items: RegistryItem[] = [];
|
||||
for (const subdir of ["blocks", "components"]) {
|
||||
const dir = join(registryRoot, subdir);
|
||||
if (!existsSync(dir)) continue;
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const manifestPath = join(dir, entry.name, "registry-item.json");
|
||||
if (!existsSync(manifestPath)) continue;
|
||||
try {
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as RegistryItem;
|
||||
if (manifest.type === "hyperframes:block" || manifest.type === "hyperframes:component")
|
||||
items.push(manifest);
|
||||
} catch {
|
||||
/* skip malformed manifests */
|
||||
}
|
||||
}
|
||||
}
|
||||
return items;
|
||||
},
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
async installRegistryBlock(opts: {
|
||||
project: ResolvedProject;
|
||||
blockName: string;
|
||||
}): Promise<{ written: string[]; block: RegistryItem }> {
|
||||
const registryRoot = resolve(__dirname, "../../registry");
|
||||
let itemDir = join(registryRoot, "blocks", opts.blockName);
|
||||
if (!existsSync(join(itemDir, "registry-item.json"))) {
|
||||
itemDir = join(registryRoot, "components", opts.blockName);
|
||||
}
|
||||
const manifestPath = join(itemDir, "registry-item.json");
|
||||
|
||||
if (!existsSync(manifestPath)) {
|
||||
throw new Error(`Item "${opts.blockName}" not found in registry`);
|
||||
}
|
||||
|
||||
const block = JSON.parse(readFileSync(manifestPath, "utf-8")) as RegistryItem;
|
||||
const written: string[] = [];
|
||||
|
||||
for (const file of block.files) {
|
||||
const sourcePath = join(itemDir, file.path);
|
||||
const targetPath = resolve(opts.project.dir, file.target);
|
||||
|
||||
if (!isPathWithin(opts.project.dir, targetPath)) {
|
||||
throw new Error(`Target path escapes project directory: ${file.target}`);
|
||||
}
|
||||
|
||||
mkdirSync(dirname(targetPath), { recursive: true });
|
||||
|
||||
if (file.type === "hyperframes:composition") {
|
||||
let content = readFileSync(sourcePath, "utf-8");
|
||||
content = `<!-- hyperframes-registry-item: ${block.name} -->\n${content}`;
|
||||
writeFileSync(targetPath, content, "utf-8");
|
||||
} else {
|
||||
copyFileSync(sourcePath, targetPath);
|
||||
}
|
||||
|
||||
written.push(file.target);
|
||||
}
|
||||
|
||||
return { written, block };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,5 +16,9 @@
|
||||
"target": "compositions/app-showcase.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/app-showcase.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/app-showcase.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"tags": ["showcase", "finance", "kinetic", "youtube", "sfx"],
|
||||
"author": "Stronkter",
|
||||
"authorUrl": "https://x.com/Stronkter",
|
||||
"sourcePrompt": "\ud83d\udcf7HyperFrames by HeyGen Make me a five-second video of, on a white background, of a Apple-style bold font counting from $0 to $10,000. Once it counts to $10,000, it changes to a green color and the screen also flashes green for a second, and then money icons come out of the $10,000 amount all over the screen and then disappear.",
|
||||
"sourcePrompt": "📷HyperFrames by HeyGen Make me a five-second video of, on a white background, of a Apple-style bold font counting from $0 to $10,000. Once it counts to $10,000, it changes to a green color and the screen also flashes green for a second, and then money icons come out of the $10,000 amount all over the screen and then disappear.",
|
||||
"dimensions": {
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
@@ -24,5 +24,9 @@
|
||||
"target": "assets/sfx-production.wav",
|
||||
"type": "hyperframes:asset"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/apple-money-count.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/apple-money-count.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,5 +28,9 @@
|
||||
"target": "assets/sfx/integrated-melodic-tech-mix.wav",
|
||||
"type": "hyperframes:asset"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/blue-sweater-intro-video.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/blue-sweater-intro-video.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,9 @@
|
||||
"target": "compositions/chromatic-radial-split.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/chromatic-radial-split.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/chromatic-radial-split.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,9 @@
|
||||
"target": "compositions/cinematic-zoom.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/cinematic-zoom.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/cinematic-zoom.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,9 @@
|
||||
"target": "compositions/cross-warp-morph.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/cross-warp-morph.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/cross-warp-morph.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,23 @@
|
||||
"target": "compositions/data-chart.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/data-chart.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/data-chart.png"
|
||||
},
|
||||
"params": [
|
||||
{
|
||||
"key": "--bg-color",
|
||||
"label": "Background",
|
||||
"type": "color",
|
||||
"default": "#faf9f6"
|
||||
},
|
||||
{
|
||||
"key": "--text-color",
|
||||
"label": "Text color",
|
||||
"type": "color",
|
||||
"default": "#333333"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -16,5 +16,9 @@
|
||||
"target": "compositions/domain-warp-dissolve.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/domain-warp-dissolve.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/domain-warp-dissolve.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,9 @@
|
||||
"target": "compositions/flash-through-white.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/flash-through-white.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/flash-through-white.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,9 @@
|
||||
"target": "compositions/flowchart-vertical.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/flowchart-vertical.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/flowchart-vertical.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,9 @@
|
||||
"target": "compositions/flowchart.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/flowchart.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/flowchart.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,9 @@
|
||||
"target": "compositions/glitch.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/glitch.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/glitch.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,9 @@
|
||||
"target": "compositions/gravitational-lens.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/gravitational-lens.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/gravitational-lens.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,5 +21,9 @@
|
||||
"target": "assets/avatar.jpg",
|
||||
"type": "hyperframes:asset"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/instagram-follow.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/instagram-follow.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,9 @@
|
||||
"target": "compositions/light-leak.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/light-leak.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/light-leak.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,23 @@
|
||||
"target": "compositions/logo-outro.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/logo-outro.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/logo-outro.png"
|
||||
},
|
||||
"params": [
|
||||
{
|
||||
"key": "--bg-color",
|
||||
"label": "Background",
|
||||
"type": "color",
|
||||
"default": "#0a0a0f"
|
||||
},
|
||||
{
|
||||
"key": "--accent-color",
|
||||
"label": "Accent",
|
||||
"type": "color",
|
||||
"default": "#1a1a1f"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -16,5 +16,9 @@
|
||||
"target": "compositions/macos-notification.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/macos-notification.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/macos-notification.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"tags": ["showcase", "map", "annotation", "youtube", "kinetic"],
|
||||
"author": "Stronkter",
|
||||
"authorUrl": "https://x.com/Stronkter",
|
||||
"sourcePrompt": "use \ud83d\udcf7HyperFrames by HeyGen and Image Gen if you need it for assets or like png images of assets without backround to make a youtube style camera moving in out and other things that are in youtube videos, to make a video of a map zooms in on north korea and a scribble style circle circles the country and a text pops up above it saying locked down when the text apears the video turns a bit redish make the video 7 seconds long id like the map to look realistic and accurate to real lfe",
|
||||
"sourcePrompt": "use 📷HyperFrames by HeyGen and Image Gen if you need it for assets or like png images of assets without backround to make a youtube style camera moving in out and other things that are in youtube videos, to make a video of a map zooms in on north korea and a scribble style circle circles the country and a text pops up above it saying locked down when the text apears the video turns a bit redish make the video 7 seconds long id like the map to look realistic and accurate to real lfe",
|
||||
"dimensions": {
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
@@ -24,5 +24,9 @@
|
||||
"target": "assets/korea-map.png",
|
||||
"type": "hyperframes:asset"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/north-korea-locked-down.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/north-korea-locked-down.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"tags": ["showcase", "travel", "map", "youtube", "sfx"],
|
||||
"author": "Stronkter",
|
||||
"authorUrl": "https://x.com/Stronkter",
|
||||
"sourcePrompt": "\ud83d\udcf7HyperFrames by HeyGen Make a six-second Apple-style font bold video of a plane going from New York to Paris. A map animation, and then it shows the plane going from New York to Paris. Make the fonts Apple-style bold and make the map actual realistic, actually realistic. Before it lands in Paris, do a doodle circle in red around Paris, and then it lands in the doodle circle, and the video basically cuts to a white screen at the end. Also add sound effects for everything as well. Every nice motion, every the plane for pop-ups, bubble, pop-up effect, everything you want.",
|
||||
"sourcePrompt": "📷HyperFrames by HeyGen Make a six-second Apple-style font bold video of a plane going from New York to Paris. A map animation, and then it shows the plane going from New York to Paris. Make the fonts Apple-style bold and make the map actual realistic, actually realistic. Before it lands in Paris, do a doodle circle in red around Paris, and then it lands in the doodle circle, and the video basically cuts to a white screen at the end. Also add sound effects for everything as well. Every nice motion, every the plane for pop-ups, bubble, pop-up effect, everything you want.",
|
||||
"dimensions": {
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
@@ -29,5 +29,9 @@
|
||||
"target": "assets/sfx-mix.wav",
|
||||
"type": "hyperframes:asset"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/nyc-paris-flight.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/nyc-paris-flight.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,9 @@
|
||||
"target": "compositions/reddit-post.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/reddit-post.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/reddit-post.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,9 @@
|
||||
"target": "compositions/ridged-burn.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/ridged-burn.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/ridged-burn.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,9 @@
|
||||
"target": "compositions/ripple-waves.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/ripple-waves.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/ripple-waves.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,9 @@
|
||||
"target": "compositions/sdf-iris.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/sdf-iris.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/sdf-iris.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
"title": "Spain Map",
|
||||
"description": "Animated Spain choropleth by autonomous community with staggered reveals and gradient legend — D3 conic conformal projection",
|
||||
"tags": ["data", "map", "geography", "spain", "europe", "choropleth"],
|
||||
"dimensions": { "width": 1920, "height": 1080 },
|
||||
"dimensions": {
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
},
|
||||
"duration": 12,
|
||||
"files": [
|
||||
{
|
||||
@@ -13,5 +16,9 @@
|
||||
"target": "compositions/spain-map.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/spain-map.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/spain-map.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,9 @@
|
||||
"target": "compositions/spotify-card.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/spotify-card.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/spotify-card.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,9 @@
|
||||
"target": "compositions/swirl-vortex.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/swirl-vortex.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/swirl-vortex.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,9 @@
|
||||
"target": "compositions/thermal-distortion.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/thermal-distortion.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/thermal-distortion.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,5 +21,9 @@
|
||||
"target": "assets/avatar.jpg",
|
||||
"type": "hyperframes:asset"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/tiktok-follow.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/tiktok-follow.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,9 @@
|
||||
"target": "compositions/transitions-3d.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/transitions-3d.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/transitions-3d.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,9 @@
|
||||
"target": "compositions/transitions-blur.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/transitions-blur.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/transitions-blur.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,9 @@
|
||||
"target": "compositions/transitions-cover.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/transitions-cover.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/transitions-cover.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,9 @@
|
||||
"target": "compositions/transitions-destruction.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/transitions-destruction.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/transitions-destruction.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,9 @@
|
||||
"target": "compositions/transitions-dissolve.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/transitions-dissolve.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/transitions-dissolve.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,9 @@
|
||||
"target": "compositions/transitions-distortion.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/transitions-distortion.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/transitions-distortion.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,9 @@
|
||||
"target": "compositions/transitions-grid.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/transitions-grid.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/transitions-grid.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,9 @@
|
||||
"target": "compositions/transitions-light.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/transitions-light.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/transitions-light.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,9 @@
|
||||
"target": "compositions/transitions-mechanical.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/transitions-mechanical.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/transitions-mechanical.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,9 @@
|
||||
"target": "compositions/transitions-other.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/transitions-other.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/transitions-other.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,9 @@
|
||||
"target": "compositions/transitions-push.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/transitions-push.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/transitions-push.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,9 @@
|
||||
"target": "compositions/transitions-radial.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/transitions-radial.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/transitions-radial.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,9 @@
|
||||
"target": "compositions/transitions-scale.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/transitions-scale.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/transitions-scale.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,9 @@
|
||||
"target": "compositions/ui-3d-reveal.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/ui-3d-reveal.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/ui-3d-reveal.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,9 @@
|
||||
"target": "compositions/us-map-bubble.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/us-map-bubble.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/us-map-bubble.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
"title": "US Flow Map",
|
||||
"description": "Animated connection arcs between US cities over a base map — composable origin-destination flow visualization",
|
||||
"tags": ["data", "map", "geography", "usa", "flow", "connections", "arcs"],
|
||||
"dimensions": { "width": 1920, "height": 1080 },
|
||||
"dimensions": {
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
},
|
||||
"duration": 12,
|
||||
"files": [
|
||||
{
|
||||
@@ -13,5 +16,9 @@
|
||||
"target": "compositions/us-map-flow.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/us-map-flow.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/us-map-flow.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
"title": "US Hex Grid Map",
|
||||
"description": "Animated hexagonal tile grid map — each state as an equal-weight hex with data fill and abbreviation label",
|
||||
"tags": ["data", "map", "geography", "usa", "hexgrid", "tilegrid"],
|
||||
"dimensions": { "width": 1920, "height": 1080 },
|
||||
"dimensions": {
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
},
|
||||
"duration": 10,
|
||||
"files": [
|
||||
{
|
||||
@@ -13,5 +16,9 @@
|
||||
"target": "compositions/us-map-hex.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/us-map-hex.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/us-map-hex.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,9 @@
|
||||
"target": "compositions/us-map.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/us-map.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/us-map.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "vfx-iphone-device",
|
||||
"type": "hyperframes:block",
|
||||
"title": "iPhone & MacBook 3D Showcase",
|
||||
"description": "Real GLTF iPhone 15 Pro Max and MacBook Pro models with live HTML-in-Canvas screen content, morphing glass lens, product review camera choreography, and 360\u00b0 turntable.",
|
||||
"description": "Real GLTF iPhone 15 Pro Max and MacBook Pro models with live HTML-in-Canvas screen content, morphing glass lens, product review camera choreography, and 360° turntable.",
|
||||
"stability": "experimental",
|
||||
"dimensions": {
|
||||
"width": 1920,
|
||||
@@ -37,5 +37,9 @@
|
||||
"target": "models/hyperframes-desktop.png",
|
||||
"type": "asset"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/vfx-iphone-device.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/vfx-iphone-device.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,5 +17,23 @@
|
||||
"target": "compositions/vfx-liquid-background.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/vfx-liquid-background.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/vfx-liquid-background.png"
|
||||
},
|
||||
"params": [
|
||||
{
|
||||
"key": "--bg-color",
|
||||
"label": "Background",
|
||||
"type": "color",
|
||||
"default": "#0a0e17"
|
||||
},
|
||||
{
|
||||
"key": "--text-color",
|
||||
"label": "Text color",
|
||||
"type": "color",
|
||||
"default": "#e2e8f0"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -17,5 +17,23 @@
|
||||
"target": "compositions/vfx-liquid-glass.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/vfx-liquid-glass.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/vfx-liquid-glass.png"
|
||||
},
|
||||
"params": [
|
||||
{
|
||||
"key": "--bg-color",
|
||||
"label": "Background",
|
||||
"type": "color",
|
||||
"default": "#030407"
|
||||
},
|
||||
{
|
||||
"key": "--accent-color",
|
||||
"label": "Accent",
|
||||
"type": "color",
|
||||
"default": "#00d4ff"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -17,5 +17,23 @@
|
||||
"target": "compositions/vfx-magnetic.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/vfx-magnetic.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/vfx-magnetic.png"
|
||||
},
|
||||
"params": [
|
||||
{
|
||||
"key": "--bg-color",
|
||||
"label": "Background",
|
||||
"type": "color",
|
||||
"default": "#0c0c0c"
|
||||
},
|
||||
{
|
||||
"key": "--text-color",
|
||||
"label": "Text color",
|
||||
"type": "color",
|
||||
"default": "#e8e8e8"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -17,5 +17,23 @@
|
||||
"target": "compositions/vfx-portal.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/vfx-portal.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/vfx-portal.png"
|
||||
},
|
||||
"params": [
|
||||
{
|
||||
"key": "--bg-color",
|
||||
"label": "Background",
|
||||
"type": "color",
|
||||
"default": "#000000"
|
||||
},
|
||||
{
|
||||
"key": "--portal-color",
|
||||
"label": "Portal color",
|
||||
"type": "color",
|
||||
"default": "#fafafa"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -17,5 +17,9 @@
|
||||
"target": "compositions/vfx-shatter.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/vfx-shatter.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/vfx-shatter.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,5 +17,9 @@
|
||||
"target": "compositions/vfx-text-cursor.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/vfx-text-cursor.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/vfx-text-cursor.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,5 +24,9 @@
|
||||
"target": "assets/vpn-sfx.wav",
|
||||
"type": "hyperframes:asset"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/vpn-youtube-spot.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/vpn-youtube-spot.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,9 @@
|
||||
"target": "compositions/whip-pan.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/whip-pan.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/whip-pan.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
"title": "World Map",
|
||||
"description": "Animated world choropleth with country-by-country reveal, tooltip labels, and rotating globe inset — D3 Natural Earth projection",
|
||||
"tags": ["data", "map", "geography", "world", "choropleth"],
|
||||
"dimensions": { "width": 1920, "height": 1080 },
|
||||
"dimensions": {
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
},
|
||||
"duration": 14,
|
||||
"files": [
|
||||
{
|
||||
@@ -13,5 +16,9 @@
|
||||
"target": "compositions/world-map.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/world-map.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/world-map.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,9 @@
|
||||
"target": "compositions/x-post.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/x-post.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/x-post.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,5 +21,9 @@
|
||||
"target": "assets/avatar.jpg",
|
||||
"type": "hyperframes:asset"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/yt-lower-third.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/yt-lower-third.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,5 +12,8 @@
|
||||
"type": "hyperframes:snippet"
|
||||
}
|
||||
],
|
||||
"preview": "https://static.heygen.ai/hyperframes-oss/registry/components/caption-clip-wipe/preview-v2.mp4"
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/registry/components/caption-clip-wipe/preview-v2.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/components/caption-clip-wipe.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,5 +12,8 @@
|
||||
"type": "hyperframes:snippet"
|
||||
}
|
||||
],
|
||||
"preview": "https://static.heygen.ai/hyperframes-oss/registry/components/caption-editorial-emphasis/preview.mp4?v=1779051416"
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/registry/components/caption-editorial-emphasis/preview.mp4?v=1779051416",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/components/caption-editorial-emphasis.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,5 +12,8 @@
|
||||
"type": "hyperframes:snippet"
|
||||
}
|
||||
],
|
||||
"preview": "https://static.heygen.ai/hyperframes-oss/registry/components/caption-emoji-pop/preview.mp4?v=1779051416"
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/registry/components/caption-emoji-pop/preview.mp4?v=1779051416",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/components/caption-emoji-pop.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,5 +12,8 @@
|
||||
"type": "hyperframes:snippet"
|
||||
}
|
||||
],
|
||||
"preview": "https://static.heygen.ai/hyperframes-oss/registry/components/caption-glitch-rgb/preview-v2.mp4"
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/registry/components/caption-glitch-rgb/preview-v2.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/components/caption-glitch-rgb.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,5 +12,8 @@
|
||||
"type": "hyperframes:snippet"
|
||||
}
|
||||
],
|
||||
"preview": "https://static.heygen.ai/hyperframes-oss/registry/components/caption-gradient-fill/preview-v2.mp4"
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/registry/components/caption-gradient-fill/preview-v2.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/components/caption-gradient-fill.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,5 +12,8 @@
|
||||
"type": "hyperframes:snippet"
|
||||
}
|
||||
],
|
||||
"preview": "https://static.heygen.ai/hyperframes-oss/registry/components/caption-highlight/preview-v2.mp4"
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/registry/components/caption-highlight/preview-v2.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/components/caption-highlight.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,5 +12,8 @@
|
||||
"type": "hyperframes:snippet"
|
||||
}
|
||||
],
|
||||
"preview": "https://static.heygen.ai/hyperframes-oss/registry/components/caption-kinetic-slam/preview.mp4?v=1779051416"
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/registry/components/caption-kinetic-slam/preview.mp4?v=1779051416",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/components/caption-kinetic-slam.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,5 +12,8 @@
|
||||
"type": "hyperframes:snippet"
|
||||
}
|
||||
],
|
||||
"preview": "https://static.heygen.ai/hyperframes-oss/registry/components/caption-matrix-decode/preview-v2.mp4"
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/registry/components/caption-matrix-decode/preview-v2.mp4",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/components/caption-matrix-decode.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,5 +12,8 @@
|
||||
"type": "hyperframes:snippet"
|
||||
}
|
||||
],
|
||||
"preview": "https://static.heygen.ai/hyperframes-oss/registry/components/caption-neon-accent/preview.mp4?v=1779051416"
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/registry/components/caption-neon-accent/preview.mp4?v=1779051416",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/components/caption-neon-accent.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,5 +12,8 @@
|
||||
"type": "hyperframes:snippet"
|
||||
}
|
||||
],
|
||||
"preview": "https://static.heygen.ai/hyperframes-oss/registry/components/caption-neon-glow/preview.mp4?v=1779051416"
|
||||
"preview": {
|
||||
"video": "https://static.heygen.ai/hyperframes-oss/registry/components/caption-neon-glow/preview.mp4?v=1779051416",
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/components/caption-neon-glow.png"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user