fix(studio): clip pictures at full strength and a seam where touching clips meet (#4309)

* fix(studio): composition and text clips show their frame at full strength

CompositionThumbnail drew its frames at 0.7 opacity with a lighten blend, the only timeline
picture dimmed that way; on a light theme the frame nearly vanished into the clip. Opacity and
blend are now theme tokens, untinted by default like video and image clips.

* fix(studio): touching clips show a hairline seam where they meet

Two clips that meet end to start used to read as one long bar. Each exact join on a row now
gets a 1px line in the row colour, drawn over the clips. Clip positions, widths and keyframe
diamonds do not move, so the timeline still tells the truth about time.

* fix(studio): hide clip join seams while a clip is being moved

A moved clip is drawn by the drag overlay, not at its element position, so its join line stayed
behind at the old boundary until the drop. Rows draw no join lines during a move or multi-clip
drag; they come back on drop. The join math now reuses the lane helpers for sort order and the
touching tolerance.

* refactor(studio): drop a multi-drag check the move-drag check already covers

A multi-clip drag preview only exists once a move drag has started, so the join lines only need
the move-drag check.
This commit is contained in:
Miguel Ángel
2026-09-23 05:19:39 -04:00
committed by GitHub
parent 29378b6c7f
commit 06340f656e
9 changed files with 172 additions and 22 deletions
@@ -137,6 +137,13 @@ describe("CompositionThumbnail", () => {
const tiles = [...host.querySelectorAll("img")];
expect(tiles.length).toBeGreaterThan(0);
expect(tiles.every((tile) => !tile.classList.contains("hidden"))).toBe(true);
// Pictures read untinted by default, like video filmstrips; the theme tokens own any dimming.
expect(
tiles.every((tile) => tile.style.opacity === "var(--timeline-composition-thumbnail-opacity)"),
).toBe(true);
expect(tiles[0]?.parentElement?.parentElement?.style.mixBlendMode).toBe(
"var(--timeline-composition-thumbnail-blend)",
);
});
it("aborts its scheduled off-DOM image probe when unmounted", async () => {
@@ -1,4 +1,4 @@
import { memo, useCallback, useMemo, useRef, useState } from "react";
import { memo, useCallback, useMemo, useRef, useState, type CSSProperties } from "react";
import { useMountEffect } from "../../hooks/useMountEffect";
import { useThumbnailLease } from "../../hooks/useThumbnailLease";
import { createThumbnailKey, type ThumbnailPriority } from "../lib/thumbnailScheduler";
@@ -153,7 +153,11 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
{value && (
<div
className="absolute inset-0 flex"
style={{ animation: "hf-thumb-fade 200ms ease-out", mixBlendMode: "lighten" }}
style={{
animation: "hf-thumb-fade 200ms ease-out",
mixBlendMode:
"var(--timeline-composition-thumbnail-blend)" as CSSProperties["mixBlendMode"],
}}
>
{Array.from({ length: frameCount }, (_, index) => (
<div
@@ -166,7 +170,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
alt=""
draggable={false}
className="absolute inset-0 h-full w-full object-cover"
style={{ opacity: 0.7 }}
style={{ opacity: "var(--timeline-composition-thumbnail-opacity)" }}
/>
</div>
))}
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { deriveTimelineClipJoins } from "./TimelineClipJoins";
import type { TimelineElement } from "../store/playerStore";
const clip = (id: string, start: number, duration: number): TimelineElement => ({
id,
tag: "video",
start,
duration,
track: 0,
});
describe("deriveTimelineClipJoins", () => {
it("finds joins in time order, whatever order the clips arrive in", () => {
expect(
deriveTimelineClipJoins([clip("c", 3, 1), clip("a", 0, 1.5), clip("b", 1.5, 1.5)]),
).toEqual([1.5, 3]);
});
it("treats float noise as a join and a one-frame gap or an overlap as none", () => {
expect(deriveTimelineClipJoins([clip("a", 0, 0.1 + 0.2), clip("b", 0.3, 1)])).toEqual([
0.1 + 0.2,
]);
expect(deriveTimelineClipJoins([clip("a", 0, 1), clip("b", 1 + 1 / 60, 1)])).toEqual([]);
expect(deriveTimelineClipJoins([clip("a", 0, 1), clip("b", 0.8, 1)])).toEqual([]);
});
});
@@ -0,0 +1,52 @@
import { CLIP_Y } from "./timelineLayout";
import { sortedLaneClips, TRACK_GAP_EPSILON_S } from "./timelineGaps";
import type { TimelineElement } from "../store/playerStore";
/** Times where one clip on a row ends exactly where the next begins. */
export function deriveTimelineClipJoins(elements: readonly TimelineElement[]): number[] {
const sorted = sortedLaneClips(elements);
const joins: number[] = [];
for (let index = 1; index < sorted.length; index += 1) {
const previous = sorted[index - 1]!;
const end = previous.start + previous.duration;
if (Math.abs(sorted[index]!.start - end) < TRACK_GAP_EPSILON_S) joins.push(end);
}
return joins;
}
interface TimelineClipJoinsProps {
elements: readonly TimelineElement[];
pixelsPerSecond: number;
rowHeight: number;
clipBarHeight?: number;
color: string;
}
/** A hairline in the row colour over each join, so touching clips read as two without moving either. */
export function TimelineClipJoins({
elements,
pixelsPerSecond,
rowHeight,
clipBarHeight,
color,
}: TimelineClipJoinsProps) {
const height = clipBarHeight ?? rowHeight - 2 * CLIP_Y;
return deriveTimelineClipJoins(elements).map((time) => (
<div
key={time}
data-timeline-clip-join=""
aria-hidden="true"
className="absolute pointer-events-none"
style={{
left: time * pixelsPerSecond,
top: CLIP_Y,
width: 1,
height,
transform: "translateX(-50%)",
background: color,
// Over an idle or hovered clip, under the diamonds (6) and a selected clip (10).
zIndex: 5,
}}
/>
));
}
@@ -462,3 +462,38 @@ describe("TimelineLanes clip thumbnails", () => {
act(() => view.root.unmount());
});
});
describe("TimelineLanes clip joins", () => {
const at = (id: string, start: number, duration: number): TimelineElement => ({
...element(id, TRACK_A),
start,
duration,
});
it("draws one row-coloured hairline where clips touch, and leaves the clips where they are", () => {
const view = renderLanes({
elements: [at("clip-a", 0, 2), at("clip-b", 2, 1.5), at("clip-c", 4, 1)],
});
const joins = view.host.querySelectorAll<HTMLElement>("[data-timeline-clip-join]");
expect(joins).toHaveLength(1);
expect(joins[0]?.style.left).toBe("200px");
expect(joins[0]?.style.width).toBe("1px");
expect(joins[0]?.style.background).toBe(defaultTimelineTheme.rowBackground);
const clipB = view.host.querySelector<HTMLElement>('[data-el-id="clip-b"]');
expect(clipB?.style.left).toBe("200px");
expect(clipB?.style.width).toBe("150px");
act(() => view.root.unmount());
});
it("draws no join while a clip is being moved, since the moved clip is drawn elsewhere", () => {
const clipA = at("clip-a", 0, 2);
const view = renderLanes({
elements: [clipA, at("clip-b", 2, 1.5)],
draggedClip: { element: clipA, started: true } as DraggedClipState,
});
expect(view.host.querySelectorAll("[data-timeline-clip-join]")).toHaveLength(0);
act(() => view.root.unmount());
});
});
@@ -32,7 +32,6 @@ import { queryTimelineClipIndex } from "../lib/timelineClipIndex";
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
import { timelineClipFocusId } from "./timelineNavigationIdentity";
import { useTimelineKeyboardActor } from "./useTimelineKeyboardActor";
import { deriveTimelineTransitionSeams } from "./timelineTransitionSeams";
import { TimelineTransitionOverlays } from "./TimelineTransitionOverlays";
export function TimelineLanes({
@@ -224,9 +223,6 @@ export function TimelineLanes({
// right only while it is collapsed and the row is nothing but bar.
const clipBarHeight = rowExpanded ? TRACK_H - 2 * CLIP_Y : undefined;
const automationElements = els.map(getPreviewElement);
const transitionSeams = deriveTimelineTransitionSeams(allTransitionElements).filter(
(seam) => seam.incoming.track === trackNum,
);
// Minted here because this is the only place that sees BOTH ends of
// the disclosure: the caret in the sticky header and the diamond lanes
// on the canvas. Keyed by display row, not by `trackNum`, which is a
@@ -555,7 +551,10 @@ export function TimelineLanes({
})
}
<TimelineTransitionOverlays
seams={transitionSeams}
transitionElements={allTransitionElements}
track={trackNum}
rowElements={draggedClip?.started ? [] : automationElements}
rowBackground={rowBackground}
pixelsPerSecond={pps}
rowHeight={rowHeight}
clipBarHeight={clipBarHeight}
@@ -1,30 +1,53 @@
import { CLIP_Y } from "./timelineLayout";
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
import type { TimelineTransitionSeam } from "./timelineTransitionSeams";
import type { TimelineElement } from "../store/playerStore";
import { deriveTimelineTransitionSeams } from "./timelineTransitionSeams";
import { TimelineTransitionBadge } from "./TimelineTransitionBadge";
import { TimelineClipJoins } from "./TimelineClipJoins";
interface TimelineTransitionOverlaysProps {
seams: readonly TimelineTransitionSeam[];
transitionElements: readonly TimelineElement[];
track: number;
rowElements: readonly TimelineElement[];
rowBackground: string;
pixelsPerSecond: number;
rowHeight: number;
clipBarHeight?: number;
}
/** What a row draws where its clips meet: a hairline at an exact join, a badge over a transition. */
export function TimelineTransitionOverlays({
seams,
transitionElements,
track,
rowElements,
rowBackground,
pixelsPerSecond,
rowHeight,
clipBarHeight,
}: TimelineTransitionOverlaysProps) {
const top = CLIP_Y + (clipBarHeight ?? rowHeight - 2 * CLIP_Y) / 2;
return seams.map((seam) => (
<TimelineTransitionBadge
key={`${getTimelineElementIdentity(seam.outgoing)}-${getTimelineElementIdentity(seam.incoming)}`}
centerPx={seam.centerTime * pixelsPerSecond}
top={top}
widthPx={Math.min(Math.max(seam.duration * pixelsPerSecond, 24), 32)}
outgoingSrc={seam.outgoing.src}
incomingSrc={seam.incoming.src}
/>
));
const seams = deriveTimelineTransitionSeams(transitionElements).filter(
(seam) => seam.incoming.track === track,
);
return (
<>
<TimelineClipJoins
elements={rowElements}
pixelsPerSecond={pixelsPerSecond}
rowHeight={rowHeight}
clipBarHeight={clipBarHeight}
color={rowBackground}
/>
{seams.map((seam) => (
<TimelineTransitionBadge
key={`${getTimelineElementIdentity(seam.outgoing)}-${getTimelineElementIdentity(seam.incoming)}`}
centerPx={seam.centerTime * pixelsPerSecond}
top={top}
widthPx={Math.min(Math.max(seam.duration * pixelsPerSecond, 24), 32)}
outgoingSrc={seam.outgoing.src}
incomingSrc={seam.incoming.src}
/>
))}
</>
);
}
@@ -25,7 +25,7 @@ export const round3 = (v: number) => Math.round(v * 1000) / 1000;
const endOf = (e: TimelineElement) => e.start + e.duration;
/** Lane clips sorted by start (key as a deterministic tie-break). */
function sortedLaneClips(elements: readonly TimelineElement[]): TimelineElement[] {
export function sortedLaneClips(elements: readonly TimelineElement[]): TimelineElement[] {
return [...elements].sort((a, b) => a.start - b.start || keyOf(a).localeCompare(keyOf(b)));
}
+3
View File
@@ -80,6 +80,9 @@
--timeline-clip-chip-audio-bg: rgba(25, 16, 38, 0.7);
--timeline-clip-chip-text: #ffffff;
--timeline-shortcut-bg: rgba(17, 23, 35, 0.84);
/* Composition and text clips draw their frame as it is, like video filmstrips; a host may dim them. */
--timeline-composition-thumbnail-opacity: 1;
--timeline-composition-thumbnail-blend: normal;
--timeline-thumbnail-shimmer: linear-gradient(
90deg,
rgba(255, 255, 255, 0.02) 0%,