mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(studio): timeline UI overhaul — flat clips, unified color, working thumbnails
Visual redesign of the timeline: - Flat solid clip backgrounds, no gradients or multi-layer shadows - Unified neutral color palette — all clips use the same base color - Clean 6px border-radius instead of organic asymmetric radii - Single-line labels, no redundant tag badge or time range - 3px teal accent stripe on the left edge of every clip - Simplified trim handles (2px accent bars) Thumbnail fixes: - Fixed broken thumbnail URLs — compositionSrc was an absolute URL that got nested inside the preview/comp path. Now normalized to relative path before constructing the thumbnail URL - Thumbnail background changed from #000 to #1c2028 so transparent overlay compositions render visible content against a matching dark background - mix-blend-mode: lighten on thumbnail layer — dark backgrounds blend away, bright content shows through - Removed double-label in CompositionThumbnail (was showing both a badge at top and text at bottom)
This commit is contained in:
@@ -349,7 +349,13 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
}, opts.seekTime);
|
||||
const manifestContent = readStudioManualEditManifestContent(opts.project.dir);
|
||||
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 reapplyStudioManualEditsToThumbnailPage(page);
|
||||
let clip: ScreenshotClip | undefined;
|
||||
|
||||
@@ -23,8 +23,18 @@ export function useRenderClipContent({
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return null;
|
||||
|
||||
// Resolve composition source path using the compIdToSrc map
|
||||
let compSrc = el.compositionSrc;
|
||||
if (compSrc) {
|
||||
try {
|
||||
const parsed = new URL(compSrc, window.location.origin);
|
||||
const previewPrefix = `/api/projects/${pid}/preview/`;
|
||||
if (parsed.pathname.startsWith(previewPrefix)) {
|
||||
compSrc = parsed.pathname.slice(previewPrefix.length);
|
||||
}
|
||||
} catch {
|
||||
// already relative
|
||||
}
|
||||
}
|
||||
if (compSrc && compIdToSrc.size > 0) {
|
||||
const resolved =
|
||||
compIdToSrc.get(el.id) ||
|
||||
|
||||
@@ -16,7 +16,6 @@ interface CompositionThumbnailProps {
|
||||
|
||||
const CLIP_HEIGHT = 66;
|
||||
const THUMBNAIL_URL_VERSION = "v3";
|
||||
const COMPOSITION_THUMBNAIL_LABEL_Z_INDEX = 10;
|
||||
|
||||
export function buildCompositionThumbnailUrl({
|
||||
previewUrl,
|
||||
@@ -53,7 +52,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
||||
previewUrl,
|
||||
label,
|
||||
labelColor,
|
||||
accentColor = "#6B7280",
|
||||
accentColor: _accentColor = "#6B7280",
|
||||
selector,
|
||||
selectorIndex,
|
||||
seekTime = 2,
|
||||
@@ -110,8 +109,11 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{loaded ? (
|
||||
<div className="absolute inset-0 flex">
|
||||
{loaded && (
|
||||
<div
|
||||
className="absolute inset-0 flex"
|
||||
style={{ animation: "fadeIn 200ms ease-out", mixBlendMode: "lighten" }}
|
||||
>
|
||||
{Array.from({ length: frameCount }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
@@ -122,59 +124,25 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
||||
src={url}
|
||||
alt=""
|
||||
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
|
||||
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
|
||||
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 }}
|
||||
>
|
||||
<div className="absolute left-3 top-0 bottom-0 flex items-center" style={{ zIndex: 10 }}>
|
||||
<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={{
|
||||
color: labelColor,
|
||||
background: `${accentColor}2e`,
|
||||
boxShadow: `inset 0 0 0 1px ${accentColor}40`,
|
||||
textShadow: loaded ? "0 1px 4px rgba(0,0,0,0.9), 0 0 8px rgba(0,0,0,0.6)" : "none",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -10,7 +10,6 @@ import { getRenderedTimelineElement, type TimelineTheme } from "./timelineTheme"
|
||||
import { GUTTER, TRACK_H, RULER_H, CLIP_Y, CLIP_HANDLE_W } from "./timelineLayout";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag";
|
||||
import { formatTime } from "../lib/time";
|
||||
import type { TrackVisualStyle } from "./timelineIcons";
|
||||
|
||||
interface TimelineCanvasProps {
|
||||
@@ -134,28 +133,16 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
||||
className={
|
||||
renderClipContent
|
||||
? "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) ?? (
|
||||
<div className="flex h-full min-h-0 flex-col justify-between py-3">
|
||||
<span
|
||||
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,
|
||||
background: `${clipStyle.accent}26`,
|
||||
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>
|
||||
<span
|
||||
className="truncate text-[10px] font-medium leading-none"
|
||||
style={{ color: clipStyle.label }}
|
||||
>
|
||||
{element.label || element.id || element.tag}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
@@ -221,10 +208,9 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
||||
paddingLeft: 16,
|
||||
color: ts.label,
|
||||
fontSize: 11,
|
||||
letterSpacing: "0.08em",
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
background: `linear-gradient(90deg, ${ts.accent}14, transparent 28%)`,
|
||||
boxShadow: `inset 0 0 0 1px ${ts.accent}24`,
|
||||
opacity: 0.5,
|
||||
}}
|
||||
>
|
||||
New track
|
||||
|
||||
@@ -51,14 +51,14 @@ export const TimelineClip = memo(function TimelineClip({
|
||||
const handleOpacity = getClipHandleOpacity({ isHovered, isSelected, isDragging });
|
||||
|
||||
const borderColor = isSelected
|
||||
? theme.clipBorderActive
|
||||
? trackStyle.accent + "60"
|
||||
: isHovered
|
||||
? theme.clipBorderHover
|
||||
: theme.clipBorder;
|
||||
const boxShadow = isDragging
|
||||
? theme.clipShadowDragging
|
||||
: isSelected
|
||||
? theme.clipShadowActive
|
||||
? `0 0 0 1px ${trackStyle.accent}40`
|
||||
: isHovered
|
||||
? theme.clipShadowHover
|
||||
: theme.clipShadow;
|
||||
@@ -77,20 +77,14 @@ export const TimelineClip = memo(function TimelineClip({
|
||||
top: clipY,
|
||||
bottom: clipY,
|
||||
borderRadius: theme.clipRadius,
|
||||
background: isSelected
|
||||
? `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,
|
||||
background: trackStyle.clip,
|
||||
border: `1px solid ${borderColor}`,
|
||||
boxShadow,
|
||||
transition:
|
||||
"border-color 120ms ease-out, box-shadow 140ms ease-out, background 140ms ease-out",
|
||||
transition: "border-color 100ms, box-shadow 100ms",
|
||||
zIndex: isDragging ? 20 : isSelected ? 10 : isHovered ? 5 : 1,
|
||||
cursor: capabilities.canMove ? "grab" : "default",
|
||||
transform: isDragging ? "translateY(-1px)" : undefined,
|
||||
opacity: isDragging ? 0.92 : 1,
|
||||
}}
|
||||
title={
|
||||
isComposition
|
||||
@@ -103,78 +97,80 @@ export const TimelineClip = memo(function TimelineClip({
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
>
|
||||
{/* Left accent stripe */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
role="presentation"
|
||||
onPointerDown={(e) => onResizeStart?.("start", e)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 18,
|
||||
opacity: showHandles && capabilities.canTrimStart ? 1 : 0,
|
||||
pointerEvents: onResizeStart && capabilities.canTrimStart ? "auto" : "none",
|
||||
zIndex: 4,
|
||||
transition: "opacity 120ms ease-out",
|
||||
cursor: "col-resize",
|
||||
background:
|
||||
showHandles && capabilities.canTrimStart
|
||||
? `linear-gradient(90deg, ${trackStyle.accent}4d 0%, ${trackStyle.accent}22 42%, transparent 100%)`
|
||||
: "transparent",
|
||||
width: 3,
|
||||
background: trackStyle.accent,
|
||||
opacity: isSelected ? 0.7 : 0.3,
|
||||
borderRadius: `${theme.clipRadius} 0 0 ${theme.clipRadius}`,
|
||||
zIndex: 2,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
/>
|
||||
{/* Left trim handle */}
|
||||
{showHandles && capabilities.canTrimStart && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
onPointerDown={(e) => onResizeStart?.("start", e)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 6,
|
||||
top: 7,
|
||||
bottom: 7,
|
||||
width: 3,
|
||||
borderRadius: 999,
|
||||
background: theme.handleColor,
|
||||
boxShadow: `0 0 0 1px ${trackStyle.accent}38, 0 0 12px ${trackStyle.accent}18`,
|
||||
opacity: handleOpacity,
|
||||
pointerEvents: "none",
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 14,
|
||||
cursor: "col-resize",
|
||||
zIndex: 4,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
role="presentation"
|
||||
onPointerDown={(e) => onResizeStart?.("end", e)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 18,
|
||||
opacity: showHandles && capabilities.canTrimEnd ? 1 : 0,
|
||||
pointerEvents: onResizeStart && capabilities.canTrimEnd ? "auto" : "none",
|
||||
zIndex: 4,
|
||||
transition: "opacity 120ms ease-out",
|
||||
cursor: "col-resize",
|
||||
background:
|
||||
showHandles && capabilities.canTrimEnd
|
||||
? `linear-gradient(270deg, ${trackStyle.accent}4d 0%, ${trackStyle.accent}22 42%, transparent 100%)`
|
||||
: "transparent",
|
||||
}}
|
||||
>
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 4,
|
||||
top: 6,
|
||||
bottom: 6,
|
||||
width: 2,
|
||||
borderRadius: 1,
|
||||
background: trackStyle.accent,
|
||||
opacity: handleOpacity * 0.6,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Right trim handle */}
|
||||
{showHandles && capabilities.canTrimEnd && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
onPointerDown={(e) => onResizeStart?.("end", e)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 6,
|
||||
top: 7,
|
||||
bottom: 7,
|
||||
width: 3,
|
||||
borderRadius: 999,
|
||||
background: theme.handleColor,
|
||||
boxShadow: `0 0 0 1px ${trackStyle.accent}38, 0 0 12px ${trackStyle.accent}18`,
|
||||
opacity: handleOpacity,
|
||||
pointerEvents: "none",
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 14,
|
||||
cursor: "col-resize",
|
||||
zIndex: 4,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 4,
|
||||
top: 6,
|
||||
bottom: 6,
|
||||
width: 2,
|
||||
borderRadius: 1,
|
||||
background: trackStyle.accent,
|
||||
opacity: handleOpacity * 0.6,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -35,32 +35,16 @@ export interface TimelineTheme {
|
||||
clipRadius: string;
|
||||
}
|
||||
|
||||
const TIMELINE_TEAL = "#3CE6AC";
|
||||
const TIMELINE_TEAL_LABEL = "#E9FFF6";
|
||||
const TIMELINE_TEAL_ICON_BACKGROUND = "rgba(60,230,172,0.12)";
|
||||
|
||||
function createTrackStyle(): TimelineTrackStyle {
|
||||
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 UNIFIED_STYLE: TimelineTrackStyle = {
|
||||
clip: "#1c2028",
|
||||
accent: "#3CE6AC",
|
||||
label: "#dde1e8",
|
||||
iconBackground: "rgba(255,255,255,0.06)",
|
||||
};
|
||||
|
||||
const DEFAULT_TRACK_STYLE: TimelineTrackStyle = createTrackStyle();
|
||||
function createTrackStyle(_tag: string): TimelineTrackStyle {
|
||||
return UNIFIED_STYLE;
|
||||
}
|
||||
|
||||
export const defaultTimelineTheme: TimelineTheme = {
|
||||
shellBackground: "#0A0A0B",
|
||||
@@ -75,21 +59,19 @@ export const defaultTimelineTheme: TimelineTheme = {
|
||||
tickText: "rgba(131,145,168,0.92)",
|
||||
tickMajor: "rgba(255,255,255,0.13)",
|
||||
tickMinor: "rgba(255,255,255,0.08)",
|
||||
clipBackground: "linear-gradient(180deg, rgba(20,25,34,0.98), rgba(14,18,27,0.98))",
|
||||
clipBackgroundActive: "linear-gradient(180deg, rgba(24,30,40,0.99), rgba(15,20,29,0.99))",
|
||||
clipBorder: "rgba(255,255,255,0.07)",
|
||||
clipBorderHover: "rgba(255,255,255,0.11)",
|
||||
clipBorderActive: "rgba(255,255,255,0.14)",
|
||||
clipShadow: "inset 0 1px 0 rgba(255,255,255,0.03), 0 6px 18px rgba(0,0,0,0.18)",
|
||||
clipShadowHover: "inset 0 1px 0 rgba(255,255,255,0.035), 0 8px 20px rgba(0,0,0,0.2)",
|
||||
clipShadowActive:
|
||||
"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:
|
||||
"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)",
|
||||
clipBackground: "#141922",
|
||||
clipBackgroundActive: "#181e28",
|
||||
clipBorder: "rgba(255,255,255,0.10)",
|
||||
clipBorderHover: "rgba(255,255,255,0.18)",
|
||||
clipBorderActive: "rgba(255,255,255,0.24)",
|
||||
clipShadow: "none",
|
||||
clipShadowHover: "0 2px 8px rgba(0,0,0,0.2)",
|
||||
clipShadowActive: "0 2px 8px rgba(0,0,0,0.2), 0 0 0 1px rgba(255,255,255,0.04)",
|
||||
clipShadowDragging: "0 8px 24px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.06)",
|
||||
handleColor: "rgba(255,255,255,0.2)",
|
||||
panelResizeSeam: "rgba(255,255,255,0.12)",
|
||||
panelResizeActive: "rgba(255,255,255,0.24)",
|
||||
clipRadius: "11px 15px 13px 9px / 10px 14px 12px 10px",
|
||||
clipRadius: "6px",
|
||||
};
|
||||
|
||||
export function getTimelineTrackStyle(tag: string): TimelineTrackStyle {
|
||||
@@ -99,9 +81,9 @@ export function getTimelineTrackStyle(tag: string): TimelineTrackStyle {
|
||||
normalized.length === 2 &&
|
||||
"123456".includes(normalized[1] ?? "")
|
||||
) {
|
||||
return TRACK_STYLES.h1;
|
||||
return createTrackStyle("h1");
|
||||
}
|
||||
return TRACK_STYLES[normalized] ?? DEFAULT_TRACK_STYLE;
|
||||
return createTrackStyle(normalized);
|
||||
}
|
||||
|
||||
export function getClipHandleOpacity({
|
||||
|
||||
@@ -125,8 +125,8 @@ export async function generateThumbnail(opts: GenerateThumbnailOptions): Promise
|
||||
});
|
||||
await page.goto(opts.previewUrl, { waitUntil: "domcontentloaded", timeout: 10000 });
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.style.background = "#000";
|
||||
document.body.style.background = "#000";
|
||||
document.documentElement.style.background = "#1c2028";
|
||||
document.body.style.background = "#1c2028";
|
||||
document.body.style.margin = "0";
|
||||
document.body.style.overflow = "hidden";
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user