Merge pull request #994 from heygen-com/worktree-feat-timeline-ui

feat(studio): timeline UI overhaul — flat clips, thumbnails, visual cleanup
This commit is contained in:
Miguel Ángel
2026-05-21 06:03:53 +02:00
committed by GitHub
9 changed files with 191 additions and 189 deletions
+7 -1
View File
@@ -349,7 +349,13 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
}, opts.seekTime); }, opts.seekTime);
const manifestContent = readStudioManualEditManifestContent(opts.project.dir); const manifestContent = readStudioManualEditManifestContent(opts.project.dir);
await applyStudioManualEditsToThumbnailPage(page, manifestContent, opts.compPath); await applyStudioManualEditsToThumbnailPage(page, manifestContent, opts.compPath);
await page.evaluate(() => document.fonts?.ready); await page.evaluate(() => {
void document.fonts?.ready;
const body = document.body;
if (body && getComputedStyle(body).backgroundColor === "rgba(0, 0, 0, 0)") {
body.style.backgroundColor = "#1c2028";
}
});
await new Promise((r) => setTimeout(r, 200)); await new Promise((r) => setTimeout(r, 200));
await reapplyStudioManualEditsToThumbnailPage(page); await reapplyStudioManualEditsToThumbnailPage(page);
let clip: ScreenshotClip | undefined; let clip: ScreenshotClip | undefined;
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { normalizeCompositionSrc } from "./useRenderClipContent";
describe("normalizeCompositionSrc", () => {
const origin = "http://localhost:5190";
const pid = "my-project";
it("strips absolute preview URL to relative path", () => {
const result = normalizeCompositionSrc(
"http://localhost:5190/api/projects/my-project/preview/compositions/intro.html",
pid,
origin,
);
expect(result).toBe("compositions/intro.html");
});
it("preserves already-relative paths", () => {
const result = normalizeCompositionSrc("compositions/intro.html", pid, origin);
expect(result).toBe("compositions/intro.html");
});
it("preserves absolute URLs from different origins", () => {
const result = normalizeCompositionSrc(
"https://cdn.example.com/compositions/intro.html",
pid,
origin,
);
expect(result).toBe("https://cdn.example.com/compositions/intro.html");
});
it("preserves absolute URLs for different projects", () => {
const result = normalizeCompositionSrc(
"http://localhost:5190/api/projects/other-project/preview/compositions/intro.html",
pid,
origin,
);
expect(result).toBe(
"http://localhost:5190/api/projects/other-project/preview/compositions/intro.html",
);
});
it("handles nested composition paths", () => {
const result = normalizeCompositionSrc(
"http://localhost:5190/api/projects/my-project/preview/compositions/scenes/hero.html",
pid,
origin,
);
expect(result).toBe("compositions/scenes/hero.html");
});
});
@@ -5,6 +5,23 @@ import type { TimelineElement } from "../player";
import { AudioWaveform } from "../player/components/AudioWaveform"; import { AudioWaveform } from "../player/components/AudioWaveform";
import { getTimelineElementLabel } from "../utils/studioHelpers"; import { getTimelineElementLabel } from "../utils/studioHelpers";
export function normalizeCompositionSrc(
compSrc: string,
projectId: string,
origin: string,
): string {
try {
const parsed = new URL(compSrc, origin);
const previewPrefix = `/api/projects/${projectId}/preview/`;
if (parsed.pathname.startsWith(previewPrefix)) {
return parsed.pathname.slice(previewPrefix.length);
}
} catch {
// already relative
}
return compSrc;
}
interface UseRenderClipContentOptions { interface UseRenderClipContentOptions {
projectIdRef: { current: string | null }; projectIdRef: { current: string | null };
compIdToSrc: Map<string, string>; compIdToSrc: Map<string, string>;
@@ -23,8 +40,10 @@ export function useRenderClipContent({
const pid = projectIdRef.current; const pid = projectIdRef.current;
if (!pid) return null; if (!pid) return null;
// Resolve composition source path using the compIdToSrc map
let compSrc = el.compositionSrc; let compSrc = el.compositionSrc;
if (compSrc) {
compSrc = normalizeCompositionSrc(compSrc, pid, window.location.origin);
}
if (compSrc && compIdToSrc.size > 0) { if (compSrc && compIdToSrc.size > 0) {
const resolved = const resolved =
compIdToSrc.get(el.id) || compIdToSrc.get(el.id) ||
@@ -40,7 +59,7 @@ export function useRenderClipContent({
previewUrl: `/api/projects/${pid}/preview/comp/${compSrc}`, previewUrl: `/api/projects/${pid}/preview/comp/${compSrc}`,
label: getTimelineElementLabel(el), label: getTimelineElementLabel(el),
labelColor: style.label, labelColor: style.label,
accentColor: style.clip,
seekTime: 0, seekTime: 0,
duration: el.duration, duration: el.duration,
}); });
@@ -53,7 +72,7 @@ export function useRenderClipContent({
previewUrl: activePreviewUrl, previewUrl: activePreviewUrl,
label: getTimelineElementLabel(el), label: getTimelineElementLabel(el),
labelColor: style.label, labelColor: style.label,
accentColor: style.clip,
selector: el.selector, selector: el.selector,
selectorIndex: el.selectorIndex, selectorIndex: el.selectorIndex,
seekTime: el.start, seekTime: el.start,
@@ -109,7 +128,7 @@ export function useRenderClipContent({
previewUrl: `/api/projects/${pid}/preview`, previewUrl: `/api/projects/${pid}/preview`,
label: getTimelineElementLabel(el), label: getTimelineElementLabel(el),
labelColor: style.label, labelColor: style.label,
accentColor: style.clip,
selector: el.selector, selector: el.selector,
selectorIndex: el.selectorIndex, selectorIndex: el.selectorIndex,
seekTime: el.start, seekTime: el.start,
@@ -5,7 +5,6 @@ interface CompositionThumbnailProps {
previewUrl: string; previewUrl: string;
label: string; label: string;
labelColor: string; labelColor: string;
accentColor?: string;
selector?: string; selector?: string;
selectorIndex?: number; selectorIndex?: number;
seekTime?: number; seekTime?: number;
@@ -16,7 +15,6 @@ interface CompositionThumbnailProps {
const CLIP_HEIGHT = 66; const CLIP_HEIGHT = 66;
const THUMBNAIL_URL_VERSION = "v3"; const THUMBNAIL_URL_VERSION = "v3";
const COMPOSITION_THUMBNAIL_LABEL_Z_INDEX = 10;
export function buildCompositionThumbnailUrl({ export function buildCompositionThumbnailUrl({
previewUrl, previewUrl,
@@ -53,7 +51,6 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
previewUrl, previewUrl,
label, label,
labelColor, labelColor,
accentColor = "#6B7280",
selector, selector,
selectorIndex, selectorIndex,
seekTime = 2, seekTime = 2,
@@ -110,8 +107,11 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
className="hidden" className="hidden"
/> />
{loaded ? ( {loaded && (
<div className="absolute inset-0 flex"> <div
className="absolute inset-0 flex"
style={{ animation: "hf-thumb-fade 200ms ease-out", mixBlendMode: "lighten" }}
>
{Array.from({ length: frameCount }).map((_, i) => ( {Array.from({ length: frameCount }).map((_, i) => (
<div <div
key={i} key={i}
@@ -122,59 +122,25 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
src={url} src={url}
alt="" alt=""
draggable={false} draggable={false}
className="absolute inset-0 h-full w-full object-cover opacity-60" className="absolute inset-0 h-full w-full object-cover"
style={{ opacity: 0.7 }}
/> />
</div> </div>
))} ))}
</div> </div>
) : (
<div
className="absolute inset-0 animate-pulse"
style={{
background:
"linear-gradient(90deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.05) 50%, rgba(255,255,255,0.02) 100%)",
}}
/>
)} )}
<div <div className="absolute left-3 top-0 bottom-0 flex items-center" style={{ zIndex: 10 }}>
className="absolute inset-0"
style={{
background: `linear-gradient(120deg, ${accentColor}2e, transparent 34%), linear-gradient(180deg, rgba(255,255,255,0.02), rgba(0,0,0,0.08))`,
}}
/>
<div
className="absolute left-2 top-2"
style={{ zIndex: COMPOSITION_THUMBNAIL_LABEL_Z_INDEX }}
>
<span <span
className="block max-w-full truncate rounded-md px-1.5 py-0.5 text-[9px] font-semibold uppercase leading-none" className="block max-w-full truncate text-[10px] font-semibold leading-none"
style={{ style={{
color: labelColor, color: labelColor,
background: `${accentColor}2e`, textShadow: loaded ? "0 1px 4px rgba(0,0,0,0.9), 0 0 8px rgba(0,0,0,0.6)" : "none",
boxShadow: `inset 0 0 0 1px ${accentColor}40`,
}} }}
> >
{label} {label}
</span> </span>
</div> </div>
<div
className="absolute bottom-0 left-0 right-0 px-1.5 pb-0.5 pt-3"
style={{
zIndex: COMPOSITION_THUMBNAIL_LABEL_Z_INDEX,
background:
"linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 60%, transparent 100%)",
}}
>
<span
className="block truncate text-[9px] font-semibold leading-tight"
style={{ color: labelColor, textShadow: "0 1px 2px rgba(0,0,0,0.9)" }}
>
{label}
</span>
</div>
</div> </div>
); );
}); });
@@ -10,7 +10,6 @@ import { getRenderedTimelineElement, type TimelineTheme } from "./timelineTheme"
import { GUTTER, TRACK_H, RULER_H, CLIP_Y, CLIP_HANDLE_W } from "./timelineLayout"; import { GUTTER, TRACK_H, RULER_H, CLIP_Y, CLIP_HANDLE_W } from "./timelineLayout";
import type { TimelineElement } from "../store/playerStore"; import type { TimelineElement } from "../store/playerStore";
import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag"; import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag";
import { formatTime } from "../lib/time";
import type { TrackVisualStyle } from "./timelineIcons"; import type { TrackVisualStyle } from "./timelineIcons";
interface TimelineCanvasProps { interface TimelineCanvasProps {
@@ -134,28 +133,16 @@ export const TimelineCanvas = memo(function TimelineCanvas({
className={ className={
renderClipContent renderClipContent
? "absolute inset-0 overflow-hidden" ? "absolute inset-0 overflow-hidden"
: "flex flex-col justify-center overflow-hidden flex-1 min-w-0 px-6" : "flex items-center overflow-hidden flex-1 min-w-0 px-3 gap-2"
} }
> >
{renderClipContent?.(element, clipStyle) ?? ( {renderClipContent?.(element, clipStyle) ?? (
<div className="flex h-full min-h-0 flex-col justify-between py-3"> <span
<span className="truncate text-[10px] font-medium leading-none"
className="max-w-full truncate rounded-md px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em] leading-none" style={{ color: clipStyle.label }}
style={{ >
color: clipStyle.label, {element.label || element.id || element.tag}
background: `${clipStyle.accent}26`, </span>
boxShadow: `inset 0 0 0 1px ${clipStyle.accent}33`,
}}
>
{element.tag}
</span>
<span
className="max-w-full truncate rounded-md px-1.5 py-0.5 text-[10px] font-medium tabular-nums leading-none"
style={{ color: theme.textSecondary, background: "rgba(255,255,255,0.04)" }}
>
{formatTime(element.start)} {"→"} {formatTime(element.start + element.duration)}
</span>
</div>
)} )}
</div> </div>
</> </>
@@ -221,10 +208,9 @@ export const TimelineCanvas = memo(function TimelineCanvas({
paddingLeft: 16, paddingLeft: 16,
color: ts.label, color: ts.label,
fontSize: 11, fontSize: 11,
letterSpacing: "0.08em", letterSpacing: "0.06em",
textTransform: "uppercase", textTransform: "uppercase",
background: `linear-gradient(90deg, ${ts.accent}14, transparent 28%)`, opacity: 0.5,
boxShadow: `inset 0 0 0 1px ${ts.accent}24`,
}} }}
> >
New track New track
@@ -51,14 +51,14 @@ export const TimelineClip = memo(function TimelineClip({
const handleOpacity = getClipHandleOpacity({ isHovered, isSelected, isDragging }); const handleOpacity = getClipHandleOpacity({ isHovered, isSelected, isDragging });
const borderColor = isSelected const borderColor = isSelected
? theme.clipBorderActive ? trackStyle.accent + "60"
: isHovered : isHovered
? theme.clipBorderHover ? theme.clipBorderHover
: theme.clipBorder; : theme.clipBorder;
const boxShadow = isDragging const boxShadow = isDragging
? theme.clipShadowDragging ? theme.clipShadowDragging
: isSelected : isSelected
? theme.clipShadowActive ? `0 0 0 1px ${trackStyle.accent}40`
: isHovered : isHovered
? theme.clipShadowHover ? theme.clipShadowHover
: theme.clipShadow; : theme.clipShadow;
@@ -77,20 +77,14 @@ export const TimelineClip = memo(function TimelineClip({
top: clipY, top: clipY,
bottom: clipY, bottom: clipY,
borderRadius: theme.clipRadius, borderRadius: theme.clipRadius,
background: isSelected background: trackStyle.clip,
? `linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0)), linear-gradient(120deg, ${trackStyle.accent}22, transparent 28%), ${theme.clipBackgroundActive}`
: `linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0)), linear-gradient(120deg, ${trackStyle.accent}1e, transparent 28%), ${theme.clipBackground}`,
backgroundImage:
isComposition && !hasCustomContent
? `repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.05) 3px, rgba(255,255,255,0.05) 6px)`
: undefined,
border: `1px solid ${borderColor}`, border: `1px solid ${borderColor}`,
boxShadow, boxShadow,
transition: transition: "border-color 100ms, box-shadow 100ms",
"border-color 120ms ease-out, box-shadow 140ms ease-out, background 140ms ease-out",
zIndex: isDragging ? 20 : isSelected ? 10 : isHovered ? 5 : 1, zIndex: isDragging ? 20 : isSelected ? 10 : isHovered ? 5 : 1,
cursor: capabilities.canMove ? "grab" : "default", cursor: capabilities.canMove ? "grab" : "default",
transform: isDragging ? "translateY(-1px)" : undefined, transform: isDragging ? "translateY(-1px)" : undefined,
opacity: isDragging ? 0.92 : 1,
}} }}
title={ title={
isComposition isComposition
@@ -103,78 +97,80 @@ export const TimelineClip = memo(function TimelineClip({
onClick={onClick} onClick={onClick}
onDoubleClick={onDoubleClick} onDoubleClick={onDoubleClick}
> >
{/* Left accent stripe */}
<div <div
aria-hidden="true" aria-hidden="true"
role="presentation"
onPointerDown={(e) => onResizeStart?.("start", e)}
style={{ style={{
position: "absolute", position: "absolute",
left: 0, left: 0,
top: 0, top: 0,
bottom: 0, bottom: 0,
width: 18, width: 3,
opacity: showHandles && capabilities.canTrimStart ? 1 : 0, background: trackStyle.accent,
pointerEvents: onResizeStart && capabilities.canTrimStart ? "auto" : "none", opacity: isSelected ? 0.7 : 0.3,
zIndex: 4, borderRadius: `${theme.clipRadius} 0 0 ${theme.clipRadius}`,
transition: "opacity 120ms ease-out", zIndex: 2,
cursor: "col-resize", pointerEvents: "none",
background:
showHandles && capabilities.canTrimStart
? `linear-gradient(90deg, ${trackStyle.accent}4d 0%, ${trackStyle.accent}22 42%, transparent 100%)`
: "transparent",
}} }}
> />
{/* Left trim handle */}
{showHandles && capabilities.canTrimStart && (
<div <div
aria-hidden="true"
onPointerDown={(e) => onResizeStart?.("start", e)}
style={{ style={{
position: "absolute", position: "absolute",
left: 6, left: 0,
top: 7, top: 0,
bottom: 7, bottom: 0,
width: 3, width: 14,
borderRadius: 999, cursor: "col-resize",
background: theme.handleColor, zIndex: 4,
boxShadow: `0 0 0 1px ${trackStyle.accent}38, 0 0 12px ${trackStyle.accent}18`,
opacity: handleOpacity,
pointerEvents: "none",
}} }}
/> >
</div> <div
<div style={{
aria-hidden="true" position: "absolute",
role="presentation" left: 4,
onPointerDown={(e) => onResizeStart?.("end", e)} top: 6,
style={{ bottom: 6,
position: "absolute", width: 2,
right: 0, borderRadius: 1,
top: 0, background: trackStyle.accent,
bottom: 0, opacity: handleOpacity * 0.6,
width: 18, }}
opacity: showHandles && capabilities.canTrimEnd ? 1 : 0, />
pointerEvents: onResizeStart && capabilities.canTrimEnd ? "auto" : "none", </div>
zIndex: 4, )}
transition: "opacity 120ms ease-out", {/* Right trim handle */}
cursor: "col-resize", {showHandles && capabilities.canTrimEnd && (
background:
showHandles && capabilities.canTrimEnd
? `linear-gradient(270deg, ${trackStyle.accent}4d 0%, ${trackStyle.accent}22 42%, transparent 100%)`
: "transparent",
}}
>
<div <div
aria-hidden="true"
onPointerDown={(e) => onResizeStart?.("end", e)}
style={{ style={{
position: "absolute", position: "absolute",
right: 6, right: 0,
top: 7, top: 0,
bottom: 7, bottom: 0,
width: 3, width: 14,
borderRadius: 999, cursor: "col-resize",
background: theme.handleColor, zIndex: 4,
boxShadow: `0 0 0 1px ${trackStyle.accent}38, 0 0 12px ${trackStyle.accent}18`,
opacity: handleOpacity,
pointerEvents: "none",
}} }}
/> >
</div> <div
style={{
position: "absolute",
right: 4,
top: 6,
bottom: 6,
width: 2,
borderRadius: 1,
background: trackStyle.accent,
opacity: handleOpacity * 0.6,
}}
/>
</div>
)}
{children} {children}
</div> </div>
); );
@@ -35,33 +35,13 @@ export interface TimelineTheme {
clipRadius: string; clipRadius: string;
} }
const TIMELINE_TEAL = "#3CE6AC"; const TRACK_STYLE: TimelineTrackStyle = {
const TIMELINE_TEAL_LABEL = "#E9FFF6"; clip: "#1c2028",
const TIMELINE_TEAL_ICON_BACKGROUND = "rgba(60,230,172,0.12)"; accent: "#3CE6AC",
label: "#dde1e8",
function createTrackStyle(): TimelineTrackStyle { iconBackground: "rgba(255,255,255,0.06)",
return {
clip: TIMELINE_TEAL,
accent: TIMELINE_TEAL,
label: TIMELINE_TEAL_LABEL,
iconBackground: TIMELINE_TEAL_ICON_BACKGROUND,
};
}
const TRACK_STYLES: Record<string, TimelineTrackStyle> = {
video: createTrackStyle(),
audio: createTrackStyle(),
img: createTrackStyle(),
div: createTrackStyle(),
span: createTrackStyle(),
p: createTrackStyle(),
h1: createTrackStyle(),
section: createTrackStyle(),
sfx: createTrackStyle(),
}; };
const DEFAULT_TRACK_STYLE: TimelineTrackStyle = createTrackStyle();
export const defaultTimelineTheme: TimelineTheme = { export const defaultTimelineTheme: TimelineTheme = {
shellBackground: "#0A0A0B", shellBackground: "#0A0A0B",
shellBorder: "rgba(255,255,255,0.05)", shellBorder: "rgba(255,255,255,0.05)",
@@ -75,33 +55,23 @@ export const defaultTimelineTheme: TimelineTheme = {
tickText: "rgba(131,145,168,0.92)", tickText: "rgba(131,145,168,0.92)",
tickMajor: "rgba(255,255,255,0.13)", tickMajor: "rgba(255,255,255,0.13)",
tickMinor: "rgba(255,255,255,0.08)", tickMinor: "rgba(255,255,255,0.08)",
clipBackground: "linear-gradient(180deg, rgba(20,25,34,0.98), rgba(14,18,27,0.98))", clipBackground: "#141922",
clipBackgroundActive: "linear-gradient(180deg, rgba(24,30,40,0.99), rgba(15,20,29,0.99))", clipBackgroundActive: "#181e28",
clipBorder: "rgba(255,255,255,0.07)", clipBorder: "rgba(255,255,255,0.10)",
clipBorderHover: "rgba(255,255,255,0.11)", clipBorderHover: "rgba(255,255,255,0.18)",
clipBorderActive: "rgba(255,255,255,0.14)", clipBorderActive: "rgba(255,255,255,0.24)",
clipShadow: "inset 0 1px 0 rgba(255,255,255,0.03), 0 6px 18px rgba(0,0,0,0.18)", clipShadow: "none",
clipShadowHover: "inset 0 1px 0 rgba(255,255,255,0.035), 0 8px 20px rgba(0,0,0,0.2)", clipShadowHover: "0 2px 8px rgba(0,0,0,0.2)",
clipShadowActive: clipShadowActive: "0 2px 8px rgba(0,0,0,0.2), 0 0 0 1px rgba(255,255,255,0.04)",
"inset 0 1px 0 rgba(255,255,255,0.04), 0 10px 24px rgba(0,0,0,0.22), 0 0 0 1px rgba(255,255,255,0.035)", clipShadowDragging: "0 8px 24px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.06)",
clipShadowDragging: handleColor: "rgba(255,255,255,0.2)",
"inset 0 1px 0 rgba(255,255,255,0.04), 0 18px 36px rgba(0,0,0,0.34), 0 8px 16px rgba(0,0,0,0.18), 0 0 0 1px rgba(255,255,255,0.04)",
handleColor: "rgba(255,255,255,0.11)",
panelResizeSeam: "rgba(255,255,255,0.12)", panelResizeSeam: "rgba(255,255,255,0.12)",
panelResizeActive: "rgba(255,255,255,0.24)", panelResizeActive: "rgba(255,255,255,0.24)",
clipRadius: "11px 15px 13px 9px / 10px 14px 12px 10px", clipRadius: "6px",
}; };
export function getTimelineTrackStyle(tag: string): TimelineTrackStyle { export function getTimelineTrackStyle(_tag: string): TimelineTrackStyle {
const normalized = tag.toLowerCase(); return TRACK_STYLE;
if (
normalized.startsWith("h") &&
normalized.length === 2 &&
"123456".includes(normalized[1] ?? "")
) {
return TRACK_STYLES.h1;
}
return TRACK_STYLES[normalized] ?? DEFAULT_TRACK_STYLE;
} }
export function getClipHandleOpacity({ export function getClipHandleOpacity({
+9
View File
@@ -152,6 +152,15 @@ body {
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
} }
@keyframes hf-thumb-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.hf-loader-progress__fill { .hf-loader-progress__fill {
width: 100%; width: 100%;
height: 100%; height: 100%;
+2 -2
View File
@@ -125,8 +125,8 @@ export async function generateThumbnail(opts: GenerateThumbnailOptions): Promise
}); });
await page.goto(opts.previewUrl, { waitUntil: "domcontentloaded", timeout: 10000 }); await page.goto(opts.previewUrl, { waitUntil: "domcontentloaded", timeout: 10000 });
await page.evaluate(() => { await page.evaluate(() => {
document.documentElement.style.background = "#000"; document.documentElement.style.background = "#1c2028";
document.body.style.background = "#000"; document.body.style.background = "#1c2028";
document.body.style.margin = "0"; document.body.style.margin = "0";
document.body.style.overflow = "hidden"; document.body.style.overflow = "hidden";
}); });