mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
style: apply oxfmt baseline formatting across all source files (#25)
## Summary - Run `oxfmt .` across the entire codebase to establish formatted baseline - 299 files changed — mechanical formatting only, no logic changes - Double quotes, semicolons, 2-space indent, trailing commas, 100 print width Part 3/4 of [VA-851](https://linear.app/heygen/issue/VA-851/pre-migration-configure-eslint-prettier-and-conventional-commits) ## Test plan - [x] `pnpm format:check` — all 426 files pass - [x] `pnpm -r typecheck` — all packages pass - [x] `pnpm build` — all packages build - [x] All 348 tests pass
This commit is contained in:
@@ -14,7 +14,15 @@ interface PropertyPanelProps {
|
||||
onSetText?: (text: string) => void;
|
||||
}
|
||||
|
||||
function PropertyRow({ label, value, onChange }: { label: string; value: string; onChange: (v: 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>
|
||||
@@ -28,12 +36,23 @@ function PropertyRow({ label, value, onChange }: { label: string; value: string;
|
||||
);
|
||||
}
|
||||
|
||||
function ColorRow({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
|
||||
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 }} />
|
||||
<div
|
||||
className="w-5 h-5 rounded border border-neutral-700 flex-shrink-0"
|
||||
style={{ backgroundColor: value }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
@@ -49,7 +68,9 @@ 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>
|
||||
<span className="text-2xs font-medium text-neutral-500 uppercase tracking-wider">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -98,7 +119,12 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
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} />
|
||||
<IconButton
|
||||
icon={<X size={11} />}
|
||||
aria-label="Clear selection"
|
||||
size="sm"
|
||||
onClick={onClearPick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -113,10 +139,26 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
{/* 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)} />
|
||||
<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 */}
|
||||
@@ -127,8 +169,16 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
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="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] ?? ""}
|
||||
@@ -139,7 +189,11 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
|
||||
{/* Colors */}
|
||||
<SectionHeader icon={Palette} label="Colors" />
|
||||
<ColorRow label="Color" value={s["color"] ?? "#fff"} onChange={(v) => onSetStyle("color", v)} />
|
||||
<ColorRow
|
||||
label="Color"
|
||||
value={s["color"] ?? "#fff"}
|
||||
onChange={(v) => onSetStyle("color", v)}
|
||||
/>
|
||||
<ColorRow
|
||||
label="Background"
|
||||
value={s["background-color"] ?? "transparent"}
|
||||
@@ -148,14 +202,26 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
|
||||
{/* Appearance */}
|
||||
<SectionHeader icon={Eye} label="Appearance" />
|
||||
<PropertyRow label="Opacity" value={s["opacity"] ?? "1"} onChange={(v) => onSetStyle("opacity", v)} />
|
||||
<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)} />
|
||||
<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"]) && (
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { useRef, useCallback, memo } from "react";
|
||||
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter } from "@codemirror/view";
|
||||
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";
|
||||
@@ -90,12 +96,7 @@ export const SourceEditor = memo(function SourceEditor({
|
||||
bracketMatching(),
|
||||
closeBrackets(),
|
||||
highlightSelectionMatches(),
|
||||
keymap.of([
|
||||
...closeBracketsKeymap,
|
||||
...defaultKeymap,
|
||||
...searchKeymap,
|
||||
...historyKeymap,
|
||||
]),
|
||||
keymap.of([...closeBracketsKeymap, ...defaultKeymap, ...searchKeymap, ...historyKeymap]),
|
||||
getLanguageExtension(lang),
|
||||
oneDark,
|
||||
updateListener,
|
||||
|
||||
@@ -32,7 +32,13 @@ export const NLELayout = memo(function NLELayout({
|
||||
activeCompositionPath,
|
||||
onIframeRef,
|
||||
}: NLELayoutProps) {
|
||||
const { iframeRef, togglePlay, seek, onIframeLoad: baseOnIframeLoad, saveSeekPosition } = useTimelinePlayer();
|
||||
const {
|
||||
iframeRef,
|
||||
togglePlay,
|
||||
seek,
|
||||
onIframeLoad: baseOnIframeLoad,
|
||||
saveSeekPosition,
|
||||
} = useTimelinePlayer();
|
||||
|
||||
// Preserve seek position when refreshKey changes (iframe will remount via key prop).
|
||||
const prevRefreshKeyRef = useRef(refreshKey);
|
||||
@@ -55,7 +61,8 @@ export const NLELayout = memo(function NLELayout({
|
||||
.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;
|
||||
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];
|
||||
@@ -98,12 +105,16 @@ export const NLELayout = memo(function NLELayout({
|
||||
try {
|
||||
const doc = iframeRef_.current?.contentDocument;
|
||||
if (doc) {
|
||||
const host = doc.querySelector(`[data-composition-id="${compId}"][data-composition-src]`);
|
||||
const host = doc.querySelector(
|
||||
`[data-composition-id="${compId}"][data-composition-src]`,
|
||||
);
|
||||
if (host) {
|
||||
resolvedPath = host.getAttribute("data-composition-src") || undefined;
|
||||
}
|
||||
}
|
||||
} catch { /* cross-origin */ }
|
||||
} catch {
|
||||
/* cross-origin */
|
||||
}
|
||||
}
|
||||
if (!resolvedPath) {
|
||||
// Strip full URL to relative path if needed
|
||||
@@ -121,7 +132,11 @@ export const NLELayout = memo(function NLELayout({
|
||||
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 label =
|
||||
resolvedPath
|
||||
.split("/")
|
||||
.pop()
|
||||
?.replace(/\.html$/, "") || resolvedPath;
|
||||
const previewUrl = `/api/projects/${projectId}/preview/comp/${resolvedPath}`;
|
||||
return [...prev, { id: resolvedPath, label, previewUrl }];
|
||||
});
|
||||
@@ -131,13 +146,10 @@ export const NLELayout = memo(function NLELayout({
|
||||
);
|
||||
|
||||
// Navigate back to a specific breadcrumb level
|
||||
const handleNavigateComposition = useCallback(
|
||||
(index: number) => {
|
||||
usePlayerStore.getState().setElements([]);
|
||||
setCompositionStack((prev) => prev.slice(0, index + 1));
|
||||
},
|
||||
[],
|
||||
);
|
||||
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);
|
||||
@@ -145,7 +157,7 @@ export const NLELayout = memo(function NLELayout({
|
||||
prevActiveCompRef.current = activeCompositionPath;
|
||||
queueMicrotask(() => usePlayerStore.getState().setElements([]));
|
||||
if (activeCompositionPath === "index.html") {
|
||||
setCompositionStack((prev) => prev.length > 1 ? [prev[0]] : prev);
|
||||
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}`;
|
||||
@@ -229,7 +241,10 @@ export const NLELayout = memo(function NLELayout({
|
||||
{/* 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} />
|
||||
<CompositionBreadcrumb
|
||||
stack={compositionStack}
|
||||
onNavigate={handleNavigateComposition}
|
||||
/>
|
||||
)}
|
||||
<PlayerControls onTogglePlay={togglePlay} onSeek={seek} />
|
||||
</div>
|
||||
|
||||
@@ -39,10 +39,14 @@ const variantStyles: Record<ButtonVariant, string> = {
|
||||
"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(
|
||||
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> = {
|
||||
@@ -52,7 +56,19 @@ const sizeStyles: Record<ButtonSize, string> = {
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ variant = "secondary", size = "md", loading, icon, children, className = "", disabled, ...props }, ref) => {
|
||||
(
|
||||
{
|
||||
variant = "secondary",
|
||||
size = "md",
|
||||
loading,
|
||||
icon,
|
||||
children,
|
||||
className = "",
|
||||
disabled,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
@@ -70,8 +86,19 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
>
|
||||
{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" />
|
||||
<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>
|
||||
|
||||
@@ -52,19 +52,25 @@ export function useCodeEditor(): UseCodeEditorReturn {
|
||||
const updateContent = useCallback(
|
||||
(content: string) => {
|
||||
setOpenFiles((prev) =>
|
||||
prev.map((f) => (f.path === activeFilePath ? { ...f, content, isDirty: content !== f.savedContent } : f)),
|
||||
prev.map((f) =>
|
||||
f.path === activeFilePath ? { ...f, content, isDirty: content !== f.savedContent } : f,
|
||||
),
|
||||
);
|
||||
},
|
||||
[activeFilePath],
|
||||
);
|
||||
|
||||
const markSaved = useCallback((path: string) => {
|
||||
setOpenFiles((prev) => prev.map((f) => (f.path === path ? { ...f, savedContent: f.content, isDirty: false } : f)));
|
||||
setOpenFiles((prev) =>
|
||||
prev.map((f) => (f.path === path ? { ...f, savedContent: f.content, isDirty: false } : f)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const externalUpdate = useCallback((path: string, content: string) => {
|
||||
setOpenFiles((prev) =>
|
||||
prev.map((f) => (f.path === path ? { ...f, savedContent: content, content, isDirty: false } : f)),
|
||||
prev.map((f) =>
|
||||
f.path === path ? { ...f, savedContent: content, content, isDirty: false } : f,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -99,7 +99,8 @@ export function useElementPicker(
|
||||
// Accept events from either the primary iframe or the active override
|
||||
const activeIframe = getActiveIframe();
|
||||
if (!activeIframe) return;
|
||||
if (e.source !== activeIframe.contentWindow && e.source !== iframeRef.current?.contentWindow) return;
|
||||
if (e.source !== activeIframe.contentWindow && e.source !== iframeRef.current?.contentWindow)
|
||||
return;
|
||||
|
||||
if (data.type === "element-picked") {
|
||||
const el = data.elementInfo;
|
||||
@@ -192,7 +193,11 @@ export function useElementPicker(
|
||||
// Persist to source file
|
||||
if (pickedElement.id) {
|
||||
// ID-based patching — surgical edit of just the element's style
|
||||
syncToSource(pickedElement.id, pickedElement.selector, { type: "inline-style", property: prop, value });
|
||||
syncToSource(pickedElement.id, pickedElement.selector, {
|
||||
type: "inline-style",
|
||||
property: prop,
|
||||
value,
|
||||
});
|
||||
} else {
|
||||
// No ID — save the full composition HTML from the iframe
|
||||
// This captures ALL inline style changes, not just the targeted one
|
||||
@@ -203,9 +208,13 @@ export function useElementPicker(
|
||||
const src = activeIframe.getAttribute("src") ?? "";
|
||||
const compMatch = src.match(/\/comp\/(.+?)(?:\?|$)/);
|
||||
const filePath = compMatch ? compMatch[1] : "index.html";
|
||||
optionsRef.current.onSyncFiles({ [filePath]: `<!DOCTYPE html>\n<html>${fullHtml.replace(/<html[^>]*>/, "")}` });
|
||||
optionsRef.current.onSyncFiles({
|
||||
[filePath]: `<!DOCTYPE html>\n<html>${fullHtml.replace(/<html[^>]*>/, "")}`,
|
||||
});
|
||||
}
|
||||
} catch { /* cross-origin */ }
|
||||
} catch {
|
||||
/* cross-origin */
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -234,7 +243,11 @@ export function useElementPicker(
|
||||
);
|
||||
// Persist to source file immediately
|
||||
if (pickedElement.id) {
|
||||
syncToSource(pickedElement.id, pickedElement.selector, { type: "attribute", property: attr, value });
|
||||
syncToSource(pickedElement.id, pickedElement.selector, {
|
||||
type: "attribute",
|
||||
property: attr,
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -18,33 +18,31 @@ interface AgentActivityTrackProps {
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export const AgentActivityTrack = memo(function AgentActivityTrack({ agents, duration }: AgentActivityTrackProps) {
|
||||
export const AgentActivityTrack = memo(function AgentActivityTrack({
|
||||
agents,
|
||||
duration,
|
||||
}: AgentActivityTrackProps) {
|
||||
if (agents.length === 0 || duration <= 0) return null;
|
||||
|
||||
return (
|
||||
<div className="border-t border-neutral-800/30">
|
||||
{/* Section header */}
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 text-[9px] text-neutral-600 font-medium uppercase tracking-wider">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="4" /></svg>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
</svg>
|
||||
Agent Activity
|
||||
</div>
|
||||
|
||||
{agents.map((agent) => (
|
||||
<div
|
||||
key={agent.agentId}
|
||||
className="relative flex"
|
||||
style={{ height: TRACK_H }}
|
||||
>
|
||||
<div key={agent.agentId} className="relative flex" style={{ height: TRACK_H }}>
|
||||
{/* Gutter: agent name */}
|
||||
<div
|
||||
className="flex-shrink-0 flex items-center justify-center"
|
||||
style={{ width: GUTTER }}
|
||||
title={agent.name}
|
||||
>
|
||||
<div
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: agent.color }}
|
||||
/>
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: agent.color }} />
|
||||
</div>
|
||||
|
||||
{/* Lane */}
|
||||
@@ -77,10 +75,7 @@ export const AgentActivityTrack = memo(function AgentActivityTrack({ agents, dur
|
||||
style={{ left: `${leftPct}%` }}
|
||||
>
|
||||
{event.type === "create" ? (
|
||||
<div
|
||||
className="w-2 h-2 rotate-45"
|
||||
style={{ backgroundColor: agent.color }}
|
||||
/>
|
||||
<div className="w-2 h-2 rotate-45" style={{ backgroundColor: agent.color }} />
|
||||
) : (
|
||||
<div
|
||||
className="w-1.5 h-1.5 rounded-full"
|
||||
|
||||
@@ -11,110 +11,120 @@ interface PlayerProps {
|
||||
portrait?: boolean;
|
||||
}
|
||||
|
||||
export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(({ projectId, directUrl, onLoad, portrait }, ref) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [scale, setScale] = useState(1);
|
||||
const dimsRef = useRef({ w: portrait ? NATIVE_H : NATIVE_W, h: portrait ? NATIVE_W : NATIVE_H });
|
||||
const [dims, setDims] = useState(dimsRef.current);
|
||||
const loadCountRef = useRef(0);
|
||||
export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
||||
({ projectId, directUrl, onLoad, portrait }, ref) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [scale, setScale] = useState(1);
|
||||
const dimsRef = useRef({
|
||||
w: portrait ? NATIVE_H : NATIVE_W,
|
||||
h: portrait ? NATIVE_W : NATIVE_H,
|
||||
});
|
||||
const [dims, setDims] = useState(dimsRef.current);
|
||||
const loadCountRef = useRef(0);
|
||||
|
||||
const updateScale = useCallback(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const d = dimsRef.current;
|
||||
setScale(Math.min(rect.width / d.w, rect.height / d.h));
|
||||
}, []);
|
||||
const updateScale = useCallback(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const d = dimsRef.current;
|
||||
setScale(Math.min(rect.width / d.w, rect.height / d.h));
|
||||
}, []);
|
||||
|
||||
useMountEffect(() => {
|
||||
updateScale();
|
||||
const ro = new ResizeObserver(updateScale);
|
||||
if (containerRef.current) ro.observe(containerRef.current);
|
||||
useMountEffect(() => {
|
||||
updateScale();
|
||||
const ro = new ResizeObserver(updateScale);
|
||||
if (containerRef.current) ro.observe(containerRef.current);
|
||||
|
||||
// Listen for stage-size messages from the runtime
|
||||
const handleMessage = (e: MessageEvent) => {
|
||||
const data = e.data;
|
||||
if ((data?.source === "hf-preview" || data?.source === "hf-preview") && data?.type === "stage-size" && data.width > 0 && data.height > 0) {
|
||||
if (dimsRef.current.w !== data.width || dimsRef.current.h !== data.height) {
|
||||
dimsRef.current = { w: data.width, h: data.height };
|
||||
setDims(dimsRef.current);
|
||||
updateScale();
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", handleMessage);
|
||||
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
window.removeEventListener("message", handleMessage);
|
||||
};
|
||||
});
|
||||
|
||||
const handleLoad = useCallback(() => {
|
||||
loadCountRef.current++;
|
||||
|
||||
// Auto-detect dimensions from the composition's data-width/data-height
|
||||
try {
|
||||
const iframeEl = typeof ref === "function" ? null : ref?.current;
|
||||
const doc = iframeEl?.contentDocument;
|
||||
if (doc) {
|
||||
const root = doc.querySelector("[data-composition-id]");
|
||||
if (root) {
|
||||
const dw = parseInt(root.getAttribute("data-width") || "0", 10);
|
||||
const dh = parseInt(root.getAttribute("data-height") || "0", 10);
|
||||
if (dw > 0 && dh > 0 && (dw !== dimsRef.current.w || dh !== dimsRef.current.h)) {
|
||||
dimsRef.current = { w: dw, h: dh };
|
||||
// Listen for stage-size messages from the runtime
|
||||
const handleMessage = (e: MessageEvent) => {
|
||||
const data = e.data;
|
||||
if (
|
||||
(data?.source === "hf-preview" || data?.source === "hf-preview") &&
|
||||
data?.type === "stage-size" &&
|
||||
data.width > 0 &&
|
||||
data.height > 0
|
||||
) {
|
||||
if (dimsRef.current.w !== data.width || dimsRef.current.h !== data.height) {
|
||||
dimsRef.current = { w: data.width, h: data.height };
|
||||
setDims(dimsRef.current);
|
||||
// Recalc scale with new dims
|
||||
const el = containerRef.current;
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
setScale(Math.min(rect.width / dw, rect.height / dh));
|
||||
updateScale();
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", handleMessage);
|
||||
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
window.removeEventListener("message", handleMessage);
|
||||
};
|
||||
});
|
||||
|
||||
const handleLoad = useCallback(() => {
|
||||
loadCountRef.current++;
|
||||
|
||||
// Auto-detect dimensions from the composition's data-width/data-height
|
||||
try {
|
||||
const iframeEl = typeof ref === "function" ? null : ref?.current;
|
||||
const doc = iframeEl?.contentDocument;
|
||||
if (doc) {
|
||||
const root = doc.querySelector("[data-composition-id]");
|
||||
if (root) {
|
||||
const dw = parseInt(root.getAttribute("data-width") || "0", 10);
|
||||
const dh = parseInt(root.getAttribute("data-height") || "0", 10);
|
||||
if (dw > 0 && dh > 0 && (dw !== dimsRef.current.w || dh !== dimsRef.current.h)) {
|
||||
dimsRef.current = { w: dw, h: dh };
|
||||
setDims(dimsRef.current);
|
||||
// Recalc scale with new dims
|
||||
const el = containerRef.current;
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
setScale(Math.min(rect.width / dw, rect.height / dh));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Cross-origin
|
||||
}
|
||||
} catch {
|
||||
// Cross-origin
|
||||
}
|
||||
|
||||
if (loadCountRef.current > 1) {
|
||||
const el = containerRef.current;
|
||||
if (el) {
|
||||
el.classList.remove("preview-revealing");
|
||||
void el.offsetWidth;
|
||||
el.classList.add("preview-revealing");
|
||||
const onEnd = () => el.classList.remove("preview-revealing");
|
||||
el.addEventListener("animationend", onEnd, { once: true });
|
||||
if (loadCountRef.current > 1) {
|
||||
const el = containerRef.current;
|
||||
if (el) {
|
||||
el.classList.remove("preview-revealing");
|
||||
void el.offsetWidth;
|
||||
el.classList.add("preview-revealing");
|
||||
const onEnd = () => el.classList.remove("preview-revealing");
|
||||
el.addEventListener("animationend", onEnd, { once: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
onLoad();
|
||||
}, [onLoad, ref]);
|
||||
onLoad();
|
||||
}, [onLoad, ref]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full h-full max-w-full max-h-full overflow-hidden shadow-float border border-neutral-800 bg-black flex items-center justify-center rounded-card-inner"
|
||||
>
|
||||
<iframe
|
||||
ref={ref}
|
||||
src={directUrl || `/api/projects/${projectId}/preview`}
|
||||
onLoad={handleLoad}
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
allow="autoplay; fullscreen"
|
||||
referrerPolicy="no-referrer"
|
||||
title="Project Preview"
|
||||
style={{
|
||||
width: dims.w,
|
||||
height: dims.h,
|
||||
border: "none",
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "center center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full h-full max-w-full max-h-full overflow-hidden shadow-float border border-neutral-800 bg-black flex items-center justify-center rounded-card-inner"
|
||||
>
|
||||
<iframe
|
||||
ref={ref}
|
||||
src={directUrl || `/api/projects/${projectId}/preview`}
|
||||
onLoad={handleLoad}
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
allow="autoplay; fullscreen"
|
||||
referrerPolicy="no-referrer"
|
||||
title="Project Preview"
|
||||
style={{
|
||||
width: dims.w,
|
||||
height: dims.h,
|
||||
border: "none",
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "center center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Player.displayName = "Player";
|
||||
|
||||
@@ -16,7 +16,11 @@ interface PlayerControlsProps {
|
||||
onSeek: (time: number) => void;
|
||||
}
|
||||
|
||||
export const PlayerControls = memo(function PlayerControls({ onTogglePlay, onSeek, ...overrides }: PlayerControlsProps) {
|
||||
export const PlayerControls = memo(function PlayerControls({
|
||||
onTogglePlay,
|
||||
onSeek,
|
||||
...overrides
|
||||
}: PlayerControlsProps) {
|
||||
// Subscribe to only the fields we render — each selector prevents cascading re-renders
|
||||
const storeIsPlaying = usePlayerStore((s) => s.isPlaying);
|
||||
const storeDuration = usePlayerStore((s) => s.duration);
|
||||
@@ -165,9 +169,14 @@ export const PlayerControls = memo(function PlayerControls({ onTogglePlay, onSee
|
||||
{SPEED_OPTIONS.map((rate) => (
|
||||
<button
|
||||
key={rate}
|
||||
onClick={() => { setPlaybackRate(rate); setShowSpeedMenu(false); }}
|
||||
onClick={() => {
|
||||
setPlaybackRate(rate);
|
||||
setShowSpeedMenu(false);
|
||||
}}
|
||||
className={`block w-full px-3 py-1 text-xs text-left font-mono tabular-nums transition-colors ${
|
||||
rate === playbackRate ? "text-white bg-neutral-800" : "text-neutral-400 hover:text-white hover:bg-neutral-800"
|
||||
rate === playbackRate
|
||||
? "text-white bg-neutral-800"
|
||||
: "text-neutral-400 hover:text-white hover:bg-neutral-800"
|
||||
}`}
|
||||
>
|
||||
{rate}x
|
||||
|
||||
@@ -36,7 +36,6 @@ export function PreviewPanel({
|
||||
renderStatus,
|
||||
children,
|
||||
}: PreviewPanelProps) {
|
||||
|
||||
const renderState = renderStatus?.state ?? "idle";
|
||||
|
||||
return (
|
||||
@@ -52,71 +51,102 @@ export function PreviewPanel({
|
||||
{hasProject && projectId ? (
|
||||
<>
|
||||
{/* Player — takes all remaining space, constrained for portrait */}
|
||||
<div className="flex items-center justify-center p-2 overflow-hidden" style={{ minHeight: 0, minWidth: 0 }}>
|
||||
<Player ref={iframeRef} projectId={projectId} onLoad={onIframeLoad} portrait={portrait} />
|
||||
<div
|
||||
className="flex items-center justify-center p-2 overflow-hidden"
|
||||
style={{ minHeight: 0, minWidth: 0 }}
|
||||
>
|
||||
<Player
|
||||
ref={iframeRef}
|
||||
projectId={projectId}
|
||||
onLoad={onIframeLoad}
|
||||
portrait={portrait}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Controls — fixed height */}
|
||||
<div className="bg-neutral-950 border-t border-neutral-800 flex-shrink-0">
|
||||
<PlayerControls
|
||||
onTogglePlay={onTogglePlay}
|
||||
onSeek={onSeek}
|
||||
/>
|
||||
<PlayerControls onTogglePlay={onTogglePlay} onSeek={onSeek} />
|
||||
</div>
|
||||
|
||||
{/* Timeline — capped height, internal scroll */}
|
||||
<div className="bg-neutral-950 flex-shrink-0 overflow-y-auto" style={{ maxHeight: "100px" }}>
|
||||
<div
|
||||
className="bg-neutral-950 flex-shrink-0 overflow-y-auto"
|
||||
style={{ maxHeight: "100px" }}
|
||||
>
|
||||
<Timeline onSeek={onSeek} />
|
||||
</div>
|
||||
|
||||
{/* Render status — only shown when actively rendering, complete, or error */}
|
||||
{renderStatus && (renderState === "rendering" || renderState === "complete" || renderState === "error") && (
|
||||
<div className="bg-neutral-950 border-t border-neutral-800 px-4 py-2 flex items-center justify-end gap-2 flex-shrink-0">
|
||||
{renderState === "rendering" && (
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 bg-neutral-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full transition-[width] duration-200"
|
||||
style={{ width: `${renderStatus.progress ?? 0}%` }}
|
||||
/>
|
||||
{renderStatus &&
|
||||
(renderState === "rendering" ||
|
||||
renderState === "complete" ||
|
||||
renderState === "error") && (
|
||||
<div className="bg-neutral-950 border-t border-neutral-800 px-4 py-2 flex items-center justify-end gap-2 flex-shrink-0">
|
||||
{renderState === "rendering" && (
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 bg-neutral-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full transition-[width] duration-200"
|
||||
style={{ width: `${renderStatus.progress ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-neutral-400 flex-shrink-0">
|
||||
{renderStatus.stage || "Rendering..."}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-neutral-400 flex-shrink-0">
|
||||
{renderStatus.stage || "Rendering..."}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{renderState === "complete" && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-green-400">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
||||
<polyline points="22 4 12 14.01 9 11.01" />
|
||||
</svg>
|
||||
<span>Complete</span>
|
||||
</div>
|
||||
)}
|
||||
{renderState === "error" && (
|
||||
<div className="flex items-center gap-2 text-xs text-red-400">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
<span className="truncate">{renderStatus.error}</span>
|
||||
{renderStatus.onRender && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={renderStatus.onRender}
|
||||
className="flex-shrink-0 px-2 py-0.5 text-xs text-neutral-300 hover:text-white hover:bg-neutral-800 rounded transition-colors"
|
||||
)}
|
||||
{renderState === "complete" && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-green-400">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
||||
<polyline points="22 4 12 14.01 9 11.01" />
|
||||
</svg>
|
||||
<span>Complete</span>
|
||||
</div>
|
||||
)}
|
||||
{renderState === "error" && (
|
||||
<div className="flex items-center gap-2 text-xs text-red-400">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
<span className="truncate">{renderStatus.error}</span>
|
||||
{renderStatus.onRender && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={renderStatus.onRender}
|
||||
className="flex-shrink-0 px-2 py-0.5 text-xs text-neutral-300 hover:text-white hover:bg-neutral-800 rounded transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Optional custom slot */}
|
||||
{children}
|
||||
@@ -140,7 +170,9 @@ export function PreviewPanel({
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm text-neutral-600">Preview will appear here</p>
|
||||
<p className="text-xs text-neutral-700 mt-1">Send a message to generate a video composition</p>
|
||||
<p className="text-xs text-neutral-700 mt-1">
|
||||
Send a message to generate a video composition
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -25,7 +25,16 @@ interface TrackStyle {
|
||||
/* ── Icons from Figma HyperFrames design system ── */
|
||||
const ICON_BASE = "/icons/timeline";
|
||||
function TimelineIcon({ src }: { src: string }) {
|
||||
return <img src={src} alt="" width={12} height={12} style={{ filter: "brightness(0) invert(1)" }} draggable={false} />;
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
width={12}
|
||||
height={12}
|
||||
style={{ filter: "brightness(0) invert(1)" }}
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const IconCaptions = <TimelineIcon src={`${ICON_BASE}/captions.svg`} />;
|
||||
const IconImage = <TimelineIcon src={`${ICON_BASE}/image.svg`} />;
|
||||
@@ -125,7 +134,9 @@ function generateTicks(duration: number): { major: number[]; minor: number[] } {
|
||||
const minor: number[] = [];
|
||||
for (let t = 0; t <= duration + 0.001; t += minorInterval) {
|
||||
const rounded = Math.round(t * 100) / 100;
|
||||
const isMajor = Math.abs(rounded % majorInterval) < 0.01 || Math.abs(rounded % majorInterval - majorInterval) < 0.01;
|
||||
const isMajor =
|
||||
Math.abs(rounded % majorInterval) < 0.01 ||
|
||||
Math.abs((rounded % majorInterval) - majorInterval) < 0.01;
|
||||
if (isMajor) major.push(rounded);
|
||||
else minor.push(rounded);
|
||||
}
|
||||
@@ -198,10 +209,14 @@ export const Timeline = memo(function Timeline({ onSeek, onDrillDown }: Timeline
|
||||
[seekFromX],
|
||||
);
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent) => { if (isDragging.current) seekFromX(e.clientX); },
|
||||
(e: React.PointerEvent) => {
|
||||
if (isDragging.current) seekFromX(e.clientX);
|
||||
},
|
||||
[seekFromX],
|
||||
);
|
||||
const handlePointerUp = useCallback(() => { isDragging.current = false; }, []);
|
||||
const handlePointerUp = useCallback(() => {
|
||||
isDragging.current = false;
|
||||
}, []);
|
||||
|
||||
const tracks = useMemo(() => {
|
||||
const map = new Map<number, typeof elements>();
|
||||
@@ -226,7 +241,11 @@ export const Timeline = memo(function Timeline({ onSeek, onDrillDown }: Timeline
|
||||
|
||||
if (!timelineReady) return null;
|
||||
if (elements.length === 0) {
|
||||
return <div className="px-3 py-3 text-2xs text-neutral-600 border-t border-neutral-800/50">No timeline elements</div>;
|
||||
return (
|
||||
<div className="px-3 py-3 text-2xs text-neutral-600 border-t border-neutral-800/50">
|
||||
No timeline elements
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalH = RULER_H + tracks.length * TRACK_H;
|
||||
@@ -243,22 +262,48 @@ export const Timeline = memo(function Timeline({ onSeek, onDrillDown }: Timeline
|
||||
>
|
||||
<div className="relative" style={{ height: totalH }}>
|
||||
{/* Grid lines */}
|
||||
<svg className="absolute pointer-events-none" style={{ left: GUTTER }} width={`calc(100% - ${GUTTER}px)`} height={totalH}>
|
||||
<svg
|
||||
className="absolute pointer-events-none"
|
||||
style={{ left: GUTTER }}
|
||||
width={`calc(100% - ${GUTTER}px)`}
|
||||
height={totalH}
|
||||
>
|
||||
{major.map((t) => (
|
||||
<line key={`g-${t}`} x1={`${(t / duration) * 100}%`} y1={RULER_H} x2={`${(t / duration) * 100}%`} y2={totalH} stroke="rgba(255,255,255,0.035)" strokeWidth="1" />
|
||||
<line
|
||||
key={`g-${t}`}
|
||||
x1={`${(t / duration) * 100}%`}
|
||||
y1={RULER_H}
|
||||
x2={`${(t / duration) * 100}%`}
|
||||
y2={totalH}
|
||||
stroke="rgba(255,255,255,0.035)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
{/* Ruler */}
|
||||
<div className="relative border-b border-neutral-800/40" style={{ height: RULER_H, marginLeft: GUTTER }}>
|
||||
<div
|
||||
className="relative border-b border-neutral-800/40"
|
||||
style={{ height: RULER_H, marginLeft: GUTTER }}
|
||||
>
|
||||
{minor.map((t) => (
|
||||
<div key={`m-${t}`} className="absolute bottom-0" style={{ left: `${(t / duration) * 100}%` }}>
|
||||
<div
|
||||
key={`m-${t}`}
|
||||
className="absolute bottom-0"
|
||||
style={{ left: `${(t / duration) * 100}%` }}
|
||||
>
|
||||
<div className="w-px h-[3px] bg-neutral-700/40" />
|
||||
</div>
|
||||
))}
|
||||
{major.map((t) => (
|
||||
<div key={`M-${t}`} className="absolute bottom-0 flex flex-col items-center" style={{ left: `${(t / duration) * 100}%` }}>
|
||||
<span className="text-[9px] text-neutral-500 font-mono tabular-nums leading-none mb-0.5">{formatTick(t)}</span>
|
||||
<div
|
||||
key={`M-${t}`}
|
||||
className="absolute bottom-0 flex flex-col items-center"
|
||||
style={{ left: `${(t / duration) * 100}%` }}
|
||||
>
|
||||
<span className="text-[9px] text-neutral-500 font-mono tabular-nums leading-none mb-0.5">
|
||||
{formatTick(t)}
|
||||
</span>
|
||||
<div className="w-px h-[5px] bg-neutral-600/60" />
|
||||
</div>
|
||||
))}
|
||||
@@ -268,9 +313,16 @@ export const Timeline = memo(function Timeline({ onSeek, onDrillDown }: Timeline
|
||||
{tracks.map(([trackNum, els]) => {
|
||||
const ts = trackStyles.get(trackNum) ?? DEFAULT;
|
||||
return (
|
||||
<div key={trackNum} className="relative flex" style={{ height: TRACK_H, backgroundColor: ts.row }}>
|
||||
<div
|
||||
key={trackNum}
|
||||
className="relative flex"
|
||||
style={{ height: TRACK_H, backgroundColor: ts.row }}
|
||||
>
|
||||
{/* Gutter: colored icon badge (Figma HyperFrames style) */}
|
||||
<div className="flex-shrink-0 flex items-center justify-center" style={{ width: GUTTER }}>
|
||||
<div
|
||||
className="flex-shrink-0 flex items-center justify-center"
|
||||
style={{ width: GUTTER }}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-center"
|
||||
style={{
|
||||
@@ -314,7 +366,9 @@ export const Timeline = memo(function Timeline({ onSeek, onDrillDown }: Timeline
|
||||
backgroundImage: isComposition
|
||||
? `repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.08) 3px, rgba(255,255,255,0.08) 6px)`
|
||||
: undefined,
|
||||
border: isSelected ? `2px solid rgba(255,255,255,0.9)` : `1px solid rgba(255,255,255,${isHovered ? 0.3 : 0.15})`,
|
||||
border: isSelected
|
||||
? `2px solid rgba(255,255,255,0.9)`
|
||||
: `1px solid rgba(255,255,255,${isHovered ? 0.3 : 0.15})`,
|
||||
boxShadow: isSelected
|
||||
? `0 0 0 1px ${style.clip}, 0 2px 8px rgba(0,0,0,0.4)`
|
||||
: isBeingEdited
|
||||
@@ -327,9 +381,11 @@ export const Timeline = memo(function Timeline({ onSeek, onDrillDown }: Timeline
|
||||
transform: isHovered && !isSelected ? "scaleY(1.04)" : "scaleY(1)",
|
||||
zIndex: isSelected ? 10 : isHovered ? 5 : 1,
|
||||
}}
|
||||
title={isComposition
|
||||
? `${el.compositionSrc} \u2022 Double-click to open`
|
||||
: `${el.id || el.tag} \u2022 ${el.start.toFixed(1)}s \u2013 ${(el.start + el.duration).toFixed(1)}s`}
|
||||
title={
|
||||
isComposition
|
||||
? `${el.compositionSrc} \u2022 Double-click to open`
|
||||
: `${el.id || el.tag} \u2022 ${el.start.toFixed(1)}s \u2013 ${(el.start + el.duration).toFixed(1)}s`
|
||||
}
|
||||
onPointerEnter={() => setHoveredClip(clipKey)}
|
||||
onPointerLeave={() => setHoveredClip(null)}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
@@ -370,8 +426,19 @@ export const Timeline = memo(function Timeline({ onSeek, onDrillDown }: Timeline
|
||||
}}
|
||||
>
|
||||
{/* Mini cursor arrow */}
|
||||
<svg width="8" height="10" viewBox="0 0 12 16" fill="none" style={{ flexShrink: 0 }}>
|
||||
<path d="M1 1L11 7L6 8L4 14L1 1Z" fill={activeEdit.agentColor} stroke="white" strokeWidth="0.8" />
|
||||
<svg
|
||||
width="8"
|
||||
height="10"
|
||||
viewBox="0 0 12 16"
|
||||
fill="none"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<path
|
||||
d="M1 1L11 7L6 8L4 14L1 1Z"
|
||||
fill={activeEdit.agentColor}
|
||||
stroke="white"
|
||||
strokeWidth="0.8"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
className="text-[8px] font-semibold px-1 py-px rounded whitespace-nowrap"
|
||||
@@ -416,13 +483,15 @@ export const Timeline = memo(function Timeline({ onSeek, onDrillDown }: Timeline
|
||||
>
|
||||
<div className="absolute top-0 bottom-0 left-1/2 -translate-x-1/2 w-px bg-white/90" />
|
||||
<div className="absolute left-1/2 -translate-x-1/2" style={{ top: 0 }}>
|
||||
<div style={{
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: "5px solid transparent",
|
||||
borderRight: "5px solid transparent",
|
||||
borderTop: "7px solid rgba(255,255,255,0.95)",
|
||||
}} />
|
||||
<div
|
||||
style={{
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: "5px solid transparent",
|
||||
borderRight: "5px solid transparent",
|
||||
borderTop: "7px solid rgba(255,255,255,0.95)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -119,7 +119,9 @@ function autoHealMissingCompositionIds(doc: Document): void {
|
||||
for (const compId of referencedIds) {
|
||||
if (compId === "root" || existingIds.has(compId)) continue;
|
||||
const host =
|
||||
doc.getElementById(`${compId}-layer`) || doc.getElementById(`${compId}-comp`) || doc.getElementById(compId);
|
||||
doc.getElementById(`${compId}-layer`) ||
|
||||
doc.getElementById(`${compId}-comp`) ||
|
||||
doc.getElementById(compId);
|
||||
if (!host) continue;
|
||||
if (!host.getAttribute("data-composition-id")) {
|
||||
host.setAttribute("data-composition-id", compId);
|
||||
@@ -205,19 +207,30 @@ export function useTimelinePlayer() {
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe) return;
|
||||
// Send to runtime via bridge (works with both new and CDN runtime)
|
||||
iframe.contentWindow?.postMessage({ source: "hf-parent", type: "control", action: "set-playback-rate", playbackRate: rate }, "*");
|
||||
iframe.contentWindow?.postMessage({ source: "hf-parent", type: "control", action: "set-playback-rate", playbackRate: rate }, "*");
|
||||
iframe.contentWindow?.postMessage(
|
||||
{ source: "hf-parent", type: "control", action: "set-playback-rate", playbackRate: rate },
|
||||
"*",
|
||||
);
|
||||
iframe.contentWindow?.postMessage(
|
||||
{ source: "hf-parent", type: "control", action: "set-playback-rate", playbackRate: rate },
|
||||
"*",
|
||||
);
|
||||
// Also set directly on GSAP timeline if accessible
|
||||
try {
|
||||
const win = iframe.contentWindow as IframeWindow | null;
|
||||
if (win?.__timelines) {
|
||||
for (const tl of Object.values(win.__timelines)) {
|
||||
if (tl && typeof (tl as unknown as { timeScale?: (v: number) => void }).timeScale === "function") {
|
||||
if (
|
||||
tl &&
|
||||
typeof (tl as unknown as { timeScale?: (v: number) => void }).timeScale === "function"
|
||||
) {
|
||||
(tl as unknown as { timeScale: (v: number) => void }).timeScale(rate);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* cross-origin */ }
|
||||
} catch {
|
||||
/* cross-origin */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const play = useCallback(() => {
|
||||
@@ -388,13 +401,22 @@ export function useTimelinePlayer() {
|
||||
console.warn("Could not find __player, __timeline, or __timelines on iframe after 5s");
|
||||
}
|
||||
}, 200);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- setElements is a stable zustand setter
|
||||
}, [getAdapter, setDuration, setCurrentTime, setTimelineReady, setIsPlaying, processTimelineMessage]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- setElements is a stable zustand setter
|
||||
}, [
|
||||
getAdapter,
|
||||
setDuration,
|
||||
setCurrentTime,
|
||||
setTimelineReady,
|
||||
setIsPlaying,
|
||||
processTimelineMessage,
|
||||
]);
|
||||
|
||||
/** Save the current playback time so the next onIframeLoad restores it. */
|
||||
const saveSeekPosition = useCallback(() => {
|
||||
const adapter = getAdapter();
|
||||
pendingSeekRef.current = adapter ? adapter.getTime() : (usePlayerStore.getState().currentTime ?? 0);
|
||||
pendingSeekRef.current = adapter
|
||||
? adapter.getTime()
|
||||
: (usePlayerStore.getState().currentTime ?? 0);
|
||||
isRefreshingRef.current = true;
|
||||
stopRAFLoop();
|
||||
setIsPlaying(false);
|
||||
@@ -430,7 +452,11 @@ export function useTimelinePlayer() {
|
||||
// so we get the complete clip list (not just the first few).
|
||||
const handleMessage = (e: MessageEvent) => {
|
||||
const data = e.data;
|
||||
if ((data?.source === "hf-preview" || data?.source === "hf-preview") && data?.type === "timeline" && Array.isArray(data.clips)) {
|
||||
if (
|
||||
(data?.source === "hf-preview" || data?.source === "hf-preview") &&
|
||||
data?.type === "timeline" &&
|
||||
Array.isArray(data.clips)
|
||||
) {
|
||||
processTimelineMessageRef.current(data);
|
||||
// Update duration only if the new value is longer (don't downgrade during generation)
|
||||
if (data.durationInFrames > 0) {
|
||||
|
||||
@@ -48,7 +48,11 @@ export function resolveSourceFile(
|
||||
if (classMatch) {
|
||||
const cls = classMatch[1];
|
||||
for (const [path, content] of Object.entries(files)) {
|
||||
if (content.includes(`class="${cls}"`) || content.includes(`class="${cls} `) || content.includes(` ${cls}"`)) {
|
||||
if (
|
||||
content.includes(`class="${cls}"`) ||
|
||||
content.includes(`class="${cls} `) ||
|
||||
content.includes(` ${cls}"`)
|
||||
) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user