Merge pull request #947 from heygen-com/feat/studio-blocks-panel

feat(studio): full Blocks panel — browse, search, add, drag-and-drop registry items
This commit is contained in:
Miguel Ángel
2026-05-19 03:58:25 +02:00
committed by GitHub
111 changed files with 1800 additions and 106 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/studio",
"version": "0.6.22",
"version": "0.6.23",
"description": "",
"repository": {
"type": "git",
+88
View File
@@ -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];
}
+172
View File
@@ -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;
}
}
+1 -1
View File
@@ -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";
+76 -2
View File
@@ -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 };
},
};
}