mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 23:00:03 +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,98 @@
|
||||
import { memo } from "react";
|
||||
|
||||
const TRACK_H = 20;
|
||||
const GUTTER = 32;
|
||||
|
||||
export interface AgentActivity {
|
||||
agentId: string;
|
||||
name: string;
|
||||
color: string;
|
||||
/** Active work periods mapped to VIDEO time (not wall clock) */
|
||||
periods: Array<{ start: number; end: number }>;
|
||||
/** Element creation events at specific video times */
|
||||
events: Array<{ time: number; type: "create" | "modify" }>;
|
||||
}
|
||||
|
||||
interface AgentActivityTrackProps {
|
||||
agents: AgentActivity[];
|
||||
duration: number;
|
||||
}
|
||||
|
||||
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>
|
||||
Agent Activity
|
||||
</div>
|
||||
|
||||
{agents.map((agent) => (
|
||||
<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>
|
||||
|
||||
{/* Lane */}
|
||||
<div className="flex-1 relative" style={{ backgroundColor: `${agent.color}06` }}>
|
||||
{/* Active work periods */}
|
||||
{agent.periods.map((period, i) => {
|
||||
const leftPct = (period.start / duration) * 100;
|
||||
const widthPct = ((period.end - period.start) / duration) * 100;
|
||||
return (
|
||||
<div
|
||||
key={`period-${i}`}
|
||||
className="absolute top-1 bottom-1 rounded-sm"
|
||||
style={{
|
||||
left: `${leftPct}%`,
|
||||
width: `${Math.max(widthPct, 0.5)}%`,
|
||||
backgroundColor: `${agent.color}30`,
|
||||
border: `1px solid ${agent.color}20`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Events: diamonds for create, circles for modify */}
|
||||
{agent.events.map((event, i) => {
|
||||
const leftPct = (event.time / duration) * 100;
|
||||
return (
|
||||
<div
|
||||
key={`event-${i}`}
|
||||
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2"
|
||||
style={{ left: `${leftPct}%` }}
|
||||
>
|
||||
{event.type === "create" ? (
|
||||
<div
|
||||
className="w-2 h-2 rotate-45"
|
||||
style={{ backgroundColor: agent.color }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-1.5 h-1.5 rounded-full"
|
||||
style={{ backgroundColor: agent.color }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { forwardRef, useRef, useState, useCallback } from "react";
|
||||
import { useMountEffect } from "../lib/useMountEffect";
|
||||
|
||||
const NATIVE_W = 1920;
|
||||
const NATIVE_H = 1080;
|
||||
|
||||
interface PlayerProps {
|
||||
projectId?: string;
|
||||
directUrl?: string;
|
||||
onLoad: () => void;
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
// 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 };
|
||||
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
|
||||
}
|
||||
|
||||
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]);
|
||||
|
||||
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";
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useRef, useState, useCallback, memo } from "react";
|
||||
import { formatTime } from "../lib/time";
|
||||
import { usePlayerStore, liveTime } from "../store/playerStore";
|
||||
import { useMountEffect } from "../lib/useMountEffect";
|
||||
|
||||
const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
|
||||
|
||||
interface PlayerControlsProps {
|
||||
/** @deprecated Pass via store — kept for backwards compat */
|
||||
isPlaying?: boolean;
|
||||
/** @deprecated Pass via store — kept for backwards compat */
|
||||
duration?: number;
|
||||
/** @deprecated Pass via store — kept for backwards compat */
|
||||
timelineReady?: boolean;
|
||||
onTogglePlay: () => void;
|
||||
onSeek: (time: number) => void;
|
||||
}
|
||||
|
||||
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);
|
||||
const storeTimelineReady = usePlayerStore((s) => s.timelineReady);
|
||||
const playbackRate = usePlayerStore((s) => s.playbackRate);
|
||||
const setPlaybackRate = usePlayerStore.getState().setPlaybackRate;
|
||||
const [showSpeedMenu, setShowSpeedMenu] = useState(false);
|
||||
|
||||
const isPlaying = overrides.isPlaying ?? storeIsPlaying;
|
||||
const duration = overrides.duration ?? storeDuration;
|
||||
const timelineReady = overrides.timelineReady ?? storeTimelineReady;
|
||||
|
||||
const progressFillRef = useRef<HTMLDivElement>(null);
|
||||
const progressThumbRef = useRef<HTMLDivElement>(null);
|
||||
const timeDisplayRef = useRef<HTMLSpanElement>(null);
|
||||
const seekBarRef = useRef<HTMLDivElement>(null);
|
||||
const isDraggingRef = useRef(false);
|
||||
const currentTimeRef = useRef(0);
|
||||
|
||||
const durationRef = useRef(duration);
|
||||
durationRef.current = duration;
|
||||
useMountEffect(() => {
|
||||
const unsub = liveTime.subscribe((t) => {
|
||||
currentTimeRef.current = t;
|
||||
const dur = durationRef.current;
|
||||
const pct = dur > 0 ? (t / dur) * 100 : 0;
|
||||
if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`;
|
||||
if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`;
|
||||
if (timeDisplayRef.current) timeDisplayRef.current.textContent = formatTime(t);
|
||||
});
|
||||
return unsub;
|
||||
});
|
||||
|
||||
const seekFromClientX = useCallback(
|
||||
(clientX: number) => {
|
||||
const bar = seekBarRef.current;
|
||||
if (!bar || duration <= 0) return;
|
||||
const rect = bar.getBoundingClientRect();
|
||||
const percent = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||
onSeek(percent * duration);
|
||||
},
|
||||
[duration, onSeek],
|
||||
);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
isDraggingRef.current = true;
|
||||
seekFromClientX(e.clientX);
|
||||
|
||||
const onMouseMove = (me: MouseEvent) => {
|
||||
if (isDraggingRef.current) seekFromClientX(me.clientX);
|
||||
};
|
||||
const onMouseUp = () => {
|
||||
isDraggingRef.current = false;
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
},
|
||||
[seekFromClientX],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (!timelineReady || duration <= 0) return;
|
||||
const step = e.shiftKey ? 5 : 1;
|
||||
if (e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
onSeek(Math.max(0, currentTimeRef.current - step));
|
||||
} else if (e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
onSeek(Math.min(duration, currentTimeRef.current + step));
|
||||
}
|
||||
},
|
||||
[timelineReady, duration, onSeek],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="px-3 py-2 flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isPlaying ? "Pause" : "Play"}
|
||||
onClick={onTogglePlay}
|
||||
disabled={!timelineReady}
|
||||
className="flex-shrink-0 w-7 h-7 flex items-center justify-center rounded-md text-neutral-300 hover:text-white hover:bg-neutral-800 disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" 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="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<span className="text-neutral-500 font-mono text-xs tabular-nums flex-shrink-0 min-w-[80px]">
|
||||
<span ref={timeDisplayRef}>{formatTime(0)}</span>
|
||||
<span className="text-neutral-700 mx-0.5">/</span>
|
||||
<span className="text-neutral-600">{formatTime(duration)}</span>
|
||||
</span>
|
||||
|
||||
<div
|
||||
ref={seekBarRef}
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
aria-label="Seek"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={Math.round(duration)}
|
||||
aria-valuenow={0}
|
||||
className="flex-1 h-6 flex items-center cursor-pointer group"
|
||||
style={{ touchAction: "manipulation" }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div className="w-full h-[3px] bg-neutral-800 rounded-full relative">
|
||||
<div
|
||||
ref={progressFillRef}
|
||||
className="absolute inset-y-0 left-0 bg-white/80 rounded-full"
|
||||
style={{ width: 0 }}
|
||||
/>
|
||||
<div
|
||||
ref={progressThumbRef}
|
||||
className="absolute top-1/2 w-2 h-2 bg-white rounded-full -translate-y-1/2 -translate-x-1/2 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity"
|
||||
style={{ left: 0 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Speed control */}
|
||||
<div className="relative flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSpeedMenu((v) => !v)}
|
||||
className="px-1.5 py-0.5 rounded text-[11px] font-mono tabular-nums text-neutral-500 hover:text-neutral-200 hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
{playbackRate === 1 ? "1x" : `${playbackRate}x`}
|
||||
</button>
|
||||
{showSpeedMenu && (
|
||||
<div className="absolute bottom-full right-0 mb-1 py-1 bg-neutral-900 border border-neutral-700 rounded-lg shadow-xl z-50 min-w-[60px]">
|
||||
{SPEED_OPTIONS.map((rate) => (
|
||||
<button
|
||||
key={rate}
|
||||
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}x
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { ReactNode, Ref } from "react";
|
||||
import { Player } from "./Player";
|
||||
import { PlayerControls } from "./PlayerControls";
|
||||
import { Timeline } from "./Timeline";
|
||||
|
||||
interface RenderStatus {
|
||||
state: "idle" | "rendering" | "complete" | "error";
|
||||
stage?: string;
|
||||
progress?: number;
|
||||
error?: string;
|
||||
onRender?: () => void;
|
||||
}
|
||||
|
||||
interface PreviewPanelProps {
|
||||
projectId: string | null;
|
||||
hasProject: boolean;
|
||||
portrait: boolean;
|
||||
iframeRef: Ref<HTMLIFrameElement>;
|
||||
onIframeLoad: () => void;
|
||||
onTogglePlay: () => void;
|
||||
onSeek: (t: number) => void;
|
||||
/** Optional render status — pass to show rendering progress/state */
|
||||
renderStatus?: RenderStatus;
|
||||
/** Optional slot for custom content below the timeline */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function PreviewPanel({
|
||||
projectId,
|
||||
hasProject,
|
||||
portrait,
|
||||
iframeRef,
|
||||
onIframeLoad,
|
||||
onTogglePlay,
|
||||
onSeek,
|
||||
renderStatus,
|
||||
children,
|
||||
}: PreviewPanelProps) {
|
||||
|
||||
const renderState = renderStatus?.state ?? "idle";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-w-0 overflow-hidden"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateRows: hasProject && projectId ? "1fr auto auto auto" : "1fr",
|
||||
height: "100%",
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
|
||||
{/* Controls — fixed height */}
|
||||
<div className="bg-neutral-950 border-t border-neutral-800 flex-shrink-0">
|
||||
<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" }}>
|
||||
<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}%` }}
|
||||
/>
|
||||
</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"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Optional custom slot */}
|
||||
{children}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center w-full min-w-0">
|
||||
<div className="text-center w-full">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-card bg-neutral-900 flex items-center justify-center">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-neutral-600"
|
||||
>
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
import { useRef, useMemo, useCallback, useState, memo, type ReactNode } from "react";
|
||||
import { usePlayerStore, liveTime } from "../store/playerStore";
|
||||
import { useMountEffect } from "../lib/useMountEffect";
|
||||
|
||||
/* ── Layout ─────────────────────────────────────────────────────── */
|
||||
const GUTTER = 32;
|
||||
const TRACK_H = 28;
|
||||
const RULER_H = 24;
|
||||
const CLIP_Y = 2; // vertical inset inside track
|
||||
|
||||
/* ── Vibrant Color System (Figma-inspired, dark-mode adapted) ──── */
|
||||
interface TrackStyle {
|
||||
/** Clip solid background */
|
||||
clip: string;
|
||||
/** Dark text color for label on clip */
|
||||
label: string;
|
||||
/** Track row tint (very subtle) */
|
||||
row: string;
|
||||
/** Gutter icon circle background */
|
||||
gutter: string;
|
||||
/** SVG icon paths (viewBox 0 0 24 24) */
|
||||
icon: ReactNode;
|
||||
}
|
||||
|
||||
/* ── 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} />;
|
||||
}
|
||||
const IconCaptions = <TimelineIcon src={`${ICON_BASE}/captions.svg`} />;
|
||||
const IconImage = <TimelineIcon src={`${ICON_BASE}/image.svg`} />;
|
||||
const IconMusic = <TimelineIcon src={`${ICON_BASE}/music.svg`} />;
|
||||
const IconText = <TimelineIcon src={`${ICON_BASE}/text.svg`} />;
|
||||
const IconComposition = <TimelineIcon src={`${ICON_BASE}/composition.svg`} />;
|
||||
const IconAudio = <TimelineIcon src={`${ICON_BASE}/audio.svg`} />;
|
||||
|
||||
const STYLES: Record<string, TrackStyle> = {
|
||||
video: {
|
||||
clip: "#1F6AFF",
|
||||
label: "#DBEAFE",
|
||||
row: "rgba(31,106,255,0.04)",
|
||||
gutter: "#1F6AFF",
|
||||
icon: IconImage,
|
||||
},
|
||||
audio: {
|
||||
clip: "#00C4FF",
|
||||
label: "#013A4B",
|
||||
row: "rgba(0,196,255,0.04)",
|
||||
gutter: "#00C4FF",
|
||||
icon: IconMusic,
|
||||
},
|
||||
img: {
|
||||
clip: "#8B5CF6",
|
||||
label: "#EDE9FE",
|
||||
row: "rgba(139,92,246,0.04)",
|
||||
gutter: "#8B5CF6",
|
||||
icon: IconImage,
|
||||
},
|
||||
div: {
|
||||
clip: "#68B200",
|
||||
label: "#1A2B03",
|
||||
row: "rgba(104,178,0,0.04)",
|
||||
gutter: "#68B200",
|
||||
icon: IconComposition,
|
||||
},
|
||||
span: {
|
||||
clip: "#F3A6FF",
|
||||
label: "#8D00A3",
|
||||
row: "rgba(243,166,255,0.04)",
|
||||
gutter: "#F3A6FF",
|
||||
icon: IconCaptions,
|
||||
},
|
||||
p: {
|
||||
clip: "#35C838",
|
||||
label: "#024A03",
|
||||
row: "rgba(53,200,56,0.04)",
|
||||
gutter: "#35C838",
|
||||
icon: IconText,
|
||||
},
|
||||
h1: {
|
||||
clip: "#35C838",
|
||||
label: "#024A03",
|
||||
row: "rgba(53,200,56,0.04)",
|
||||
gutter: "#35C838",
|
||||
icon: IconText,
|
||||
},
|
||||
section: {
|
||||
clip: "#68B200",
|
||||
label: "#1A2B03",
|
||||
row: "rgba(104,178,0,0.04)",
|
||||
gutter: "#68B200",
|
||||
icon: IconComposition,
|
||||
},
|
||||
sfx: {
|
||||
clip: "#FF8C42",
|
||||
label: "#512000",
|
||||
row: "rgba(255,140,66,0.04)",
|
||||
gutter: "#FF8C42",
|
||||
icon: IconAudio,
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT: TrackStyle = {
|
||||
clip: "#6B7280",
|
||||
label: "#F3F4F6",
|
||||
row: "rgba(107,114,128,0.03)",
|
||||
gutter: "#6B7280",
|
||||
icon: IconComposition,
|
||||
};
|
||||
|
||||
function getStyle(tag: string): TrackStyle {
|
||||
const t = tag.toLowerCase();
|
||||
if (t.startsWith("h") && t.length === 2 && "123456".includes(t[1])) return STYLES.h1;
|
||||
return STYLES[t] ?? DEFAULT;
|
||||
}
|
||||
|
||||
/* ── Tick Generation ────────────────────────────────────────────── */
|
||||
function generateTicks(duration: number): { major: number[]; minor: number[] } {
|
||||
if (duration <= 0) return { major: [], minor: [] };
|
||||
const intervals = [0.5, 1, 2, 5, 10, 15, 30, 60];
|
||||
const target = duration / 6;
|
||||
const majorInterval = intervals.find((i) => i >= target) ?? 60;
|
||||
const minorInterval = majorInterval / 2;
|
||||
const major: 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;
|
||||
if (isMajor) major.push(rounded);
|
||||
else minor.push(rounded);
|
||||
}
|
||||
return { major, minor };
|
||||
}
|
||||
|
||||
function formatTick(s: number): string {
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60);
|
||||
return `${m}:${sec.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/* ── Component ──────────────────────────────────────────────────── */
|
||||
interface TimelineProps {
|
||||
/** Called when user seeks via ruler/track click or playhead drag */
|
||||
onSeek?: (time: number) => void;
|
||||
/** Called when user double-clicks a composition clip to drill into it */
|
||||
onDrillDown?: (element: import("../store/playerStore").TimelineElement) => void;
|
||||
}
|
||||
|
||||
export const Timeline = memo(function Timeline({ onSeek, onDrillDown }: TimelineProps = {}) {
|
||||
const elements = usePlayerStore((s) => s.elements);
|
||||
const duration = usePlayerStore((s) => s.duration);
|
||||
const timelineReady = usePlayerStore((s) => s.timelineReady);
|
||||
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
|
||||
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
|
||||
const activeEdits = usePlayerStore((s) => s.activeEdits);
|
||||
const playheadRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [hoveredClip, setHoveredClip] = useState<string | null>(null);
|
||||
const isDragging = useRef(false);
|
||||
|
||||
const durationRef = useRef(duration);
|
||||
durationRef.current = duration;
|
||||
useMountEffect(() => {
|
||||
const unsub = liveTime.subscribe((t) => {
|
||||
const dur = durationRef.current;
|
||||
if (!playheadRef.current || dur <= 0) return;
|
||||
const pct = (t / dur) * 100;
|
||||
playheadRef.current.style.left = `calc(${GUTTER}px + (100% - ${GUTTER}px) * ${pct / 100})`;
|
||||
});
|
||||
return unsub;
|
||||
});
|
||||
|
||||
const seekFromX = useCallback(
|
||||
(clientX: number) => {
|
||||
const el = containerRef.current;
|
||||
if (!el || duration <= 0) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const start = rect.left + GUTTER;
|
||||
const w = rect.width - GUTTER;
|
||||
if (w <= 0) return;
|
||||
const pct = Math.max(0, Math.min(1, (clientX - start) / w));
|
||||
const time = pct * duration;
|
||||
// Notify liveTime for instant visual update (direct DOM, no re-render)
|
||||
liveTime.notify(time);
|
||||
// Call parent's onSeek to actually seek the iframe/player
|
||||
onSeek?.(time);
|
||||
},
|
||||
[duration, onSeek],
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if ((e.target as HTMLElement).closest("[data-clip]")) return;
|
||||
isDragging.current = true;
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
seekFromX(e.clientX);
|
||||
},
|
||||
[seekFromX],
|
||||
);
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent) => { if (isDragging.current) seekFromX(e.clientX); },
|
||||
[seekFromX],
|
||||
);
|
||||
const handlePointerUp = useCallback(() => { isDragging.current = false; }, []);
|
||||
|
||||
const tracks = useMemo(() => {
|
||||
const map = new Map<number, typeof elements>();
|
||||
for (const el of elements) {
|
||||
const list = map.get(el.track) ?? [];
|
||||
list.push(el);
|
||||
map.set(el.track, list);
|
||||
}
|
||||
return Array.from(map.entries()).sort(([a], [b]) => a - b);
|
||||
}, [elements]);
|
||||
|
||||
// Determine dominant style per track (from first element)
|
||||
const trackStyles = useMemo(() => {
|
||||
const map = new Map<number, TrackStyle>();
|
||||
for (const [trackNum, els] of tracks) {
|
||||
map.set(trackNum, getStyle(els[0]?.tag ?? ""));
|
||||
}
|
||||
return map;
|
||||
}, [tracks]);
|
||||
|
||||
const { major, minor } = useMemo(() => generateTicks(duration), [duration]);
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
const totalH = RULER_H + tracks.length * TRACK_H;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
aria-label="Timeline"
|
||||
className="border-t border-neutral-800/50 bg-[#0a0a0b] select-none overflow-x-hidden cursor-crosshair"
|
||||
style={{ touchAction: "none" }}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
>
|
||||
<div className="relative" style={{ height: totalH }}>
|
||||
{/* Grid lines */}
|
||||
<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" />
|
||||
))}
|
||||
</svg>
|
||||
|
||||
{/* Ruler */}
|
||||
<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 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 className="w-px h-[5px] bg-neutral-600/60" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tracks */}
|
||||
{tracks.map(([trackNum, els]) => {
|
||||
const ts = trackStyles.get(trackNum) ?? DEFAULT;
|
||||
return (
|
||||
<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 items-center justify-center"
|
||||
style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 6,
|
||||
backgroundColor: ts.gutter,
|
||||
border: "1px solid rgba(255,255,255,0.35)",
|
||||
color: "#fff",
|
||||
}}
|
||||
>
|
||||
{ts.icon}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clips */}
|
||||
<div className="flex-1 relative">
|
||||
{els.map((el, i) => {
|
||||
const leftPct = (el.start / duration) * 100;
|
||||
const widthPct = (el.duration / duration) * 100;
|
||||
const style = getStyle(el.tag);
|
||||
const isSelected = selectedElementId === el.id;
|
||||
const isComposition = !!el.compositionSrc;
|
||||
const clipKey = `${el.id}-${i}`;
|
||||
const isHovered = hoveredClip === clipKey;
|
||||
const activeEdit = activeEdits[el.id];
|
||||
const isBeingEdited = !!activeEdit;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={clipKey}
|
||||
data-clip="true"
|
||||
className="absolute flex items-center overflow-hidden"
|
||||
style={{
|
||||
left: `${leftPct}%`,
|
||||
width: `${Math.max(widthPct, 1)}%`,
|
||||
top: CLIP_Y,
|
||||
bottom: CLIP_Y,
|
||||
borderRadius: 5,
|
||||
backgroundColor: style.clip,
|
||||
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})`,
|
||||
boxShadow: isSelected
|
||||
? `0 0 0 1px ${style.clip}, 0 2px 8px rgba(0,0,0,0.4)`
|
||||
: isBeingEdited
|
||||
? `0 0 0 1px ${activeEdit.agentColor}80, 0 0 8px ${activeEdit.agentColor}40`
|
||||
: isHovered
|
||||
? "0 1px 4px rgba(0,0,0,0.3)"
|
||||
: "none",
|
||||
cursor: "pointer",
|
||||
transition: "border-color 120ms, box-shadow 120ms, transform 80ms",
|
||||
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`}
|
||||
onPointerEnter={() => setHoveredClip(clipKey)}
|
||||
onPointerLeave={() => setHoveredClip(null)}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedElementId(isSelected ? null : el.id);
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (isComposition && onDrillDown) {
|
||||
onDrillDown(el);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Agent ownership dot */}
|
||||
{el.agentColor && (
|
||||
<div
|
||||
className="flex-shrink-0 w-1.5 h-1.5 rounded-full ml-1"
|
||||
style={{ backgroundColor: el.agentColor }}
|
||||
title={el.agentId ? `Agent: ${el.agentId}` : undefined}
|
||||
/>
|
||||
)}
|
||||
{/* Editing glow pulse */}
|
||||
{/* Agent editing indicator — cursor on the clip */}
|
||||
{isBeingEdited && (
|
||||
<>
|
||||
<div
|
||||
className="absolute inset-0 rounded-[5px] animate-pulse pointer-events-none"
|
||||
style={{ boxShadow: `inset 0 0 0 1px ${activeEdit.agentColor}60` }}
|
||||
/>
|
||||
{/* Agent name badge above clip */}
|
||||
<div
|
||||
className="absolute pointer-events-none flex items-center gap-1"
|
||||
style={{
|
||||
top: -16,
|
||||
left: 2,
|
||||
zIndex: 30,
|
||||
}}
|
||||
>
|
||||
{/* 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>
|
||||
<span
|
||||
className="text-[8px] font-semibold px-1 py-px rounded whitespace-nowrap"
|
||||
style={{
|
||||
backgroundColor: activeEdit.agentColor,
|
||||
color: "white",
|
||||
boxShadow: `0 1px 4px ${activeEdit.agentColor}40`,
|
||||
}}
|
||||
>
|
||||
{activeEdit.agentId}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<span
|
||||
className="text-[10px] font-semibold truncate px-1.5 leading-none"
|
||||
style={{ color: style.label }}
|
||||
>
|
||||
{el.id || el.tag}
|
||||
</span>
|
||||
{widthPct > 10 && (
|
||||
<span
|
||||
className="text-[9px] font-mono tabular-nums pr-1.5 ml-auto flex-shrink-0 leading-none opacity-70"
|
||||
style={{ color: style.label }}
|
||||
>
|
||||
{el.duration.toFixed(1)}s
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Playhead */}
|
||||
<div
|
||||
ref={playheadRef}
|
||||
className="absolute top-0 bottom-0 z-20 pointer-events-none"
|
||||
style={{ left: `${GUTTER}px` }}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user