mirror of
https://github.com/hanzoai/frames.git
synced 2026-09-24 00:08:03 +00:00
Merge pull request #1025 from heygen-com/feat/studio-catalog-panel
feat(studio): catalog panel with Ask agent, block preview, and composition context
This commit is contained in:
@@ -398,8 +398,38 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
async installRegistryBlock(opts) {
|
||||
const { resolveItem } = await import("../registry/resolver.js");
|
||||
const { installItem } = await import("../registry/installer.js");
|
||||
const { readFileSync, writeFileSync, existsSync } = await import("node:fs");
|
||||
const { join } = await import("node:path");
|
||||
const item = await resolveItem(opts.blockName);
|
||||
const { written } = await installItem(item, { destDir: opts.project.dir });
|
||||
|
||||
const indexPath = join(opts.project.dir, "index.html");
|
||||
if (existsSync(indexPath)) {
|
||||
const indexHtml = readFileSync(indexPath, "utf-8");
|
||||
const hostW = indexHtml.match(/data-width="(\d+)"/)?.[1];
|
||||
const hostH = indexHtml.match(/data-height="(\d+)"/)?.[1];
|
||||
if (hostW && hostH) {
|
||||
for (const absPath of written) {
|
||||
if (!absPath.endsWith(".html")) continue;
|
||||
let content = readFileSync(absPath, "utf-8");
|
||||
content = content.replace(
|
||||
/(<meta\s+name="viewport"\s+content="width=)\d+(,\s*height=)\d+/i,
|
||||
`$1${hostW}$2${hostH}`,
|
||||
);
|
||||
content = content.replace(
|
||||
/(\bwidth:\s*)\d+(px;\s*\n?\s*height:\s*)\d+(px;)/g,
|
||||
(match, pre, mid, post) => {
|
||||
if (match.includes("1920") || match.includes("1080")) {
|
||||
return `${pre}${hostW}${mid}${hostH}${post}`;
|
||||
}
|
||||
return match;
|
||||
},
|
||||
);
|
||||
writeFileSync(absPath, content, "utf-8");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const relativePaths = written.map((abs) => {
|
||||
const rel = abs.startsWith(opts.project.dir) ? abs.slice(opts.project.dir.length + 1) : abs;
|
||||
return rel;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Tooltip } from "./ui";
|
||||
import { PropertyPanel } from "./editor/PropertyPanel";
|
||||
import { MotionPanel } from "./editor/MotionPanel";
|
||||
import { LayersPanel } from "./editor/LayersPanel";
|
||||
@@ -106,54 +107,62 @@ export function StudioRightPanel({
|
||||
<div className="flex items-center gap-1 border-b border-neutral-800 px-3 py-2">
|
||||
{STUDIO_INSPECTOR_PANELS_ENABLED && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRightPanelTab("design")}
|
||||
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
|
||||
rightPanelTab === "design"
|
||||
? "bg-neutral-800 text-white"
|
||||
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
|
||||
}`}
|
||||
>
|
||||
Design
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRightPanelTab("layers")}
|
||||
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
|
||||
rightPanelTab === "layers"
|
||||
? "bg-neutral-800 text-white"
|
||||
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
|
||||
}`}
|
||||
>
|
||||
Layers
|
||||
</button>
|
||||
{STUDIO_MOTION_PANEL_ENABLED && (
|
||||
<Tooltip label="Element styles and properties" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRightPanelTab("motion")}
|
||||
onClick={() => setRightPanelTab("design")}
|
||||
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
|
||||
rightPanelTab === "motion"
|
||||
rightPanelTab === "design"
|
||||
? "bg-neutral-800 text-white"
|
||||
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
|
||||
}`}
|
||||
>
|
||||
Motion
|
||||
Design
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Composition layer stack" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRightPanelTab("layers")}
|
||||
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
|
||||
rightPanelTab === "layers"
|
||||
? "bg-neutral-800 text-white"
|
||||
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
|
||||
}`}
|
||||
>
|
||||
Layers
|
||||
</button>
|
||||
</Tooltip>
|
||||
{STUDIO_MOTION_PANEL_ENABLED && (
|
||||
<Tooltip label="Animation and motion" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRightPanelTab("motion")}
|
||||
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
|
||||
rightPanelTab === "motion"
|
||||
? "bg-neutral-800 text-white"
|
||||
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
|
||||
}`}
|
||||
>
|
||||
Motion
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRightPanelTab("renders")}
|
||||
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
|
||||
rightPanelTab === "renders"
|
||||
? "bg-neutral-800 text-white"
|
||||
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
|
||||
}`}
|
||||
>
|
||||
{renderJobs.length > 0 ? `Renders (${renderJobs.length})` : "Renders"}
|
||||
</button>
|
||||
<Tooltip label="Render queue and exports" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRightPanelTab("renders")}
|
||||
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
|
||||
rightPanelTab === "renders"
|
||||
? "bg-neutral-800 text-white"
|
||||
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
|
||||
}`}
|
||||
>
|
||||
{renderJobs.length > 0 ? `Renders (${renderJobs.length})` : "Renders"}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
{rightPanelTab === "block-params" && activeBlockParams ? (
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
} from "../player/components/timelineZoom";
|
||||
import { getTimelineToggleTitle } from "../utils/timelineDiscovery";
|
||||
import { usePlayerStore } from "../player";
|
||||
import { Tooltip } from "./ui";
|
||||
|
||||
interface TimelineToolbarProps {
|
||||
toggleTimelineVisibility: () => void;
|
||||
@@ -23,65 +24,71 @@ export function TimelineToolbar({ toggleTimelineVisibility }: TimelineToolbarPro
|
||||
Timeline
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZoomMode("fit")}
|
||||
className={`h-7 px-2.5 rounded-md border text-[11px] font-medium transition-colors ${
|
||||
zoomMode === "fit"
|
||||
? "border-studio-accent/30 bg-studio-accent/10 text-studio-accent"
|
||||
: "border-neutral-800 text-neutral-400 hover:border-neutral-700 hover:text-neutral-200"
|
||||
}`}
|
||||
title="Fit timeline to width"
|
||||
>
|
||||
Fit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setZoomMode("manual");
|
||||
setManualZoomPercent(getNextTimelineZoomPercent("out", zoomMode, manualZoomPercent));
|
||||
}}
|
||||
className="h-7 w-7 rounded-md border border-neutral-800 text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-200"
|
||||
title="Zoom out"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<Tooltip label="Fit timeline to width">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZoomMode("fit")}
|
||||
className={`h-7 px-2.5 rounded-md border text-[11px] font-medium transition-colors ${
|
||||
zoomMode === "fit"
|
||||
? "border-studio-accent/30 bg-studio-accent/10 text-studio-accent"
|
||||
: "border-neutral-800 text-neutral-400 hover:border-neutral-700 hover:text-neutral-200"
|
||||
}`}
|
||||
>
|
||||
Fit
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Zoom out">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setZoomMode("manual");
|
||||
setManualZoomPercent(
|
||||
getNextTimelineZoomPercent("out", zoomMode, manualZoomPercent),
|
||||
);
|
||||
}}
|
||||
className="h-7 w-7 rounded-md border border-neutral-800 text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-200"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
</Tooltip>
|
||||
<div className="min-w-[58px] text-center text-[10px] font-medium tabular-nums text-neutral-500">
|
||||
{`${displayedTimelineZoomPercent}%`}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setZoomMode("manual");
|
||||
setManualZoomPercent(getNextTimelineZoomPercent("in", zoomMode, manualZoomPercent));
|
||||
}}
|
||||
className="h-7 w-7 rounded-md border border-neutral-800 text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-200"
|
||||
title="Zoom in"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTimelineVisibility}
|
||||
className="ml-1 flex h-7 w-7 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-neutral-900 hover:text-neutral-200"
|
||||
title={getTimelineToggleTitle(true)}
|
||||
aria-label="Hide timeline editor"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
<Tooltip label="Zoom in">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setZoomMode("manual");
|
||||
setManualZoomPercent(getNextTimelineZoomPercent("in", zoomMode, manualZoomPercent));
|
||||
}}
|
||||
className="h-7 w-7 rounded-md border border-neutral-800 text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-200"
|
||||
>
|
||||
<path d="M5 7h14" />
|
||||
<path d="m8 11 4 4 4-4" />
|
||||
</svg>
|
||||
</button>
|
||||
+
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={getTimelineToggleTitle(true)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTimelineVisibility}
|
||||
className="ml-1 flex h-7 w-7 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-neutral-900 hover:text-neutral-200"
|
||||
aria-label="Hide timeline editor"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M5 7h14" />
|
||||
<path d="m8 11 4 4 4-4" />
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -391,6 +391,16 @@ export const NLEPreview = memo(function NLEPreview({
|
||||
}}
|
||||
data-testid="preview-zoom-stage"
|
||||
>
|
||||
{directUrl?.includes("/components/") && (
|
||||
<Player
|
||||
key={`backdrop-${projectId}`}
|
||||
projectId={projectId}
|
||||
onLoad={() => {}}
|
||||
portrait={portrait}
|
||||
suppressLoadingOverlay
|
||||
style={{ position: "absolute", inset: 0, zIndex: 0 }}
|
||||
/>
|
||||
)}
|
||||
<Player
|
||||
key={activeKey}
|
||||
ref={iframeRef}
|
||||
@@ -403,6 +413,11 @@ export const NLEPreview = memo(function NLEPreview({
|
||||
onCompositionLoadingChange={onCompositionLoadingChange}
|
||||
portrait={portrait}
|
||||
suppressLoadingOverlay={suppressLoadingOverlay}
|
||||
style={
|
||||
directUrl?.includes("/components/")
|
||||
? { position: "absolute", inset: 0, zIndex: 1 }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
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";
|
||||
|
||||
import { usePlayerStore } from "../../player";
|
||||
import { formatTime } from "../../player/lib/time";
|
||||
import { useStudioContext } from "../../contexts/StudioContext";
|
||||
export interface BlockPreviewInfo {
|
||||
videoUrl?: string;
|
||||
posterUrl?: string;
|
||||
@@ -14,7 +16,7 @@ export interface BlockPreviewInfo {
|
||||
}
|
||||
|
||||
interface BlocksTabProps {
|
||||
onAddBlock: (blockName: string) => void;
|
||||
onAddBlock?: (blockName: string) => void;
|
||||
onPreviewBlock?: (preview: BlockPreviewInfo | null) => void;
|
||||
}
|
||||
|
||||
@@ -22,6 +24,7 @@ interface BlocksTabProps {
|
||||
export const BlocksTab = memo(function BlocksTab({ onAddBlock, onPreviewBlock }: BlocksTabProps) {
|
||||
const { loading, error, search, setSearch, category, setCategory, filteredBlocks } =
|
||||
useBlockCatalog();
|
||||
const [promptModal, setPromptModal] = useState<{ title: string; prompt: string } | null>(null);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -62,7 +65,7 @@ export const BlocksTab = memo(function BlocksTab({ onAddBlock, onPreviewBlock }:
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search blocks…"
|
||||
placeholder="Search by name, category, or tag…"
|
||||
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>
|
||||
@@ -104,29 +107,42 @@ export const BlocksTab = memo(function BlocksTab({ onAddBlock, onPreviewBlock }:
|
||||
>
|
||||
{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}
|
||||
description={block.description}
|
||||
blockType={block.type}
|
||||
duration={dur}
|
||||
category={block.category}
|
||||
tags={block.tags}
|
||||
posterUrl={block.preview?.poster}
|
||||
videoUrl={block.preview?.video}
|
||||
dimensions={dims}
|
||||
onAdd={() => onAddBlock(block.name)}
|
||||
onPreview={onPreviewBlock}
|
||||
onShowPrompt={setPromptModal}
|
||||
onAdd={
|
||||
block.category === "vfx" ||
|
||||
block.category === "social" ||
|
||||
block.category === "scenes"
|
||||
? () => onAddBlock?.(block.name)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{promptModal &&
|
||||
createPortal(
|
||||
<PromptPreviewModal
|
||||
title={promptModal.title}
|
||||
prompt={promptModal.prompt}
|
||||
onClose={() => setPromptModal(null)}
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -160,27 +176,134 @@ function CategoryPill({
|
||||
);
|
||||
}
|
||||
|
||||
interface CompositionContext {
|
||||
currentTime: number;
|
||||
activeCompPath: string | null;
|
||||
elements: Array<{
|
||||
id: string;
|
||||
start: number;
|
||||
duration: number;
|
||||
track: number;
|
||||
label?: string;
|
||||
compositionSrc?: string;
|
||||
}>;
|
||||
compositionDimensions?: { width: number; height: number };
|
||||
}
|
||||
|
||||
function formatCompositionContext(ctx: CompositionContext): string {
|
||||
const lines: string[] = [
|
||||
`Playback time: ${formatTime(ctx.currentTime)}`,
|
||||
`Active composition: ${ctx.activeCompPath || "index.html"}`,
|
||||
];
|
||||
if (ctx.compositionDimensions) {
|
||||
lines.push(
|
||||
`Dimensions: ${ctx.compositionDimensions.width}x${ctx.compositionDimensions.height}`,
|
||||
);
|
||||
}
|
||||
const visibleNow = ctx.elements.filter(
|
||||
(el) => ctx.currentTime >= el.start && ctx.currentTime < el.start + el.duration,
|
||||
);
|
||||
if (visibleNow.length > 0) {
|
||||
lines.push(
|
||||
"",
|
||||
`Elements visible at ${formatTime(ctx.currentTime)}:`,
|
||||
...visibleNow.map(
|
||||
(el) =>
|
||||
`- ${el.label || el.id} (track ${el.track}, ${formatTime(el.start)}–${formatTime(el.start + el.duration)}${el.compositionSrc ? `, src: ${el.compositionSrc}` : ""})`,
|
||||
),
|
||||
);
|
||||
}
|
||||
const maxZ = ctx.elements.length > 0 ? Math.max(...ctx.elements.map((_, i) => i + 1)) : 0;
|
||||
lines.push("", `Highest track index: ${maxZ}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function buildAgentPrompt(
|
||||
title: string,
|
||||
name: string,
|
||||
description: string,
|
||||
category: BlockCategory,
|
||||
blockType: string,
|
||||
context: CompositionContext,
|
||||
): string {
|
||||
const isComponent = blockType === "hyperframes:component";
|
||||
const kind = isComponent ? "component" : "block";
|
||||
const compositionInfo = formatCompositionContext(context);
|
||||
|
||||
const categoryPrompts: Record<string, string> = {
|
||||
captions: [
|
||||
`Using /hyperframes, add the "${title}" caption style (registry: ${name}) to my composition.`,
|
||||
`${description}`,
|
||||
`Transcribe the audio with /hyperframes-media, then wire the transcript into this caption component. Match the font colors and animation timing to my composition's design tokens. Place it as an overlay above the main content with the highest z-index.`,
|
||||
].join("\n\n"),
|
||||
vfx: [
|
||||
`Using /hyperframes, add the "${title}" VFX (registry: ${name}) as a full-screen overlay on my composition.`,
|
||||
`${description}`,
|
||||
`This is a WebGL effect that requires chrome://flags/#html-in-canvas. Layer it on top of all content, adjust the shader uniforms and color palette to complement my scene, and set the duration to match the composition length.`,
|
||||
].join("\n\n"),
|
||||
transitions: [
|
||||
`Using /hyperframes, add the "${title}" transition (registry: ${name}) between my scenes.`,
|
||||
`${description}`,
|
||||
`Place this transition at the cut point between the current scene and the next. Set the duration to 0.5–1s, position it at the scene boundary on the timeline, and make sure the z-index is above both scenes. Adjust colors to match my palette.`,
|
||||
].join("\n\n"),
|
||||
effects: [
|
||||
`Using /hyperframes, add the "${title}" effect (registry: ${name}) as an overlay on my composition.`,
|
||||
`${description}`,
|
||||
`Layer this on top of the current content. Adjust the opacity, colors, and animation timing to enhance the scene without overwhelming the main content.`,
|
||||
].join("\n\n"),
|
||||
social: [
|
||||
`Using /hyperframes, add the "${title}" template (registry: ${name}) to my composition.`,
|
||||
`${description}`,
|
||||
`Replace the placeholder text, handle, and avatar with my actual content. Match the typography and colors to my brand. Adjust timing so the elements animate in sync with the voiceover.`,
|
||||
].join("\n\n"),
|
||||
data: [
|
||||
`Using /hyperframes, add the "${title}" visualization (registry: ${name}) to my composition.`,
|
||||
`${description}`,
|
||||
`Replace the placeholder data with my actual values and labels. Adjust the color scale, animation stagger timing, and typography to match my composition's design system. Size it to fit the current viewport.`,
|
||||
].join("\n\n"),
|
||||
scenes: [
|
||||
`Using /hyperframes, add the "${title}" scene (registry: ${name}) to my composition.`,
|
||||
`${description}`,
|
||||
`Replace all placeholder text, images, and content with my actual material. Match fonts, colors, and layout to my existing design tokens. Set the timeline position and duration to fit the narrative flow.`,
|
||||
].join("\n\n"),
|
||||
};
|
||||
|
||||
const instruction =
|
||||
categoryPrompts[category] ??
|
||||
[
|
||||
`Using /hyperframes, add the "${title}" ${kind} (registry: ${name}) to my composition.`,
|
||||
`${description}`,
|
||||
`Customize it to match my composition's design and timeline.`,
|
||||
].join("\n\n");
|
||||
|
||||
return [instruction, "", "## Current composition state", "", compositionInfo].join("\n");
|
||||
}
|
||||
|
||||
function BlockCard({
|
||||
name,
|
||||
title,
|
||||
description,
|
||||
blockType,
|
||||
duration,
|
||||
category,
|
||||
tags,
|
||||
posterUrl,
|
||||
videoUrl,
|
||||
dimensions,
|
||||
onAdd,
|
||||
onShowPrompt,
|
||||
onPreview,
|
||||
}: {
|
||||
name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
blockType: string;
|
||||
duration?: number;
|
||||
category: BlockCategory;
|
||||
tags?: string[];
|
||||
posterUrl?: string;
|
||||
videoUrl?: string;
|
||||
dimensions?: { width: number; height: number };
|
||||
onAdd: () => void;
|
||||
onAdd?: () => void;
|
||||
onShowPrompt?: (info: { title: string; prompt: string }) => void;
|
||||
onPreview?: (preview: BlockPreviewInfo | null) => void;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
@@ -214,7 +337,7 @@ function BlockCard({
|
||||
const handleAdd = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (adding) return;
|
||||
if (adding || !onAdd) return;
|
||||
setAdding(true);
|
||||
onAdd();
|
||||
setTimeout(() => setAdding(false), 1000);
|
||||
@@ -222,12 +345,38 @@ function BlockCard({
|
||||
[onAdd, adding],
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
e.dataTransfer.setData(TIMELINE_BLOCK_MIME, JSON.stringify({ name, duration, dimensions }));
|
||||
const { activeCompPath, compositionDimensions } = useStudioContext();
|
||||
|
||||
const handleShowPrompt = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const state = usePlayerStore.getState();
|
||||
const context: CompositionContext = {
|
||||
currentTime: state.currentTime,
|
||||
activeCompPath,
|
||||
elements: state.elements.map((el) => ({
|
||||
id: el.id,
|
||||
start: el.start,
|
||||
duration: el.duration,
|
||||
track: el.track,
|
||||
label: el.label,
|
||||
compositionSrc: el.compositionSrc,
|
||||
})),
|
||||
compositionDimensions: compositionDimensions ?? undefined,
|
||||
};
|
||||
const prompt = buildAgentPrompt(title, name, description, category, blockType, context);
|
||||
onShowPrompt?.({ title, prompt });
|
||||
},
|
||||
[name, duration, dimensions],
|
||||
[
|
||||
title,
|
||||
name,
|
||||
description,
|
||||
category,
|
||||
blockType,
|
||||
activeCompPath,
|
||||
compositionDimensions,
|
||||
onShowPrompt,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -235,8 +384,6 @@ function BlockCard({
|
||||
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">
|
||||
@@ -267,14 +414,52 @@ function BlockCard({
|
||||
</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>
|
||||
{/* Action overlay */}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-1.5 bg-black/60 opacity-0 group-hover/card:opacity-100 transition-opacity">
|
||||
{onAdd && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAdd}
|
||||
title="Add to composition at current time"
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-md bg-white text-black text-[10px] font-semibold hover:bg-neutral-200 transition-colors"
|
||||
>
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
{adding ? "Added!" : "Add"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleShowPrompt}
|
||||
title="Generate a prompt to paste into your AI agent"
|
||||
className={`flex items-center gap-1.5 px-3 ${onAdd ? "py-1" : "py-1.5"} rounded-md transition-colors ${
|
||||
onAdd
|
||||
? "bg-white/15 text-white/90 hover:bg-white/25 text-[9px]"
|
||||
: "bg-white text-black hover:bg-neutral-200 text-[10px] font-semibold"
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
width={onAdd ? 9 : 11}
|
||||
height={onAdd ? 9 : 11}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
Ask agent
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Badges */}
|
||||
<div className="absolute top-1 right-1 flex items-center gap-0.5 pointer-events-none">
|
||||
@@ -306,3 +491,93 @@ function BlockCard({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PromptPreviewModal({
|
||||
title,
|
||||
prompt,
|
||||
onClose,
|
||||
}: {
|
||||
title: string;
|
||||
prompt: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [value, setValue] = useState(prompt);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => textareaRef.current?.focus());
|
||||
}, []);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-[560px] max-h-[80vh] flex flex-col rounded-2xl border border-neutral-800 bg-neutral-950 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-neutral-800/60">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-neutral-200">Ask agent</h3>
|
||||
<p className="text-xs text-neutral-500 mt-0.5">{title}</p>
|
||||
</div>
|
||||
<button
|
||||
className="p-1 rounded-md text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800/50"
|
||||
onClick={onClose}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||
<p className="text-[11px] text-neutral-500 mb-2">
|
||||
Edit the prompt below, then copy and paste into your AI agent
|
||||
</p>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) handleCopy();
|
||||
if (e.key === "Escape") onClose();
|
||||
}}
|
||||
className="w-full min-h-[240px] text-[11px] text-neutral-200 leading-relaxed font-mono bg-neutral-900/60 rounded-lg p-3 border border-neutral-800 resize-y focus:outline-none focus:border-studio-accent/60 focus:ring-1 focus:ring-studio-accent/30"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-5 py-3 border-t border-neutral-800/60">
|
||||
<span className="text-[11px] text-neutral-600">
|
||||
{navigator.platform.includes("Mac") ? "⌘" : "Ctrl"}+Enter to copy
|
||||
</span>
|
||||
<button
|
||||
className={`px-4 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||
copied
|
||||
? "bg-emerald-500 text-white"
|
||||
: "bg-studio-accent/90 text-neutral-950 hover:bg-studio-accent"
|
||||
}`}
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? "Copied!" : "Copy prompt"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { trackStudioEvent } from "../../utils/studioTelemetry";
|
||||
import { BlocksTab, type BlockPreviewInfo } from "./BlocksTab";
|
||||
import { FileTree } from "../editor/FileTree";
|
||||
import { STUDIO_BLOCKS_PANEL_ENABLED } from "../editor/manualEditingAvailability";
|
||||
import { Tooltip } from "../ui";
|
||||
|
||||
export type SidebarTab = "compositions" | "assets" | "code" | "blocks";
|
||||
|
||||
@@ -124,51 +125,59 @@ export const LeftSidebar = memo(
|
||||
: "1fr 1fr 1fr",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectTab("code")}
|
||||
className={`rounded-[14px] px-1.5 py-2 text-[10px] font-semibold truncate transition-all ${
|
||||
tab === "code"
|
||||
? "bg-neutral-800 text-white"
|
||||
: "text-neutral-500 hover:text-neutral-200"
|
||||
}`}
|
||||
>
|
||||
Code
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectTab("compositions")}
|
||||
className={`rounded-[14px] px-1.5 py-2 text-[10px] font-semibold truncate transition-all ${
|
||||
tab === "compositions"
|
||||
? "bg-neutral-800 text-white"
|
||||
: "text-neutral-500 hover:text-neutral-200"
|
||||
}`}
|
||||
>
|
||||
Comps
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectTab("assets")}
|
||||
className={`rounded-[14px] px-1.5 py-2 text-[10px] font-semibold truncate transition-all ${
|
||||
tab === "assets"
|
||||
? "bg-neutral-800 text-white"
|
||||
: "text-neutral-500 hover:text-neutral-200"
|
||||
}`}
|
||||
>
|
||||
Assets
|
||||
</button>
|
||||
{STUDIO_BLOCKS_PANEL_ENABLED && (
|
||||
<Tooltip label="Source code editor" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectTab("blocks")}
|
||||
onClick={() => selectTab("code")}
|
||||
className={`rounded-[14px] px-1.5 py-2 text-[10px] font-semibold truncate transition-all ${
|
||||
tab === "blocks"
|
||||
tab === "code"
|
||||
? "bg-neutral-800 text-white"
|
||||
: "text-neutral-500 hover:text-neutral-200"
|
||||
}`}
|
||||
>
|
||||
Catalog
|
||||
Code
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Compositions and sub-compositions" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectTab("compositions")}
|
||||
className={`rounded-[14px] px-1.5 py-2 text-[10px] font-semibold truncate transition-all ${
|
||||
tab === "compositions"
|
||||
? "bg-neutral-800 text-white"
|
||||
: "text-neutral-500 hover:text-neutral-200"
|
||||
}`}
|
||||
>
|
||||
Comps
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Videos, images, audio, fonts" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectTab("assets")}
|
||||
className={`rounded-[14px] px-1.5 py-2 text-[10px] font-semibold truncate transition-all ${
|
||||
tab === "assets"
|
||||
? "bg-neutral-800 text-white"
|
||||
: "text-neutral-500 hover:text-neutral-200"
|
||||
}`}
|
||||
>
|
||||
Assets
|
||||
</button>
|
||||
</Tooltip>
|
||||
{STUDIO_BLOCKS_PANEL_ENABLED && (
|
||||
<Tooltip label="Browse blocks and components" side="bottom">
|
||||
<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"
|
||||
}`}
|
||||
>
|
||||
Catalog
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{onToggleCollapse && (
|
||||
@@ -246,7 +255,7 @@ export const LeftSidebar = memo(
|
||||
</div>
|
||||
)}
|
||||
|
||||
{STUDIO_BLOCKS_PANEL_ENABLED && tab === "blocks" && onAddBlock && (
|
||||
{STUDIO_BLOCKS_PANEL_ENABLED && tab === "blocks" && (
|
||||
<BlocksTab onAddBlock={onAddBlock} onPreviewBlock={onPreviewBlock} />
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useState, useRef, useCallback, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
interface TooltipProps {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
delay?: number;
|
||||
side?: "top" | "bottom";
|
||||
}
|
||||
|
||||
export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipProps) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [pos, setPos] = useState({ x: 0, y: 0 });
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const triggerRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
const show = useCallback(() => {
|
||||
timerRef.current = setTimeout(() => {
|
||||
const el = triggerRef.current;
|
||||
if (!el) return;
|
||||
const child = el.firstElementChild as HTMLElement | null;
|
||||
const rect = (child ?? el).getBoundingClientRect();
|
||||
if (rect.width === 0 && rect.height === 0) return;
|
||||
setPos({
|
||||
x: rect.left + rect.width / 2,
|
||||
y: side === "top" ? rect.top - 6 : rect.bottom + 6,
|
||||
});
|
||||
setVisible(true);
|
||||
}, delay);
|
||||
}, [delay, side]);
|
||||
|
||||
const hide = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
setVisible(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<span ref={triggerRef} onPointerEnter={show} onPointerLeave={hide} className="contents">
|
||||
{children}
|
||||
</span>
|
||||
{visible &&
|
||||
createPortal(
|
||||
<div
|
||||
className="fixed z-[200] pointer-events-none"
|
||||
style={{
|
||||
left: pos.x,
|
||||
top: pos.y,
|
||||
transform: side === "top" ? "translate(-50%, -100%)" : "translate(-50%, 0)",
|
||||
}}
|
||||
>
|
||||
<div className="px-2 py-1 rounded-md bg-neutral-800 border border-neutral-700/50 text-[10px] font-medium text-neutral-200 whitespace-nowrap shadow-lg">
|
||||
{label}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,3 +2,4 @@
|
||||
export { Button, IconButton } from "./Button";
|
||||
export { HyperframesLoader, StatusFrame } from "./HyperframesLoader";
|
||||
export type { HyperframesLoaderProps } from "./HyperframesLoader";
|
||||
export { Tooltip } from "./Tooltip";
|
||||
|
||||
@@ -56,7 +56,11 @@ export function useBlockCatalog() {
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
result = result.filter(
|
||||
(b) => b.title.toLowerCase().includes(q) || b.description.toLowerCase().includes(q),
|
||||
(b) =>
|
||||
b.title.toLowerCase().includes(q) ||
|
||||
b.description.toLowerCase().includes(q) ||
|
||||
b.category.toLowerCase().includes(q) ||
|
||||
b.tags?.some((t) => t.toLowerCase().includes(q)),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { formatFrameTime, frameToSeconds, stepFrameTime, formatTime } from "../l
|
||||
import { shouldMutePreviewAudio } from "../lib/timelineIframeHelpers";
|
||||
import { usePlayerStore, liveTime } from "../store/playerStore";
|
||||
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
||||
import { Tooltip } from "../../components/ui";
|
||||
|
||||
const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
|
||||
const SEEK_EDGE_SNAP_PX = 8;
|
||||
@@ -340,46 +341,51 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
}}
|
||||
>
|
||||
{/* Play/Pause button */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isPlaying ? "Pause" : "Play"}
|
||||
onClick={() => {
|
||||
trackStudioEvent("playback", { action: isPlaying ? "pause" : "play" });
|
||||
onTogglePlay();
|
||||
}}
|
||||
disabled={controlsDisabled}
|
||||
className="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-lg disabled:opacity-30 disabled:pointer-events-none transition-colors"
|
||||
style={{ background: "rgba(255,255,255,0.06)" }}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="#FAFAFA" aria-hidden="true">
|
||||
<rect x="6" y="4" width="4" height="16" rx="1" />
|
||||
<rect x="14" y="4" width="4" height="16" rx="1" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="#FAFAFA" aria-hidden="true">
|
||||
<polygon points="6,3 20,12 6,21" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<Tooltip label={isPlaying ? "Pause" : "Play"}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isPlaying ? "Pause" : "Play"}
|
||||
onClick={() => {
|
||||
trackStudioEvent("playback", { action: isPlaying ? "pause" : "play" });
|
||||
onTogglePlay();
|
||||
}}
|
||||
disabled={controlsDisabled}
|
||||
className="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-lg disabled:opacity-30 disabled:pointer-events-none transition-colors"
|
||||
style={{ background: "rgba(255,255,255,0.06)" }}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="#FAFAFA" aria-hidden="true">
|
||||
<rect x="6" y="4" width="4" height="16" rx="1" />
|
||||
<rect x="14" y="4" width="4" height="16" rx="1" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="#FAFAFA" aria-hidden="true">
|
||||
<polygon points="6,3 20,12 6,21" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Time display — click to toggle time/frame mode */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTimeDisplayMode((m) => (m === "time" ? "frame" : "time"))}
|
||||
disabled={disabled}
|
||||
title={timeDisplayMode === "time" ? "Switch to frame display" : "Switch to time display"}
|
||||
className="font-mono text-[11px] tabular-nums flex-shrink-0 w-[118px] text-left transition-colors disabled:pointer-events-none hover:opacity-80"
|
||||
style={{ color: "#A1A1AA", cursor: "pointer" }}
|
||||
<Tooltip
|
||||
label={timeDisplayMode === "time" ? "Switch to frame display" : "Switch to time display"}
|
||||
>
|
||||
<span ref={timeDisplayRef}>{formatTime(0)}</span>
|
||||
{timeDisplayMode === "time" ? (
|
||||
<>
|
||||
<span style={{ color: "#3F3F46", margin: "0 2px" }}>/</span>
|
||||
<span style={{ color: "#52525B" }}>{formatTime(duration)}</span>
|
||||
</>
|
||||
) : null}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTimeDisplayMode((m) => (m === "time" ? "frame" : "time"))}
|
||||
disabled={disabled}
|
||||
className="font-mono text-[11px] tabular-nums flex-shrink-0 w-[118px] text-left transition-colors disabled:pointer-events-none hover:opacity-80"
|
||||
style={{ color: "#A1A1AA", cursor: "pointer" }}
|
||||
>
|
||||
<span ref={timeDisplayRef}>{formatTime(0)}</span>
|
||||
{timeDisplayMode === "time" ? (
|
||||
<>
|
||||
<span style={{ color: "#3F3F46", margin: "0 2px" }}>/</span>
|
||||
<span style={{ color: "#52525B" }}>{formatTime(duration)}</span>
|
||||
</>
|
||||
) : null}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Seek bar — teal progress fill */}
|
||||
<div
|
||||
@@ -469,70 +475,73 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
</div>
|
||||
|
||||
{/* Mute toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!audioAutoMuted) {
|
||||
trackStudioEvent("playback", { action: "mute_toggle", muted: !audioMuted });
|
||||
setAudioMuted(!audioMuted);
|
||||
}
|
||||
}}
|
||||
disabled={controlsDisabled || audioAutoMuted}
|
||||
title={muteButtonLabel}
|
||||
aria-label={muteButtonLabel}
|
||||
aria-pressed={effectiveAudioMuted}
|
||||
className={`h-7 w-7 flex-shrink-0 flex items-center justify-center rounded-md border transition-colors disabled:pointer-events-none ${
|
||||
effectiveAudioMuted
|
||||
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
||||
: "border-neutral-700 text-neutral-400 hover:border-neutral-500 hover:bg-neutral-800"
|
||||
} ${audioAutoMuted ? "opacity-70" : ""}`}
|
||||
>
|
||||
{effectiveAudioMuted ? (
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M11 5 6 9H3v6h3l5 4V5Z" />
|
||||
<path d="m19 9-6 6" />
|
||||
<path d="m13 9 6 6" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M11 5 6 9H3v6h3l5 4V5Z" />
|
||||
<path d="M15.5 8.5a5 5 0 0 1 0 7" />
|
||||
<path d="M18.5 5.5a9 9 0 0 1 0 13" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<Tooltip label={muteButtonLabel}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!audioAutoMuted) {
|
||||
trackStudioEvent("playback", { action: "mute_toggle", muted: !audioMuted });
|
||||
setAudioMuted(!audioMuted);
|
||||
}
|
||||
}}
|
||||
disabled={controlsDisabled || audioAutoMuted}
|
||||
aria-label={muteButtonLabel}
|
||||
aria-pressed={effectiveAudioMuted}
|
||||
className={`h-7 w-7 flex-shrink-0 flex items-center justify-center rounded-md border transition-colors disabled:pointer-events-none ${
|
||||
effectiveAudioMuted
|
||||
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
||||
: "border-neutral-700 text-neutral-400 hover:border-neutral-500 hover:bg-neutral-800"
|
||||
} ${audioAutoMuted ? "opacity-70" : ""}`}
|
||||
>
|
||||
{effectiveAudioMuted ? (
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M11 5 6 9H3v6h3l5 4V5Z" />
|
||||
<path d="m19 9-6 6" />
|
||||
<path d="m13 9 6 6" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M11 5 6 9H3v6h3l5 4V5Z" />
|
||||
<path d="M15.5 8.5a5 5 0 0 1 0 7" />
|
||||
<path d="M18.5 5.5a9 9 0 0 1 0 13" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Speed control */}
|
||||
<div ref={speedMenuContainerRef} className="relative flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSpeedMenu((v) => !v)}
|
||||
disabled={disabled}
|
||||
className="w-10 px-2 py-1 rounded-md text-[10px] font-mono tabular-nums transition-colors"
|
||||
style={{ color: "#71717A", background: "rgba(255,255,255,0.04)" }}
|
||||
>
|
||||
{playbackRate === 1 ? "1x" : `${playbackRate}x`}
|
||||
</button>
|
||||
<Tooltip label="Playback speed">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSpeedMenu((v) => !v)}
|
||||
disabled={disabled}
|
||||
className="w-10 px-2 py-1 rounded-md text-[10px] font-mono tabular-nums transition-colors"
|
||||
style={{ color: "#71717A", background: "rgba(255,255,255,0.04)" }}
|
||||
>
|
||||
{playbackRate === 1 ? "1x" : `${playbackRate}x`}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{showSpeedMenu && (
|
||||
<div
|
||||
className="absolute bottom-full right-0 mb-1.5 rounded-lg shadow-xl z-50 min-w-[56px] overflow-hidden"
|
||||
@@ -566,122 +575,126 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
trackStudioEvent("playback", { action: "loop_toggle", enabled: !loopEnabled });
|
||||
setLoopEnabled(!loopEnabled);
|
||||
}}
|
||||
disabled={disabled}
|
||||
className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
|
||||
loopEnabled
|
||||
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
||||
: "border-neutral-700 text-neutral-400 hover:border-neutral-500 hover:bg-neutral-800"
|
||||
}`}
|
||||
title="Loop playback"
|
||||
aria-label={loopEnabled ? "Disable loop playback" : "Enable loop playback"}
|
||||
aria-pressed={loopEnabled}
|
||||
>
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M17 2l4 4-4 4" />
|
||||
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
|
||||
<path d="M7 22l-4-4 4-4" />
|
||||
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Fullscreen toggle */}
|
||||
{onToggleFullscreen && (
|
||||
<Tooltip label="Loop playback">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
trackStudioEvent("playback", { action: "fullscreen_toggle", active: !isFullscreen });
|
||||
onToggleFullscreen();
|
||||
trackStudioEvent("playback", { action: "loop_toggle", enabled: !loopEnabled });
|
||||
setLoopEnabled(!loopEnabled);
|
||||
}}
|
||||
className={`h-7 w-7 flex-shrink-0 flex items-center justify-center rounded-md border transition-colors ${
|
||||
isFullscreen
|
||||
disabled={disabled}
|
||||
className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
|
||||
loopEnabled
|
||||
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
||||
: "border-neutral-700 text-neutral-400 hover:border-neutral-500 hover:bg-neutral-800"
|
||||
}`}
|
||||
title={isFullscreen ? "Exit fullscreen (F)" : "Enter fullscreen (F)"}
|
||||
aria-label={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M8 3v3a2 2 0 0 1-2 2H3" />
|
||||
<path d="M21 8h-3a2 2 0 0 1-2-2V3" />
|
||||
<path d="M3 16h3a2 2 0 0 1 2 2v3" />
|
||||
<path d="M16 21v-3a2 2 0 0 1 2-2h3" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M8 3H5a2 2 0 0 0-2 2v3" />
|
||||
<path d="M21 8V5a2 2 0 0 0-2-2h-3" />
|
||||
<path d="M3 16v3a2 2 0 0 0 2 2h3" />
|
||||
<path d="M16 21h3a2 2 0 0 0 2-2v-3" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Keyboard shortcuts + frame jump + work area — click to open panel */}
|
||||
<div ref={shortcutsPanelRef} className="relative flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowShortcuts((v) => !v)}
|
||||
className={`w-6 h-6 flex items-center justify-center rounded border transition-colors ${
|
||||
showShortcuts
|
||||
? "border-neutral-600 text-neutral-200 bg-neutral-800"
|
||||
: "border-neutral-800 text-neutral-600 hover:text-neutral-300 hover:border-neutral-600"
|
||||
}`}
|
||||
aria-label="Shortcuts and tools"
|
||||
aria-expanded={showShortcuts}
|
||||
aria-label={loopEnabled ? "Disable loop playback" : "Enable loop playback"}
|
||||
aria-pressed={loopEnabled}
|
||||
>
|
||||
<svg
|
||||
width="11"
|
||||
height="11"
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||
<path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8" />
|
||||
<path d="M17 2l4 4-4 4" />
|
||||
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
|
||||
<path d="M7 22l-4-4 4-4" />
|
||||
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Fullscreen toggle */}
|
||||
{onToggleFullscreen && (
|
||||
<Tooltip label={isFullscreen ? "Exit fullscreen (F)" : "Enter fullscreen (F)"}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
trackStudioEvent("playback", { action: "fullscreen_toggle", active: !isFullscreen });
|
||||
onToggleFullscreen();
|
||||
}}
|
||||
className={`h-7 w-7 flex-shrink-0 flex items-center justify-center rounded-md border transition-colors ${
|
||||
isFullscreen
|
||||
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
||||
: "border-neutral-700 text-neutral-400 hover:border-neutral-500 hover:bg-neutral-800"
|
||||
}`}
|
||||
aria-label={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M8 3v3a2 2 0 0 1-2 2H3" />
|
||||
<path d="M21 8h-3a2 2 0 0 1-2-2V3" />
|
||||
<path d="M3 16h3a2 2 0 0 1 2 2v3" />
|
||||
<path d="M16 21v-3a2 2 0 0 1 2-2h3" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M8 3H5a2 2 0 0 0-2 2v3" />
|
||||
<path d="M21 8V5a2 2 0 0 0-2-2h-3" />
|
||||
<path d="M3 16v3a2 2 0 0 0 2 2h3" />
|
||||
<path d="M16 21h3a2 2 0 0 0 2-2v-3" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Keyboard shortcuts + frame jump + work area — click to open panel */}
|
||||
<div ref={shortcutsPanelRef} className="relative flex-shrink-0">
|
||||
<Tooltip label="Shortcuts and tools">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowShortcuts((v) => !v)}
|
||||
className={`w-6 h-6 flex items-center justify-center rounded border transition-colors ${
|
||||
showShortcuts
|
||||
? "border-neutral-600 text-neutral-200 bg-neutral-800"
|
||||
: "border-neutral-800 text-neutral-600 hover:text-neutral-300 hover:border-neutral-600"
|
||||
}`}
|
||||
aria-label="Shortcuts and tools"
|
||||
aria-expanded={showShortcuts}
|
||||
>
|
||||
<svg
|
||||
width="11"
|
||||
height="11"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||
<path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8" />
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{showShortcuts && (
|
||||
<div
|
||||
className="absolute bottom-full right-0 mb-2 z-50 rounded-lg shadow-xl min-w-[220px] overflow-y-auto"
|
||||
@@ -709,13 +722,15 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
onKeyDown={handleJumpKeyDown}
|
||||
onBlur={commitJumpFrame}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={disabled}
|
||||
className="h-6 px-2 rounded border border-neutral-700 text-[10px] text-neutral-300 transition-colors hover:border-neutral-500 hover:bg-neutral-800 disabled:opacity-40"
|
||||
>
|
||||
Go
|
||||
</button>
|
||||
<Tooltip label="Jump to frame">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={disabled}
|
||||
className="h-6 px-2 rounded border border-neutral-700 text-[10px] text-neutral-300 transition-colors hover:border-neutral-500 hover:bg-neutral-800 disabled:opacity-40"
|
||||
>
|
||||
Go
|
||||
</button>
|
||||
</Tooltip>
|
||||
</form>
|
||||
</div>
|
||||
<div style={{ borderTop: "1px solid rgba(255,255,255,0.06)" }} />
|
||||
@@ -741,23 +756,25 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
<span className="font-mono text-[10px] text-neutral-300">
|
||||
{formatTime(inPoint)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setInPoint(null)}
|
||||
className="w-4 h-4 flex items-center justify-center rounded text-neutral-500 hover:text-neutral-200 transition-colors"
|
||||
aria-label="Clear in-point"
|
||||
>
|
||||
<svg
|
||||
width="8"
|
||||
height="8"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
<Tooltip label="Clear in-point">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setInPoint(null)}
|
||||
className="w-4 h-4 flex items-center justify-center rounded text-neutral-500 hover:text-neutral-200 transition-colors"
|
||||
aria-label="Clear in-point"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<svg
|
||||
width="8"
|
||||
height="8"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-[10px] text-neutral-600">—</span>
|
||||
@@ -780,23 +797,25 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
<span className="font-mono text-[10px] text-neutral-300">
|
||||
{formatTime(outPoint)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOutPoint(null)}
|
||||
className="w-4 h-4 flex items-center justify-center rounded text-neutral-500 hover:text-neutral-200 transition-colors"
|
||||
aria-label="Clear out-point"
|
||||
>
|
||||
<svg
|
||||
width="8"
|
||||
height="8"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
<Tooltip label="Clear out-point">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOutPoint(null)}
|
||||
className="w-4 h-4 flex items-center justify-center rounded text-neutral-500 hover:text-neutral-200 transition-colors"
|
||||
aria-label="Clear out-point"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<svg
|
||||
width="8"
|
||||
height="8"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-[10px] text-neutral-600">—</span>
|
||||
|
||||
@@ -18,13 +18,13 @@ describe("wrapTimeline seek keepPlaying option (#834)", () => {
|
||||
};
|
||||
}
|
||||
|
||||
it("default seek pauses the GSAP timeline before seeking", () => {
|
||||
it("default seek pauses the GSAP timeline before and after seeking", () => {
|
||||
const tl = mockTimeline();
|
||||
const adapter = wrapTimeline(tl);
|
||||
|
||||
adapter.seek(5);
|
||||
|
||||
expect(tl.pause).toHaveBeenCalledTimes(1);
|
||||
expect(tl.pause).toHaveBeenCalledTimes(2);
|
||||
expect(tl.seek).toHaveBeenCalledWith(5);
|
||||
});
|
||||
|
||||
@@ -44,7 +44,7 @@ describe("wrapTimeline seek keepPlaying option (#834)", () => {
|
||||
|
||||
adapter.seek(5, { keepPlaying: false });
|
||||
|
||||
expect(tl.pause).toHaveBeenCalledTimes(1);
|
||||
expect(tl.pause).toHaveBeenCalledTimes(2);
|
||||
expect(tl.seek).toHaveBeenCalledWith(5);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -135,8 +135,10 @@ export function wrapTimeline(tl: TimelineLike): PlaybackAdapter {
|
||||
play: () => tl.play(),
|
||||
pause: () => tl.pause(),
|
||||
seek: (t, options) => {
|
||||
if (!options?.keepPlaying) tl.pause();
|
||||
const shouldPause = !options?.keepPlaying;
|
||||
if (shouldPause) tl.pause();
|
||||
tl.seek(t);
|
||||
if (shouldPause) tl.pause();
|
||||
},
|
||||
getTime: () => tl.time(),
|
||||
getDuration: () => tl.duration(),
|
||||
|
||||
@@ -155,6 +155,7 @@ describe("insertTimelineAssetIntoSource", () => {
|
||||
'<img id="photo_asset" data-start="0" data-duration="3" />',
|
||||
);
|
||||
|
||||
expect(html).toContain('<div id="root" data-composition-id="main"><img id="photo_asset"');
|
||||
expect(html).toContain('data-composition-id="main">');
|
||||
expect(html).toContain('<img id="photo_asset"');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user