mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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)));
|
||||
}
|
||||
|
||||
|
||||
@@ -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%,
|
||||
|
||||
Reference in New Issue
Block a user