From 06340f656ef2f70e41d2f4cafc85b78f184a24cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 23 Sep 2026 05:19:39 -0400 Subject: [PATCH] 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. --- .../components/CompositionThumbnail.test.ts | 7 +++ .../components/CompositionThumbnail.tsx | 10 ++-- .../components/TimelineClipJoins.test.ts | 27 ++++++++++ .../player/components/TimelineClipJoins.tsx | 52 +++++++++++++++++++ .../player/components/TimelineLanes.test.tsx | 35 +++++++++++++ .../src/player/components/TimelineLanes.tsx | 9 ++-- .../components/TimelineTransitionOverlays.tsx | 49 ++++++++++++----- .../src/player/components/timelineGaps.ts | 2 +- packages/studio/src/styles/theme.css | 3 ++ 9 files changed, 172 insertions(+), 22 deletions(-) create mode 100644 packages/studio/src/player/components/TimelineClipJoins.test.ts create mode 100644 packages/studio/src/player/components/TimelineClipJoins.tsx diff --git a/packages/studio/src/player/components/CompositionThumbnail.test.ts b/packages/studio/src/player/components/CompositionThumbnail.test.ts index 03673b2d8a..5fb60ca03f 100644 --- a/packages/studio/src/player/components/CompositionThumbnail.test.ts +++ b/packages/studio/src/player/components/CompositionThumbnail.test.ts @@ -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 () => { diff --git a/packages/studio/src/player/components/CompositionThumbnail.tsx b/packages/studio/src/player/components/CompositionThumbnail.tsx index a5c5eede6b..b4340a423e 100644 --- a/packages/studio/src/player/components/CompositionThumbnail.tsx +++ b/packages/studio/src/player/components/CompositionThumbnail.tsx @@ -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 && (
{Array.from({ length: frameCount }, (_, index) => (
))} diff --git a/packages/studio/src/player/components/TimelineClipJoins.test.ts b/packages/studio/src/player/components/TimelineClipJoins.test.ts new file mode 100644 index 0000000000..8e24b6578a --- /dev/null +++ b/packages/studio/src/player/components/TimelineClipJoins.test.ts @@ -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([]); + }); +}); diff --git a/packages/studio/src/player/components/TimelineClipJoins.tsx b/packages/studio/src/player/components/TimelineClipJoins.tsx new file mode 100644 index 0000000000..623a667c36 --- /dev/null +++ b/packages/studio/src/player/components/TimelineClipJoins.tsx @@ -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) => ( +