diff --git a/.filesize-allowlist b/.filesize-allowlist index 11dad5076..ba27c83d5 100644 --- a/.filesize-allowlist +++ b/.filesize-allowlist @@ -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 diff --git a/packages/aws-lambda/package.json b/packages/aws-lambda/package.json index f869f343c..676686ff2 100644 --- a/packages/aws-lambda/package.json +++ b/packages/aws-lambda/package.json @@ -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", diff --git a/packages/cli/package.json b/packages/cli/package.json index b8108fb8e..8ef26a91b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index 7a491a915..cbf2d32ca 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -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 ───────────────────────────────────────────────── diff --git a/packages/core/package.json b/packages/core/package.json index c938b53f2..d17551c7f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/core", - "version": "0.6.22", + "version": "0.6.23", "description": "", "repository": { "type": "git", diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index ff8b5890d..cf47b75a8 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -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, diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 098b6ac06..32ded043f 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -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 `. */ 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 { diff --git a/packages/core/src/studio-api/createStudioApi.ts b/packages/core/src/studio-api/createStudioApi.ts index 04492aad4..99ff77553 100644 --- a/packages/core/src/studio-api/createStudioApi.ts +++ b/packages/core/src/studio-api/createStudioApi.ts @@ -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; } diff --git a/packages/core/src/studio-api/routes/registry.ts b/packages/core/src/studio-api/routes/registry.ts new file mode 100644 index 000000000..c41a2f50b --- /dev/null +++ b/packages/core/src/studio-api/routes/registry.ts @@ -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); + } + }); +} diff --git a/packages/core/src/studio-api/types.ts b/packages/core/src/studio-api/types.ts index 93aa9dc4a..0ef6e17cd 100644 --- a/packages/core/src/studio-api/types.ts +++ b/packages/core/src/studio-api/types.ts @@ -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; + + /** Optional: install a registry item into a project directory. */ + installRegistryBlock?(opts: { + project: ResolvedProject; + blockName: string; + }): Promise<{ written: string[]; block: RegistryItem }>; } diff --git a/packages/engine/package.json b/packages/engine/package.json index e01652195..b757226f4 100644 --- a/packages/engine/package.json +++ b/packages/engine/package.json @@ -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", diff --git a/packages/player/package.json b/packages/player/package.json index 09329b28b..ee7058eb1 100644 --- a/packages/player/package.json +++ b/packages/player/package.json @@ -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", diff --git a/packages/producer/package.json b/packages/producer/package.json index 2f9fcb2a1..7b33a839e 100644 --- a/packages/producer/package.json +++ b/packages/producer/package.json @@ -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", diff --git a/packages/shader-transitions/package.json b/packages/shader-transitions/package.json index 23725f909..bb71bafe7 100644 --- a/packages/shader-transitions/package.json +++ b/packages/shader-transitions/package.json @@ -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", diff --git a/packages/studio/package.json b/packages/studio/package.json index 4e44a5857..a6975bc86 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/studio", - "version": "0.6.22", + "version": "0.6.23", "description": "", "repository": { "type": "git", diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 1cf24b2c5..7a0360b60 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -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(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(null); const handleDomEditElementDeleteRef = useRef<(s: DomEditSelection) => Promise>( @@ -427,6 +508,7 @@ export function StudioApp() { @@ -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"); + }} /> )} diff --git a/packages/studio/src/components/StudioLeftSidebar.tsx b/packages/studio/src/components/StudioLeftSidebar.tsx index f83232507..d71f2e106 100644 --- a/packages/studio/src/components/StudioLeftSidebar.tsx +++ b/packages/studio/src/components/StudioLeftSidebar.tsx @@ -11,13 +11,16 @@ import { getPersistedRenderSettings } from "./renders/renderSettings"; export interface StudioLeftSidebarProps { leftSidebarRef: RefObject; 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} />
, ) => Promise | void; + handleTimelineBlockDrop?: ( + blockName: string, + placement: Pick, + ) => Promise | void; handleTimelineFileDrop: ( files: File[], placement?: Pick, @@ -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} diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index ef82b14ac..4f36db440 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -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({
- {rightPanelTab === "layers" ? ( + {rightPanelTab === "block-params" && activeBlockParams ? ( + {})} + /> + ) : rightPanelTab === "layers" ? ( ) : designPanelActive ? ( void; +} + +export const BlockParamsPanel = memo(function BlockParamsPanel({ + blockTitle, + params, + compositionPath, + onClose, +}: BlockParamsPanelProps) { + const [values, setValues] = useState>(() => { + const initial: Record = {}; + 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 ( +
+
+
{blockTitle}
+ +
+ +
+
+ Parameters +
+ {params.map((param) => ( + handleChange(param.key, v)} + /> + ))} +
+
+ ); +}); + +function ParamControl({ + param, + value, + onChange, +}: { + param: BlockParam; + value: string; + onChange: (value: string) => void; +}) { + return ( +
+ + + {param.type === "color" && ( +
+ onChange(e.target.value)} + className="w-7 h-7 rounded border border-neutral-700 bg-transparent cursor-pointer" + /> + 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" + /> +
+ )} + + {param.type === "number" && ( +
+ onChange(e.target.value)} + className="flex-1" + /> + {value} +
+ )} + + {param.type === "text" && ( + 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 && ( + + )} +
+ ); +} diff --git a/packages/studio/src/components/editor/manualEditingAvailability.ts b/packages/studio/src/components/editor/manualEditingAvailability.ts index 345ca8f41..703211a13 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.ts @@ -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; diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx index 685ce24ff..e23e22574 100644 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ b/packages/studio/src/components/nle/NLELayout.tsx @@ -42,6 +42,10 @@ interface NLELayoutProps { assetPath: string, placement: Pick, ) => Promise | void; + onBlockDrop?: ( + blockName: string, + placement: Pick, + ) => Promise | 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} diff --git a/packages/studio/src/components/sidebar/BlocksTab.tsx b/packages/studio/src/components/sidebar/BlocksTab.tsx new file mode 100644 index 000000000..02b3ab2cb --- /dev/null +++ b/packages/studio/src/components/sidebar/BlocksTab.tsx @@ -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 ( +
+ Loading blocks… +
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + return ( +
+ {/* Search */} +
+
+ + + + + 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" + /> +
+
+ + {/* Category pills */} +
+
+ setCategory(null)} /> + {BLOCK_CATEGORIES.map((cat) => ( + setCategory(category === cat.id ? null : cat.id)} + /> + ))} +
+
+ + {/* Block grid */} +
+ {category === "vfx" && ( +
+ VFX blocks use WebGL via HTML-in-Canvas. Enable{" "} + chrome://flags/#html-in-canvas for + preview. +
+ )} + {filteredBlocks.length === 0 ? ( +
+ No blocks match your search +
+ ) : ( +
+ {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 ( + onAddBlock(block.name)} + /> + ); + })} +
+ )} +
+
+ ); +}); + +function CategoryPill({ + label, + category, + active, + onClick, +}: { + label: string; + category?: BlockCategory; + active: boolean; + onClick: () => void; +}) { + const colors = category ? getCategoryColors(category) : null; + return ( + + ); +} + +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 | null>(null); + const leaveTimer = useRef | null>(null); + const videoRef = useRef(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 ( +
+ {/* Thumbnail */} +
+ {hovered && videoUrl ? ( +
+ + {/* Info */} +
+
+ {title} +
+
+ + + {BLOCK_CATEGORIES.find((c) => c.id === category)?.label} + +
+
+ + {/* Fullscreen hover preview */} + {hovered && + (videoUrl || posterUrl) && + createPortal( +
+
+ +
e.stopPropagation()} + > +
+ {videoUrl ? ( +
+
+
{title}
+
+ + + {BLOCK_CATEGORIES.find((c) => c.id === category)?.label} + + {duration != null && ( + {duration}s + )} +
+
+
+
, + document.body, + )} +
+ ); +} diff --git a/packages/studio/src/components/sidebar/LeftSidebar.tsx b/packages/studio/src/components/sidebar/LeftSidebar.tsx index 6a882babb..191835f2c 100644 --- a/packages/studio/src/components/sidebar/LeftSidebar.tsx +++ b/packages/studio/src/components/sidebar/LeftSidebar.tsx @@ -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(
+ {STUDIO_BLOCKS_PANEL_ENABLED && ( + + )}
{onToggleCollapse && (