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:
Miguel Ángel
2026-05-20 23:54:19 -04:00
parent be9b61a8c9
commit ef2ff298b6
7 changed files with 124 additions and 176 deletions
+7 -1
View File
@@ -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({
+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.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";
});