mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
initial code (#2)
* feat: initial code port from hyperframes-internal Port all OSS-ready packages from the internal monorepo: - @hyperframes/core — shared types, HTML generation, GSAP utilities, runtime - @hyperframes/cli — CLI for creating, previewing, and rendering compositions - @hyperframes/engine — framework-agnostic rendering engine (BeginFrame + FFmpeg) - @hyperframes/producer — video rendering pipeline (Puppeteer + FFmpeg) - @hyperframes/ui-player — browser-based video player component - @hyperframes/studio — composition editor (React frontend + Hono backend) Includes regression test suite with Docker-based test harness. All HeyGen-internal references, deployment infrastructure, and proprietary assets have been removed. Package names migrated from @app/* to @hyperframes/*. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: scrub internal codenames and stale references from OSS port - Replace static.heygen.ai runtime URLs in test fixtures - Remove internal CDN publish script (publish-hyperframe-runtime.ts) - Replace sandbox-studio, sandbox-interceptor, __magicEditRuntime with neutral names (studio, hyperframe-runtime, __hyperframeRuntime) - Fix stale Vault API / localhost references in docs - Remove broken deprecated_studio link Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove remaining internal codenames and stale references - Delete stale producer README.md and PIPELINE.md (referenced nonexistent files) - Replace "Cerberus" codename with "HyperFrames" in test design reviews - Replace magic-edit postMessage identifiers with hf-preview/hf-parent - Rename debug-magic-edit-timeline.ts to debug-timeline.ts - Replace "Motion Cut" with "HyperFrames" in Timeline comments - Fix studio/CLI references to nonexistent archive package (use local data/projects/ dir, stub render proxy) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
10621e7903
commit
9f8e5ba5a1
@@ -0,0 +1,70 @@
|
||||
import { memo } from "react";
|
||||
import { FileCode, Image, Film, Music, File } from "../../icons/SystemIcons";
|
||||
|
||||
interface FileTreeProps {
|
||||
files: string[];
|
||||
activeFile: string | null;
|
||||
onSelectFile: (path: string) => void;
|
||||
}
|
||||
|
||||
const FILE_ICONS: Record<string, { icon: typeof File; color: string }> = {
|
||||
html: { icon: FileCode, color: "#3B82F6" },
|
||||
css: { icon: FileCode, color: "#A855F7" },
|
||||
js: { icon: FileCode, color: "#F59E0B" },
|
||||
ts: { icon: FileCode, color: "#3B82F6" },
|
||||
json: { icon: File, color: "#22C55E" },
|
||||
png: { icon: Image, color: "#22C55E" },
|
||||
jpg: { icon: Image, color: "#22C55E" },
|
||||
svg: { icon: Image, color: "#F97316" },
|
||||
mp4: { icon: Film, color: "#A855F7" },
|
||||
mp3: { icon: Music, color: "#F59E0B" },
|
||||
wav: { icon: Music, color: "#F59E0B" },
|
||||
};
|
||||
|
||||
function getFileIcon(path: string) {
|
||||
const ext = path.split(".").pop()?.toLowerCase() ?? "";
|
||||
return FILE_ICONS[ext] ?? { icon: File, color: "#737373" };
|
||||
}
|
||||
|
||||
export const FileTree = memo(function FileTree({ files, activeFile, onSelectFile }: FileTreeProps) {
|
||||
const sorted = [...files].sort((a, b) => {
|
||||
// index.html first, then alphabetical
|
||||
if (a === "index.html") return -1;
|
||||
if (b === "index.html") return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="px-2.5 py-1.5 border-b border-neutral-800 flex-shrink-0">
|
||||
<span className="text-2xs font-medium text-neutral-500 uppercase tracking-caps">Files</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto py-1">
|
||||
{sorted.map((path) => {
|
||||
const { icon: Icon, color } = getFileIcon(path);
|
||||
const isActive = path === activeFile;
|
||||
const name = path.split("/").pop() ?? path;
|
||||
const dir = path.includes("/") ? path.split("/").slice(0, -1).join("/") + "/" : "";
|
||||
|
||||
return (
|
||||
<button
|
||||
key={path}
|
||||
onClick={() => onSelectFile(path)}
|
||||
className={`w-full flex items-center gap-2 px-2.5 py-1 min-h-7 text-left transition-all duration-press text-xs ${
|
||||
isActive
|
||||
? "bg-neutral-800/60 text-neutral-200"
|
||||
: "text-neutral-500 hover:bg-neutral-800/30 hover:text-neutral-300 active:scale-[0.98]"
|
||||
}`}
|
||||
>
|
||||
<Icon size={12} style={{ color }} className="flex-shrink-0" />
|
||||
<span className="truncate">
|
||||
{dir && <span className="text-neutral-600">{dir}</span>}
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import { memo } from "react";
|
||||
import { X, MousePointer, Move, Type, Palette, Clock, Eye } from "../../icons/SystemIcons";
|
||||
import { Button, IconButton } from "../ui";
|
||||
import type { PickedElement } from "../../hooks/useElementPicker";
|
||||
|
||||
interface PropertyPanelProps {
|
||||
element: PickedElement | null;
|
||||
isPickMode: boolean;
|
||||
onEnablePick: () => void;
|
||||
onDisablePick: () => void;
|
||||
onClearPick: () => void;
|
||||
onSetStyle: (prop: string, value: string) => void;
|
||||
onSetDataAttr: (attr: string, value: string) => void;
|
||||
onSetText?: (text: string) => void;
|
||||
}
|
||||
|
||||
function PropertyRow({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xs text-neutral-600 w-16 flex-shrink-0 text-right">{label}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="flex-1 bg-neutral-900 border border-neutral-800 rounded px-1.5 py-0.5 text-2xs text-neutral-200 font-mono outline-none focus:border-neutral-600 min-w-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorRow({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xs text-neutral-600 w-16 flex-shrink-0 text-right">{label}</span>
|
||||
<div className="flex items-center gap-1 flex-1">
|
||||
<div className="w-5 h-5 rounded border border-neutral-700 flex-shrink-0" style={{ backgroundColor: value }} />
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="flex-1 bg-neutral-900 border border-neutral-800 rounded px-1.5 py-0.5 text-2xs text-neutral-200 font-mono outline-none focus:border-neutral-600 min-w-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeader({ icon: Icon, label }: { icon: typeof Move; label: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 mt-2 mb-1">
|
||||
<Icon size={10} className="text-neutral-600" />
|
||||
<span className="text-2xs font-medium text-neutral-500 uppercase tracking-wider">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const PropertyPanel = memo(function PropertyPanel({
|
||||
element,
|
||||
isPickMode,
|
||||
onEnablePick,
|
||||
onDisablePick,
|
||||
onClearPick,
|
||||
onSetStyle,
|
||||
onSetDataAttr,
|
||||
onSetText,
|
||||
}: PropertyPanelProps) {
|
||||
if (!element) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full px-4 text-center">
|
||||
<MousePointer size={20} className="text-neutral-700 mb-2" />
|
||||
<p className="text-xs text-neutral-500">Click an element in the preview to inspect it</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={isPickMode ? onDisablePick : onEnablePick}
|
||||
className={`mt-3 ${isPickMode ? "bg-blue-500/20 text-blue-400 border-blue-500/30" : ""}`}
|
||||
>
|
||||
{isPickMode ? "Pick mode active..." : "Enable Pick Mode"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const s = element.computedStyles;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-neutral-800 flex-shrink-0">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="text-2xs font-mono text-blue-400 truncate">{element.selector}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<IconButton
|
||||
icon={<MousePointer size={11} />}
|
||||
aria-label={isPickMode ? "Disable pick mode" : "Enable pick mode"}
|
||||
size="sm"
|
||||
onClick={isPickMode ? onDisablePick : onEnablePick}
|
||||
className={isPickMode ? "text-blue-400 bg-blue-500/10" : ""}
|
||||
/>
|
||||
<IconButton icon={<X size={11} />} aria-label="Clear selection" size="sm" onClick={onClearPick} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Properties */}
|
||||
<div className="flex-1 overflow-y-auto px-3 py-2 space-y-1">
|
||||
{/* Element info */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-2xs text-neutral-300 font-medium">{element.label}</span>
|
||||
<span className="text-2xs text-neutral-600 font-mono"><{element.tagName}></span>
|
||||
</div>
|
||||
|
||||
{/* Position & Size */}
|
||||
<SectionHeader icon={Move} label="Position & Size" />
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<PropertyRow label="X" value={s["left"] ?? "auto"} onChange={(v) => onSetStyle("left", v)} />
|
||||
<PropertyRow label="Y" value={s["top"] ?? "auto"} onChange={(v) => onSetStyle("top", v)} />
|
||||
<PropertyRow label="W" value={s["width"] ?? "auto"} onChange={(v) => onSetStyle("width", v)} />
|
||||
<PropertyRow label="H" value={s["height"] ?? "auto"} onChange={(v) => onSetStyle("height", v)} />
|
||||
</div>
|
||||
|
||||
{/* Typography */}
|
||||
{(element.tagName === "div" ||
|
||||
element.tagName === "span" ||
|
||||
element.tagName === "p" ||
|
||||
element.tagName === "h1" ||
|
||||
element.tagName === "h2") && (
|
||||
<>
|
||||
<SectionHeader icon={Type} label="Typography" />
|
||||
<PropertyRow label="Size" value={s["font-size"] ?? ""} onChange={(v) => onSetStyle("font-size", v)} />
|
||||
<PropertyRow label="Weight" value={s["font-weight"] ?? ""} onChange={(v) => onSetStyle("font-weight", v)} />
|
||||
<PropertyRow
|
||||
label="Family"
|
||||
value={s["font-family"]?.split(",")[0] ?? ""}
|
||||
onChange={(v) => onSetStyle("font-family", v)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Colors */}
|
||||
<SectionHeader icon={Palette} label="Colors" />
|
||||
<ColorRow label="Color" value={s["color"] ?? "#fff"} onChange={(v) => onSetStyle("color", v)} />
|
||||
<ColorRow
|
||||
label="Background"
|
||||
value={s["background-color"] ?? "transparent"}
|
||||
onChange={(v) => onSetStyle("background-color", v)}
|
||||
/>
|
||||
|
||||
{/* Appearance */}
|
||||
<SectionHeader icon={Eye} label="Appearance" />
|
||||
<PropertyRow label="Opacity" value={s["opacity"] ?? "1"} onChange={(v) => onSetStyle("opacity", v)} />
|
||||
<PropertyRow
|
||||
label="Radius"
|
||||
value={s["border-radius"] ?? "0"}
|
||||
onChange={(v) => onSetStyle("border-radius", v)}
|
||||
/>
|
||||
<PropertyRow label="Z-index" value={s["z-index"] ?? "auto"} onChange={(v) => onSetStyle("z-index", v)} />
|
||||
<PropertyRow label="Transform" value={s["transform"] ?? "none"} onChange={(v) => onSetStyle("transform", v)} />
|
||||
|
||||
{/* Timing */}
|
||||
{(element.dataAttributes["start"] || element.dataAttributes["duration"]) && (
|
||||
<>
|
||||
<SectionHeader icon={Clock} label="Timing" />
|
||||
{element.dataAttributes["start"] != null && (
|
||||
<PropertyRow
|
||||
label="Start"
|
||||
value={element.dataAttributes["start"]}
|
||||
onChange={(v) => onSetDataAttr("start", v)}
|
||||
/>
|
||||
)}
|
||||
{element.dataAttributes["duration"] != null && (
|
||||
<PropertyRow
|
||||
label="Duration"
|
||||
value={element.dataAttributes["duration"]}
|
||||
onChange={(v) => onSetDataAttr("duration", v)}
|
||||
/>
|
||||
)}
|
||||
{element.dataAttributes["track-index"] != null && (
|
||||
<PropertyRow
|
||||
label="Track"
|
||||
value={element.dataAttributes["track-index"]}
|
||||
onChange={(v) => onSetDataAttr("track-index", v)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Editable text content */}
|
||||
{element.textContent && (
|
||||
<>
|
||||
<SectionHeader icon={Type} label="Text Content" />
|
||||
<textarea
|
||||
defaultValue={element.textContent}
|
||||
onBlur={(e) => onSetText?.(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && e.metaKey) {
|
||||
(e.target as HTMLTextAreaElement).blur();
|
||||
}
|
||||
}}
|
||||
rows={3}
|
||||
className="w-full bg-neutral-900 border border-neutral-800 rounded px-2 py-1.5 text-xs text-neutral-200 outline-none focus:border-neutral-600 resize-y leading-relaxed"
|
||||
placeholder="Edit text..."
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useRef, useCallback, memo } from "react";
|
||||
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter } from "@codemirror/view";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
|
||||
import { bracketMatching, foldGutter, indentOnInput } from "@codemirror/language";
|
||||
import { closeBrackets, closeBracketsKeymap } from "@codemirror/autocomplete";
|
||||
import { highlightSelectionMatches, searchKeymap } from "@codemirror/search";
|
||||
import { oneDark } from "@codemirror/theme-one-dark";
|
||||
import { html } from "@codemirror/lang-html";
|
||||
import { css } from "@codemirror/lang-css";
|
||||
import { javascript } from "@codemirror/lang-javascript";
|
||||
|
||||
function getLanguageExtension(language: string) {
|
||||
switch (language) {
|
||||
case "html":
|
||||
return html();
|
||||
case "css":
|
||||
return css();
|
||||
case "javascript":
|
||||
case "js":
|
||||
case "typescript":
|
||||
case "ts":
|
||||
return javascript({ typescript: language === "typescript" || language === "ts" });
|
||||
default:
|
||||
return html();
|
||||
}
|
||||
}
|
||||
|
||||
function detectLanguage(filePath: string): string {
|
||||
const ext = filePath.split(".").pop()?.toLowerCase() ?? "";
|
||||
const map: Record<string, string> = {
|
||||
html: "html",
|
||||
htm: "html",
|
||||
css: "css",
|
||||
js: "javascript",
|
||||
ts: "typescript",
|
||||
jsx: "javascript",
|
||||
tsx: "typescript",
|
||||
json: "javascript",
|
||||
};
|
||||
return map[ext] ?? "html";
|
||||
}
|
||||
|
||||
interface SourceEditorProps {
|
||||
content: string;
|
||||
filePath?: string;
|
||||
language?: string;
|
||||
onChange?: (content: string) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export const SourceEditor = memo(function SourceEditor({
|
||||
content,
|
||||
filePath,
|
||||
language,
|
||||
onChange,
|
||||
readOnly = false,
|
||||
}: SourceEditorProps) {
|
||||
const editorRef = useRef<EditorView | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
const mountEditor = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.destroy();
|
||||
editorRef.current = null;
|
||||
}
|
||||
if (!node) return;
|
||||
containerRef.current = node;
|
||||
|
||||
const lang = language ?? (filePath ? detectLanguage(filePath) : "html");
|
||||
|
||||
const updateListener = EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged && onChangeRef.current) {
|
||||
onChangeRef.current(update.state.doc.toString());
|
||||
}
|
||||
});
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: content,
|
||||
extensions: [
|
||||
lineNumbers(),
|
||||
highlightActiveLine(),
|
||||
highlightActiveLineGutter(),
|
||||
history(),
|
||||
foldGutter(),
|
||||
indentOnInput(),
|
||||
bracketMatching(),
|
||||
closeBrackets(),
|
||||
highlightSelectionMatches(),
|
||||
keymap.of([
|
||||
...closeBracketsKeymap,
|
||||
...defaultKeymap,
|
||||
...searchKeymap,
|
||||
...historyKeymap,
|
||||
]),
|
||||
getLanguageExtension(lang),
|
||||
oneDark,
|
||||
updateListener,
|
||||
EditorState.readOnly.of(readOnly),
|
||||
EditorView.theme({
|
||||
"&": { height: "100%" },
|
||||
".cm-scroller": { overflow: "auto" },
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
editorRef.current = new EditorView({ state, parent: node });
|
||||
},
|
||||
[content, filePath, language, readOnly],
|
||||
);
|
||||
|
||||
return <div ref={mountEditor} className="h-full w-full overflow-hidden" />;
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ArrowLeft, CaretRight } from "@phosphor-icons/react";
|
||||
|
||||
export interface CompositionLevel {
|
||||
/** Unique id — "master" or composition file path */
|
||||
id: string;
|
||||
/** Display label — "Master" or filename without extension */
|
||||
label: string;
|
||||
/** Preview URL for this composition level */
|
||||
previewUrl: string;
|
||||
}
|
||||
|
||||
interface CompositionBreadcrumbProps {
|
||||
stack: CompositionLevel[];
|
||||
onNavigate: (index: number) => void;
|
||||
}
|
||||
|
||||
export function CompositionBreadcrumb({ stack, onNavigate }: CompositionBreadcrumbProps) {
|
||||
if (stack.length <= 1) return null;
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Composition navigation"
|
||||
className="flex items-center gap-1 px-2 h-8 border-b border-neutral-800/50 bg-neutral-900/50 flex-shrink-0"
|
||||
>
|
||||
{/* Back button — always goes to parent */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigate(stack.length - 2)}
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-xs text-neutral-400 hover:text-white hover:bg-neutral-800 transition-colors"
|
||||
title="Back (Esc)"
|
||||
>
|
||||
<ArrowLeft size={12} weight="bold" />
|
||||
</button>
|
||||
|
||||
{/* Breadcrumb path */}
|
||||
{stack.map((level, i) => {
|
||||
const isLast = i === stack.length - 1;
|
||||
return (
|
||||
<span key={level.id} className="flex items-center gap-1">
|
||||
{i > 0 && <CaretRight size={10} className="text-neutral-600 flex-shrink-0" />}
|
||||
{isLast ? (
|
||||
<span className="text-xs text-neutral-200 font-medium">{level.label}</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigate(i)}
|
||||
className="text-xs text-neutral-500 hover:text-neutral-200 transition-colors"
|
||||
>
|
||||
{level.label}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { useState, useEffect, useCallback, useRef, memo, type ReactNode } from "react";
|
||||
import { useTimelinePlayer, PlayerControls, Timeline, usePlayerStore } from "../../player";
|
||||
import type { TimelineElement } from "../../player";
|
||||
import { NLEPreview } from "./NLEPreview";
|
||||
import { CompositionBreadcrumb, type CompositionLevel } from "./CompositionBreadcrumb";
|
||||
|
||||
interface NLELayoutProps {
|
||||
projectId: string;
|
||||
portrait?: boolean;
|
||||
/** Slot for overlays rendered on top of the preview (cursors, highlights, etc.) */
|
||||
previewOverlay?: ReactNode;
|
||||
/** Slot rendered below the timeline tracks (e.g., agent activity swim lanes) */
|
||||
timelineFooter?: ReactNode;
|
||||
/** Increment to force the preview to reload (e.g., after file writes) */
|
||||
refreshKey?: number;
|
||||
/** Navigate to a specific composition path (e.g., "compositions/intro.html") */
|
||||
activeCompositionPath?: string | null;
|
||||
/** Callback to expose the iframe ref (for element picker, etc.) */
|
||||
onIframeRef?: (iframe: HTMLIFrameElement | null) => void;
|
||||
}
|
||||
|
||||
const MIN_TIMELINE_H = 100;
|
||||
const DEFAULT_TIMELINE_H = 220;
|
||||
const MIN_PREVIEW_H = 120;
|
||||
|
||||
export const NLELayout = memo(function NLELayout({
|
||||
projectId,
|
||||
portrait,
|
||||
previewOverlay,
|
||||
timelineFooter,
|
||||
refreshKey,
|
||||
activeCompositionPath,
|
||||
onIframeRef,
|
||||
}: NLELayoutProps) {
|
||||
const { iframeRef, togglePlay, seek, onIframeLoad: baseOnIframeLoad, saveSeekPosition } = useTimelinePlayer();
|
||||
|
||||
// Preserve seek position when refreshKey changes (iframe will remount via key prop).
|
||||
const prevRefreshKeyRef = useRef(refreshKey);
|
||||
if (refreshKey !== prevRefreshKeyRef.current) {
|
||||
prevRefreshKeyRef.current = refreshKey;
|
||||
saveSeekPosition();
|
||||
}
|
||||
|
||||
// Wrap onIframeLoad to also notify parent of iframe ref
|
||||
const onIframeLoad = useCallback(() => {
|
||||
baseOnIframeLoad();
|
||||
onIframeRef?.(iframeRef.current);
|
||||
}, [baseOnIframeLoad, iframeRef, onIframeRef]);
|
||||
|
||||
// Composition ID → actual file path mapping, built from the raw index.html
|
||||
const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
|
||||
useEffect(() => {
|
||||
fetch(`/api/projects/${projectId}/files/index.html`)
|
||||
.then((r) => r.json())
|
||||
.then((data: { content?: string }) => {
|
||||
const html = data.content || "";
|
||||
const map = new Map<string, string>();
|
||||
const re = /data-composition-id=["']([^"']+)["'][^>]*data-composition-src=["']([^"']+)["']|data-composition-src=["']([^"']+)["'][^>]*data-composition-id=["']([^"']+)["']/g;
|
||||
let match;
|
||||
while ((match = re.exec(html)) !== null) {
|
||||
const id = match[1] || match[4];
|
||||
const src = match[2] || match[3];
|
||||
if (id && src) map.set(id, src);
|
||||
}
|
||||
setCompIdToSrc(map);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [projectId]);
|
||||
|
||||
// Composition drill-down stack
|
||||
const [compositionStack, setCompositionStack] = useState<CompositionLevel[]>([
|
||||
{ id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` },
|
||||
]);
|
||||
|
||||
// Resizable timeline height
|
||||
const [timelineH, setTimelineH] = useState(DEFAULT_TIMELINE_H);
|
||||
const isDragging = useRef(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Current preview URL — derived from composition stack
|
||||
const currentLevel = compositionStack[compositionStack.length - 1];
|
||||
const directUrl = compositionStack.length > 1 ? currentLevel.previewUrl : undefined;
|
||||
|
||||
// Drill-down: push a sub-composition onto the stack
|
||||
const iframeRef_ = iframeRef; // stable ref for the callback
|
||||
const handleDrillDown = useCallback(
|
||||
(element: TimelineElement) => {
|
||||
if (!element.compositionSrc) return;
|
||||
// compositionSrc may be a full URL (from runtime manifest) or a relative path
|
||||
// Extract the element's composition ID from its timeline ID
|
||||
const compId = element.id;
|
||||
|
||||
// 1. Check compIdToSrc map (from index.html)
|
||||
// 2. Scan the current iframe DOM for data-composition-src attribute
|
||||
// 3. Fall back to stripping the compositionSrc to a relative path
|
||||
let resolvedPath = compIdToSrc.get(compId);
|
||||
if (!resolvedPath) {
|
||||
try {
|
||||
const doc = iframeRef_.current?.contentDocument;
|
||||
if (doc) {
|
||||
const host = doc.querySelector(`[data-composition-id="${compId}"][data-composition-src]`);
|
||||
if (host) {
|
||||
resolvedPath = host.getAttribute("data-composition-src") || undefined;
|
||||
}
|
||||
}
|
||||
} catch { /* cross-origin */ }
|
||||
}
|
||||
if (!resolvedPath) {
|
||||
// Strip full URL to relative path if needed
|
||||
const src = element.compositionSrc;
|
||||
const compMatch = src.match(/compositions\/.*\.html/);
|
||||
resolvedPath = compMatch ? compMatch[0] : src;
|
||||
}
|
||||
|
||||
usePlayerStore.getState().setElements([]);
|
||||
|
||||
// Toggle: if already viewing this composition, go back to parent (like Premiere)
|
||||
setCompositionStack((prev) => {
|
||||
const currentId = prev[prev.length - 1].id;
|
||||
if (currentId === resolvedPath && prev.length > 1) {
|
||||
return prev.slice(0, -1);
|
||||
}
|
||||
// Extract a clean label from the path (strip directories and extension)
|
||||
const label = resolvedPath.split("/").pop()?.replace(/\.html$/, "") || resolvedPath;
|
||||
const previewUrl = `/api/projects/${projectId}/preview/comp/${resolvedPath}`;
|
||||
return [...prev, { id: resolvedPath, label, previewUrl }];
|
||||
});
|
||||
},
|
||||
[projectId, compIdToSrc],
|
||||
);
|
||||
|
||||
// Navigate back to a specific breadcrumb level
|
||||
const handleNavigateComposition = useCallback(
|
||||
(index: number) => {
|
||||
usePlayerStore.getState().setElements([]);
|
||||
setCompositionStack((prev) => prev.slice(0, index + 1));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Navigate to a composition when activeCompositionPath changes
|
||||
const prevActiveCompRef = useRef<string | null>(null);
|
||||
if (activeCompositionPath && activeCompositionPath !== prevActiveCompRef.current) {
|
||||
prevActiveCompRef.current = activeCompositionPath;
|
||||
queueMicrotask(() => usePlayerStore.getState().setElements([]));
|
||||
if (activeCompositionPath === "index.html") {
|
||||
setCompositionStack((prev) => prev.length > 1 ? [prev[0]] : prev);
|
||||
} else if (activeCompositionPath.startsWith("compositions/")) {
|
||||
const label = activeCompositionPath.replace(/^compositions\//, "").replace(/\.html$/, "");
|
||||
const previewUrl = `/api/projects/${projectId}/preview/comp/${activeCompositionPath}`;
|
||||
setCompositionStack((prev) => {
|
||||
if (prev[prev.length - 1].id === activeCompositionPath) return prev;
|
||||
return [
|
||||
{ id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` },
|
||||
{ id: activeCompositionPath, label, previewUrl },
|
||||
];
|
||||
});
|
||||
}
|
||||
} else if (!activeCompositionPath && prevActiveCompRef.current) {
|
||||
prevActiveCompRef.current = null;
|
||||
queueMicrotask(() => usePlayerStore.getState().setElements([]));
|
||||
}
|
||||
|
||||
// Resize divider handlers
|
||||
const handleDividerPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
isDragging.current = true;
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}, []);
|
||||
|
||||
const handleDividerPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!isDragging.current || !containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const mouseY = e.clientY - rect.top;
|
||||
const containerH = rect.height;
|
||||
const newTimelineH = Math.max(
|
||||
MIN_TIMELINE_H,
|
||||
Math.min(containerH - MIN_PREVIEW_H, containerH - mouseY),
|
||||
);
|
||||
setTimelineH(newTimelineH);
|
||||
}, []);
|
||||
|
||||
const handleDividerPointerUp = useCallback(() => {
|
||||
isDragging.current = false;
|
||||
}, []);
|
||||
|
||||
// Keyboard: Escape to pop composition level
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape" && compositionStack.length > 1) {
|
||||
setCompositionStack((prev) => prev.slice(0, -1));
|
||||
}
|
||||
},
|
||||
[compositionStack.length],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex flex-col h-full min-h-0 bg-neutral-950"
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{/* Preview — takes remaining space above timeline */}
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<NLEPreview
|
||||
projectId={projectId}
|
||||
iframeRef={iframeRef}
|
||||
onIframeLoad={onIframeLoad}
|
||||
portrait={portrait}
|
||||
directUrl={directUrl}
|
||||
refreshKey={refreshKey}
|
||||
/>
|
||||
{previewOverlay}
|
||||
</div>
|
||||
|
||||
{/* Resize divider */}
|
||||
<div
|
||||
className="h-1 flex-shrink-0 bg-neutral-800 hover:bg-blue-500 cursor-row-resize transition-colors active:bg-blue-400 z-10"
|
||||
style={{ touchAction: "none" }}
|
||||
onPointerDown={handleDividerPointerDown}
|
||||
onPointerMove={handleDividerPointerMove}
|
||||
onPointerUp={handleDividerPointerUp}
|
||||
/>
|
||||
|
||||
{/* Timeline section — fixed height, resizable */}
|
||||
<div className="flex flex-col flex-shrink-0" style={{ height: timelineH }}>
|
||||
{/* Breadcrumb + Player controls */}
|
||||
<div className="bg-neutral-950 border-t border-neutral-800/50 flex-shrink-0">
|
||||
{compositionStack.length > 1 && (
|
||||
<CompositionBreadcrumb stack={compositionStack} onNavigate={handleNavigateComposition} />
|
||||
)}
|
||||
<PlayerControls onTogglePlay={togglePlay} onSeek={seek} />
|
||||
</div>
|
||||
|
||||
{/* Timeline tracks */}
|
||||
<div
|
||||
className="flex-1 min-h-0 overflow-y-auto bg-neutral-950"
|
||||
onDoubleClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-clip]")) return;
|
||||
if (compositionStack.length > 1) {
|
||||
setCompositionStack((prev) => prev.slice(0, -1));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Timeline onSeek={seek} onDrillDown={handleDrillDown} />
|
||||
{timelineFooter}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { memo, type Ref } from "react";
|
||||
import { Player } from "../../player";
|
||||
|
||||
interface NLEPreviewProps {
|
||||
projectId: string;
|
||||
iframeRef: Ref<HTMLIFrameElement>;
|
||||
onIframeLoad: () => void;
|
||||
portrait?: boolean;
|
||||
directUrl?: string;
|
||||
refreshKey?: number;
|
||||
}
|
||||
|
||||
export const NLEPreview = memo(function NLEPreview({
|
||||
projectId,
|
||||
iframeRef,
|
||||
onIframeLoad,
|
||||
portrait,
|
||||
directUrl,
|
||||
refreshKey,
|
||||
}: NLEPreviewProps) {
|
||||
const playerKey = `${directUrl ?? projectId}_${refreshKey ?? 0}`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex-1 flex items-center justify-center p-2 overflow-hidden min-h-0">
|
||||
<Player
|
||||
key={playerKey}
|
||||
ref={iframeRef}
|
||||
projectId={directUrl ? undefined : projectId}
|
||||
directUrl={directUrl}
|
||||
onLoad={onIframeLoad}
|
||||
portrait={portrait}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Button & IconButton — The most important primitive.
|
||||
*
|
||||
* Absorbs: active state (scale 0.98), hit target (min 32px),
|
||||
* shadow anatomy (primary), focus ring, disabled state,
|
||||
* loading state, reduced motion, proper timing tokens.
|
||||
*
|
||||
* Rules applied:
|
||||
* - physics-active-state: scale(0.98) on :active
|
||||
* - ux-fitts-target-size: min 32px hit target
|
||||
* - visual-button-shadow-anatomy: 6-layer shadow on primary
|
||||
* - duration-press-hover: 120ms press, 150ms hover
|
||||
*/
|
||||
|
||||
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
|
||||
|
||||
// -- Button --
|
||||
|
||||
type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
|
||||
type ButtonSize = "sm" | "md" | "lg";
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
loading?: boolean;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
const variantStyles: Record<ButtonVariant, string> = {
|
||||
primary: [
|
||||
"bg-white text-neutral-950 font-medium",
|
||||
"shadow-btn-primary",
|
||||
"hover:bg-neutral-200",
|
||||
"active:scale-[0.97]",
|
||||
].join(" "),
|
||||
secondary: [
|
||||
"bg-transparent text-neutral-300 font-medium",
|
||||
"border border-border",
|
||||
"hover:bg-surface-hover hover:text-white hover:border-border-strong",
|
||||
"active:scale-[0.98]",
|
||||
].join(" "),
|
||||
danger: ["bg-accent-red text-white font-medium", "hover:bg-red-600", "active:scale-[0.97]"].join(" "),
|
||||
ghost: ["bg-transparent text-neutral-400", "hover:bg-surface-hover hover:text-white", "active:scale-[0.98]"].join(
|
||||
" ",
|
||||
),
|
||||
};
|
||||
|
||||
const sizeStyles: Record<ButtonSize, string> = {
|
||||
sm: "h-7 px-2.5 text-xs gap-1.5 rounded-button",
|
||||
md: "h-8 px-3 text-sm gap-1.5 rounded-button",
|
||||
lg: "h-9 px-4 text-base gap-2 rounded-button",
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ variant = "secondary", size = "md", loading, icon, children, className = "", disabled, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
disabled={disabled || loading}
|
||||
className={[
|
||||
"inline-flex items-center justify-center",
|
||||
"transition-all duration-press ease-standard",
|
||||
"disabled:opacity-40 disabled:pointer-events-none",
|
||||
"select-none cursor-pointer",
|
||||
variantStyles[variant],
|
||||
sizeStyles[size],
|
||||
className,
|
||||
].join(" ")}
|
||||
{...props}
|
||||
>
|
||||
{loading ? (
|
||||
<svg className="animate-spin h-3.5 w-3.5" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
) : icon ? (
|
||||
<span className="flex-shrink-0">{icon}</span>
|
||||
) : null}
|
||||
{children && <span>{children}</span>}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
// -- IconButton --
|
||||
// For icon-only buttons. Enforces min 32px hit target.
|
||||
|
||||
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
icon: ReactNode;
|
||||
size?: ButtonSize;
|
||||
variant?: ButtonVariant;
|
||||
"aria-label": string; // REQUIRED for accessibility
|
||||
}
|
||||
|
||||
const iconSizeStyles: Record<ButtonSize, string> = {
|
||||
sm: "min-w-7 min-h-7 rounded-button", // 28px
|
||||
md: "min-w-8 min-h-8 rounded-button", // 32px — minimum recommended
|
||||
lg: "min-w-9 min-h-9 rounded-button", // 36px
|
||||
};
|
||||
|
||||
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||
({ icon, size = "md", variant = "ghost", className = "", ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={[
|
||||
"inline-flex items-center justify-center",
|
||||
"transition-all duration-press ease-standard",
|
||||
"disabled:opacity-40 disabled:pointer-events-none",
|
||||
"select-none cursor-pointer",
|
||||
variantStyles[variant],
|
||||
iconSizeStyles[size],
|
||||
className,
|
||||
].join(" ")}
|
||||
{...props}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
IconButton.displayName = "IconButton";
|
||||
@@ -0,0 +1,2 @@
|
||||
// Minimal UI primitives for studio canvas components
|
||||
export { Button, IconButton } from "./Button";
|
||||
Reference in New Issue
Block a user