feat(timeline): tint video and image clips by element type

Video clips on the timeline previously had a transparent background,
which made them blend into the timeline surface and only become
visible when selected. Image clips share the same track type as
video and therefore had the same problem.

This adds a per-element-type tint layer that applies a subtle
background colour to clips on the timeline:

- video clips: sky tint (\`bg-sky-500/20\`)
- image clips: amber tint (\`bg-amber-500/20\`)
- audio / text / sticker / graphic / effect: unchanged, still
  driven by \`TIMELINE_TRACK_THEME\`

The new \`TIMELINE_ELEMENT_THEME\` map is a Partial<Record<ElementType, …>>
so the element-level theme can override per type while keeping the
existing track-level theme as the fallback. A small unit test covers
the override and fallback behaviour.
This commit is contained in:
Andrés Villagrán 2026-05-16 20:00:37 -04:00
parent fbe3db74d7
commit f1d537efa9
3 changed files with 103 additions and 4 deletions

View File

@ -0,0 +1,64 @@
import { describe, expect, test } from "bun:test";
import {
getTimelineElementClassName,
getTimelineElementClassNameForElement,
TIMELINE_ELEMENT_THEME,
TIMELINE_TRACK_THEME,
} from "../theme";
describe("getTimelineElementClassName", () => {
test("returns the configured class for each track type", () => {
expect(getTimelineElementClassName({ type: "audio" })).toBe(
TIMELINE_TRACK_THEME.audio.elementClassName.trim(),
);
expect(getTimelineElementClassName({ type: "text" })).toBe(
TIMELINE_TRACK_THEME.text.elementClassName.trim(),
);
});
});
describe("getTimelineElementClassNameForElement", () => {
test("uses the element-level theme when one is defined", () => {
expect(
getTimelineElementClassNameForElement({
elementType: "video",
trackType: "video",
}),
).toBe(TIMELINE_ELEMENT_THEME.video?.elementClassName.trim());
});
test("distinguishes video and image even though they share the video track", () => {
const videoClass = getTimelineElementClassNameForElement({
elementType: "video",
trackType: "video",
});
const imageClass = getTimelineElementClassNameForElement({
elementType: "image",
trackType: "video",
});
expect(videoClass).not.toBe(imageClass);
});
test("falls back to the track-level theme when an element type has no override", () => {
expect(
getTimelineElementClassNameForElement({
elementType: "audio",
trackType: "audio",
}),
).toBe(TIMELINE_TRACK_THEME.audio.elementClassName.trim());
expect(
getTimelineElementClassNameForElement({
elementType: "text",
trackType: "text",
}),
).toBe(TIMELINE_TRACK_THEME.text.elementClassName.trim());
expect(
getTimelineElementClassNameForElement({
elementType: "sticker",
trackType: "graphic",
}),
).toBe(TIMELINE_TRACK_THEME.graphic.elementClassName.trim());
});
});

View File

@ -1,4 +1,4 @@
import type { TrackType } from "@/timeline";
import type { ElementType, TrackType } from "@/timeline";
export const TIMELINE_AUDIO_WAVEFORM_COLOR = "rgba(255, 255, 255, 0.7)";
@ -19,6 +19,22 @@ export const TIMELINE_TRACK_THEME: Record<
effect: { elementClassName: "bg-[#5d93ba]" },
} as const;
/**
* Per-element-type background tint applied to clips on the timeline.
*
* Falls back to the track-level theme when an element type has no
* dedicated entry. Used so that clips on the same track (e.g. video
* and image, which both live on the "video" track) can still be
* visually distinguished from each other and from the timeline
* background.
*/
export const TIMELINE_ELEMENT_THEME: Partial<
Record<ElementType, { elementClassName: string }>
> = {
video: { elementClassName: "bg-sky-500/20" },
image: { elementClassName: "bg-amber-500/20" },
} as const;
export const SELECTED_TRACK_ROW_CLASS = "bg-accent/50";
export const DEFAULT_TIMELINE_BOOKMARK_COLOR = "#009dff";
@ -29,3 +45,17 @@ export function getTimelineElementClassName({
}): string {
return TIMELINE_TRACK_THEME[type].elementClassName.trim();
}
export function getTimelineElementClassNameForElement({
elementType,
trackType,
}: {
elementType: ElementType;
trackType: TrackType;
}): string {
const elementTheme = TIMELINE_ELEMENT_THEME[elementType];
if (elementTheme) {
return elementTheme.elementClassName.trim();
}
return TIMELINE_TRACK_THEME[trackType].elementClassName.trim();
}

View File

@ -23,7 +23,11 @@ import {
timelineTimeToSnappedPixels,
} from "@/timeline";
import { getTrackHeight } from "./track-layout";
import { getTimelineElementClassName, TIMELINE_TRACK_THEME } from "./theme";
import {
getTimelineElementClassName,
getTimelineElementClassNameForElement,
TIMELINE_TRACK_THEME,
} from "./theme";
import {
ContextMenu,
ContextMenuContent,
@ -585,8 +589,9 @@ function ElementInner({
<div
className={cn(
"flex shrink-0 items-center overflow-hidden",
getTimelineElementClassName({
type: getTrackTypeForElementType({
getTimelineElementClassNameForElement({
elementType: element.type,
trackType: getTrackTypeForElementType({
elementType: element.type,
}),
}),