feat(studio): transition seams blur and carry a badge (#4280)

* feat(studio): transition seams blur and carry a badge

* fix(studio): keep labelled transitions on one row

* fix(studio): render transition crossfade zone

* fix(studio): gate seams on transition labels

* fix(studio): bridge GSAP transition markers to clips

* fix(studio): clear transition seam CI failures

* fix(studio): register transition frame classes
This commit is contained in:
Miguel Ángel
2026-09-22 08:59:53 -04:00
committed by GitHub
parent 189bead8cb
commit e5a0370727
19 changed files with 403 additions and 12 deletions
@@ -32,6 +32,8 @@ 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({
pps,
@@ -104,13 +106,15 @@ export function TimelineLanes({
const { collapsedGroupIds, expandedLaneOwnerIds, toggleGroupExpanded, toggleLaneOwnerExpanded } =
useTimelineGroupDisclosure();
const automationLanes = useAutomationLanes();
const allTransitionElements = useMemo(
() => tracks.flatMap(([, elements]) => elements.map(getPreviewElement)),
[getPreviewElement, tracks],
);
// A group's automation clock is COMPOSITION time (groups doc §1.3), so its
// synthetic lane element spans the whole composition rather than a clip.
const compositionDuration = usePlayerStore((s) => s.duration);
useAutomationSelectionKeyboard({ lanes: automationLanes });
const { logicalRowsByTrack, groupByAnchor } = useTimelineLaneRowIndexes(logicalRows, groups);
// Which tracks are group MEMBERS, so their headers can render the level-2
// nesting their `aria-level` already reports.
const groupMemberTracks = useMemo(
() => new Set(groups.flatMap((group) => group.memberTracks)),
[groups],
@@ -197,11 +201,7 @@ export function TimelineLanes({
const ts = trackStyles.get(trackNum) ?? getTrackStyle("");
const isPendingTrack =
draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0;
// All lanes use the same uniform color — no alternating stripes.
const rowBackground = theme.rowBackground;
// The beat-dot strip occupies the top of this track's lane (active track,
// or the music track when nothing is selected). When shown, keyframe
// diamonds shrink + drop to the bottom half so they don't collide with it.
const beatStripOnTrack = trackShowsBeatStrip(els, beatAnalysis?.beatTimes, {
selectedElementId,
isMusicTrack,
@@ -223,9 +223,10 @@ export function TimelineLanes({
// property lanes are showing. Undefined means "fill the row", which is
// right only while it is collapsed and the row is nothing but bar.
const clipBarHeight = rowExpanded ? TRACK_H - 2 * CLIP_Y : undefined;
// The clips whose envelopes this row draws, at their dragged positions.
// Once per row, not once per clip in the map below.
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
@@ -553,6 +554,12 @@ export function TimelineLanes({
);
})
}
<TimelineTransitionOverlays
seams={transitionSeams}
pixelsPerSecond={pps}
rowHeight={rowHeight}
clipBarHeight={clipBarHeight}
/>
{/* The automation lanes belong to the ROW, so they are mounted
here rather than under the active clip's property lanes.
Hanging off that clip meant selecting a sibling moved the
@@ -0,0 +1,59 @@
import type { CSSProperties } from "react";
interface TimelineTransitionBadgeProps {
centerPx: number;
top: number;
widthPx: number;
outgoingSrc?: string;
incomingSrc?: string;
}
export function TimelineTransitionBadge({
centerPx,
top,
widthPx,
outgoingSrc,
incomingSrc,
}: TimelineTransitionBadgeProps) {
const style = {
left: centerPx,
top,
"--timeline-transition-zone-width": `${widthPx}px`,
} as CSSProperties;
return (
<div className="timeline-transition" style={style} aria-hidden="true">
<div className="timeline-transition__zone">
<div
className="timeline-transition__image timeline-transition__image--out"
style={outgoingSrc ? { backgroundImage: `url("${outgoingSrc}")` } : undefined}
/>
<div
className="timeline-transition__image timeline-transition__image--in"
style={incomingSrc ? { backgroundImage: `url("${incomingSrc}")` } : undefined}
/>
</div>
<div className="timeline-transition__badge">
<svg viewBox="0 0 20 20" role="presentation">
<defs>
<filter id="timeline-transition-glyph-blur">
<feGaussianBlur stdDeviation="0.55" />
</filter>
</defs>
<path
className="timeline-transition__frame timeline-transition__frame--out"
d="M3 5h8v10H3z"
/>
<path
className="timeline-transition__frame timeline-transition__frame--in"
d="M9 5h8v10H9z"
/>
<path
className="timeline-transition__intersection"
d="M9 5h2v10H9z"
filter="url(#timeline-transition-glyph-blur)"
/>
</svg>
</div>
</div>
);
}
@@ -0,0 +1,30 @@
import { CLIP_Y } from "./timelineLayout";
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
import type { TimelineTransitionSeam } from "./timelineTransitionSeams";
import { TimelineTransitionBadge } from "./TimelineTransitionBadge";
interface TimelineTransitionOverlaysProps {
seams: readonly TimelineTransitionSeam[];
pixelsPerSecond: number;
rowHeight: number;
clipBarHeight?: number;
}
export function TimelineTransitionOverlays({
seams,
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}
/>
));
}
@@ -32,6 +32,9 @@ export interface TimelineTheme {
panelResizeActive: string;
clipRadius: string;
audioClipRadius: string;
transitionZone: string;
transitionBadge: string;
transitionBadgeInk: string;
}
const TRACK_STYLE: TimelineTrackStyle = {
@@ -69,6 +72,9 @@ export const defaultTimelineTheme: TimelineTheme = {
panelResizeActive: "var(--timeline-resize-active)",
clipRadius: "var(--timeline-clip-radius)",
audioClipRadius: "var(--timeline-clip-audio-radius)",
transitionZone: "var(--timeline-transition-zone)",
transitionBadge: "var(--timeline-transition-badge)",
transitionBadgeInk: "var(--timeline-transition-badge-ink)",
};
export type ClipWidthLadder = "labeled" | "picture" | "frame";
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { deriveTimelineTransitionSeams } from "./timelineTransitionSeams";
import type { TimelineElement } from "../store/playerStore";
const clip = (
id: string,
start: number,
duration: number,
track = 0,
transitionLabel?: string,
): TimelineElement => ({
id,
tag: "video",
start,
duration,
track,
transitionLabel,
});
describe("deriveTimelineTransitionSeams", () => {
it("finds a labelled transition and centers the seam in the shared time", () => {
const label = "hf:transition:out:in:crossfade";
const [seam] = deriveTimelineTransitionSeams([
clip("in", 1.8, 2, 0, label),
clip("out", 0, 2, 0, label),
]);
expect(seam?.outgoing).toEqual(clip("out", 0, 2, 0, label));
expect(seam?.incoming).toEqual(clip("in", 1.8, 2, 0, label));
expect(seam?.centerTime).toBeCloseTo(1.9);
expect(seam?.duration).toBeCloseTo(0.2);
});
it("does not badge an unlabelled overlap", () => {
expect(deriveTimelineTransitionSeams([clip("out", 0, 2), clip("in", 1.8, 2)])).toEqual([]);
});
it("does not badge a cross-track overlap", () => {
const label = "hf:transition:out:in:crossfade";
expect(
deriveTimelineTransitionSeams([clip("out", 0, 2, 0, label), clip("in", 1.8, 2, 1, label)]),
).toEqual([]);
});
it("rejects crisp gaps and touching edges", () => {
expect(deriveTimelineTransitionSeams([clip("a", 0, 2), clip("b", 2.04, 1)])).toEqual([]);
expect(deriveTimelineTransitionSeams([clip("a", 0, 2), clip("b", 2, 1)])).toEqual([]);
});
});
@@ -0,0 +1,36 @@
import type { TimelineElement } from "../store/playerStore";
export interface TimelineTransitionSeam {
outgoing: TimelineElement;
incoming: TimelineElement;
centerTime: number;
duration: number;
}
export function isTransitionPair(a: TimelineElement, b: TimelineElement): boolean {
return Boolean(a.transitionLabel && a.transitionLabel === b.transitionLabel);
}
export function deriveTimelineTransitionSeams(
elements: readonly TimelineElement[],
): TimelineTransitionSeam[] {
const sorted = [...elements].sort((left, right) => left.start - right.start);
const seams: TimelineTransitionSeam[] = [];
for (let index = 0; index < sorted.length - 1; index += 1) {
const outgoing = sorted[index]!;
const incoming = sorted[index + 1]!;
if (outgoing.track !== incoming.track || !isTransitionPair(outgoing, incoming)) continue;
const outgoingEnd = outgoing.start + outgoing.duration;
const overlapStart = Math.max(outgoing.start, incoming.start);
const overlapEnd = Math.min(outgoingEnd, incoming.start + incoming.duration);
const duration = overlapEnd - overlapStart;
if (duration <= 0) continue;
seams.push({
outgoing,
incoming,
centerTime: overlapStart + duration / 2,
duration,
});
}
return seams;
}
@@ -171,6 +171,24 @@ describe("normalizeToZones — CapCut-stable lanes follow the track-index (never
});
describe("normalizeToZones — legacy overlap spill (display-only, deterministic)", () => {
it("keeps a labelled transition pair on one row", () => {
const transitionLabel = "hf:transition:outgoing:incoming:crossfade";
const out = normalizeToZones([
{ ...zClip("outgoing", 0, 2.5, 0, 0), transitionLabel },
{ ...zClip("incoming", 2, 2.5, 0, 0), transitionLabel },
]);
expect(trackOf(out, "outgoing")).toBe(0);
expect(trackOf(out, "incoming")).toBe(0);
});
it("still splits an unlabelled overlap", () => {
const out = normalizeToZones([
zClip("outgoing", 0, 2.5, 0, 0),
zClip("incoming", 2, 2.5, 0, 0),
]);
expect(trackOf(out, "outgoing")).not.toBe(trackOf(out, "incoming"));
});
it("splits time-overlapping SAME-track clips onto adjacent sub-lanes (no visible overlap)", () => {
// a [0,5), b [2,7) overlaps a, c [6,9) sequential — all authored on track 1.
// The editor forbids per-track overlap, but a legacy file can carry it; the
@@ -1,5 +1,6 @@
import type { TimelineElement } from "../store/playerStore";
import { isAudioTimelineElement } from "../../utils/timelineInspector";
import { isTransitionPair } from "./timelineTransitionSeams";
/**
* Free-form vertical zones, top → bottom: visual, audio. Canvas layering is
@@ -66,7 +67,9 @@ function packTrackLanes(
const ordered = [...clips].sort(byStableId);
const lanes: TimelineElement[][] = [];
for (const el of ordered) {
let sub = lanes.findIndex((occ) => occ.every((o) => !overlaps(o, el)));
let sub = lanes.findIndex((occ) =>
occ.every((o) => !overlaps(o, el) || isTransitionPair(o, el)),
);
if (sub === -1) {
sub = lanes.length;
lanes.push([]);
@@ -32,6 +32,8 @@ export interface TimelineLike {
time: () => number;
duration: () => number;
isActive: () => boolean;
labels?: Record<string, number>;
getLabels?: () => Record<string, number>;
}
export interface ClipManifestClip extends RuntimeTimelineClipIdentity {
@@ -1,5 +1,6 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import { gsap } from "gsap";
import {
createTimelineElementFromManifestClip,
parseTimelineFromDOM,
@@ -20,6 +21,33 @@ function makeDoc(html: string): Document {
}
describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
it("bridges a real GSAP transition marker onto both named clips", () => {
document.body.innerHTML = `
<div data-composition-id="root">
<img data-hf-id="outgoing" data-start="0" data-duration="2.5" data-track-index="0" />
<img data-hf-id="incoming" data-start="2" data-duration="2.5" data-track-index="0" />
</div>
`;
const timeline = gsap.timeline({ paused: true });
timeline.addLabel("hf:transition:outgoing:incoming:crossfade", 2);
const runtimeWindow = window as Window & {
__timelines?: Record<string, typeof timeline>;
};
runtimeWindow.__timelines = { main: timeline };
try {
const elements = parseTimelineFromDOM(document, 5);
expect(elements.map((element) => element.transitionLabel)).toEqual([
"hf:transition:outgoing:incoming:crossfade",
"hf:transition:outgoing:incoming:crossfade",
]);
} finally {
delete runtimeWindow.__timelines;
timeline.kill();
document.body.innerHTML = "";
}
});
it("harvests hfId from a data-start element that has data-hf-id", () => {
const doc = makeDoc(`
<div data-composition-id="root">
+24 -2
View File
@@ -9,10 +9,11 @@
*/
import type { TimelineElement } from "../store/playerStore";
import type { ClipManifestClip } from "./playbackTypes";
import type { ClipManifestClip, IframeWindow, TimelineLike } from "./playbackTypes";
import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context";
import { readClipTiming } from "@hyperframes/core/composition-contract";
import { groupInfoFor } from "./timelineGroupInfo";
import { transitionLabelsForDocument } from "./timelineTransitionMetadata";
import {
resolveMediaElement,
applyMediaMetadataFromElement,
@@ -75,6 +76,9 @@ export function createTimelineElementFromManifestClip(params: {
}): TimelineElement {
const { clip, fallbackIndex, doc } = params;
let hostEl = params.hostEl ?? null;
const transitionLabels = doc
? transitionLabelsForDocument(doc, (doc.defaultView as IframeWindow | null)?.__timelines)
: new Map<Element, string>();
const label = getTimelineElementDisplayLabel({
id: clip.id,
label: clip.label,
@@ -108,6 +112,10 @@ export function createTimelineElementFromManifestClip(params: {
const entry: TimelineElement = {
id: identity.id,
label,
transitionLabel:
(hostEl && transitionLabels.get(hostEl)) ||
hostEl?.getAttribute("data-transition-label") ||
undefined,
key: identity.key,
kind: clip.kind,
tag: resolveClipTag(clip),
@@ -134,6 +142,10 @@ export function createTimelineElementFromManifestClip(params: {
if (hostEl) {
applyMediaMetadataFromElement(entry, hostEl);
if (!entry.src) {
const rawSrc = hostEl.getAttribute("src");
if (rawSrc) entry.src = new URL(rawSrc, hostEl.baseURI).href;
}
if (hostEl.hasAttribute("data-hidden")) entry.hidden = true;
const timelineRole = hostEl.getAttribute("data-timeline-role");
if (timelineRole) entry.timelineRole = timelineRole;
@@ -205,11 +217,19 @@ export function createTimelineElementFromManifestClip(params: {
* Parse [data-start] elements from a Document into TimelineElement[].
* Shared helper — used by onIframeLoad fallback, handleMessage, and enrichMissingCompositions.
*/
export function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElement[] {
export function parseTimelineFromDOM(
doc: Document,
rootDuration: number,
timelines?: Readonly<Record<string, TimelineLike>>,
): TimelineElement[] {
const rootComp = doc.querySelector("[data-composition-id]");
const nodes = doc.querySelectorAll("[data-start]");
const els: TimelineElement[] = [];
let trackCounter = 0;
const transitionLabels = transitionLabelsForDocument(
doc,
timelines ?? (doc.defaultView as IframeWindow | null)?.__timelines,
);
// fallow-ignore-next-line complexity
nodes.forEach((node) => {
@@ -252,6 +272,8 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
const entry: TimelineElement = {
id: identity.id,
label,
transitionLabel:
transitionLabels.get(el) ?? el.getAttribute("data-transition-label") ?? undefined,
key: identity.key,
kind:
compId && compId !== rootComp?.getAttribute("data-composition-id")
@@ -22,6 +22,7 @@ import {
readTimelineElementZIndex,
} from "./timelineElementHelpers";
import { postRuntimeControlMessage } from "./runtimeProtocol";
import { transitionLabelsForDocument } from "./timelineTransitionMetadata";
// ---------------------------------------------------------------------------
// Viewport / DOM normalisation
@@ -414,6 +415,7 @@ function buildMissingCompositionEntry(params: {
}): TimelineElement | null {
const { doc, iframeWin, element, compositionId, rootDuration, fallbackIndex, resolveEnd } =
params;
const transitionLabels = transitionLabelsForDocument(doc, iframeWin.__timelines);
const timing = readClipTiming(element, {
resolveReferenceEnd: (refId) => resolveEnd(refId, new Set([compositionId])),
});
@@ -447,6 +449,8 @@ function buildMissingCompositionEntry(params: {
const entry: TimelineElement = {
id: identity.id,
label,
transitionLabel:
transitionLabels.get(element) ?? element.getAttribute("data-transition-label") ?? undefined,
key: identity.key,
tag: element.tagName.toLowerCase(),
start: window.start,
@@ -459,6 +463,9 @@ function buildMissingCompositionEntry(params: {
selectorIndex,
sourceFile,
zIndex: readTimelineElementZIndex(element),
src: optionalNonEmpty(element.getAttribute("src"))
? new URL(element.getAttribute("src")!, element.baseURI).href
: undefined,
};
return attachCompositionSource(entry, element, compositionSrc);
}
@@ -0,0 +1,52 @@
import type { TimelineLike } from "./playbackTypes";
const TRANSITION_LABEL_PREFIX = "hf:transition:";
type TimelineWithLabels = TimelineLike & {
labels?: Record<string, number>;
getLabels?: () => Record<string, number>;
};
function transitionParts(label: string): [string, string] | null {
if (!label.startsWith(TRANSITION_LABEL_PREFIX)) return null;
const parts = label.slice(TRANSITION_LABEL_PREFIX.length).split(":");
if (parts.length < 3 || !parts[0] || !parts[1]) return null;
return [parts[0], parts[1]];
}
function findTimedElement(doc: Document, markerId: string): Element | null {
for (const candidate of [markerId, `el-${markerId}`]) {
const byId = doc.getElementById(candidate);
if (byId?.hasAttribute("data-start")) return byId;
}
return (
Array.from(doc.querySelectorAll("[data-start]")).find(
(element) =>
element.getAttribute("data-hf-id") === markerId ||
element.getAttribute("data-composition-id") === markerId,
) ?? null
);
}
export function transitionLabelsForDocument(
doc: Document,
timelines: Readonly<Record<string, TimelineLike>> | undefined,
): ReadonlyMap<Element, string> {
const labels = new Map<Element, string>();
if (!timelines) return labels;
for (const timeline of Object.values(timelines) as TimelineWithLabels[]) {
const timelineLabels = timeline.getLabels?.() ?? timeline.labels ?? {};
for (const label of Object.keys(timelineLabels)) {
const pair = transitionParts(label);
if (!pair) continue;
const outgoing = findTimedElement(doc, pair[0]);
const incoming = findTimedElement(doc, pair[1]);
if (outgoing && incoming) {
labels.set(outgoing, label);
labels.set(incoming, label);
}
}
}
return labels;
}
@@ -12,6 +12,7 @@ import type { ClipManifestClip } from "../lib/playbackTypes";
export interface TimelineElement {
id: string;
label?: string;
transitionLabel?: string;
key?: string;
kind?: ClipManifestClip["kind"];
tag: string;
+66
View File
@@ -208,6 +208,72 @@ body {
var(--timeline-clip-shadow);
}
.timeline-transition {
position: absolute;
width: 0;
height: 0;
pointer-events: none;
z-index: 30;
}
.timeline-transition__zone {
position: absolute;
left: calc(var(--timeline-transition-zone-width) / -2);
top: -21px;
width: var(--timeline-transition-zone-width);
height: 42px;
border-radius: 8px;
overflow: hidden;
background: var(--timeline-transition-zone);
filter: blur(2.5px);
opacity: 0.95;
}
.timeline-transition__image {
position: absolute;
inset: -4px;
background-position: center;
background-size: cover;
}
.timeline-transition__image--out {
mask-image: linear-gradient(90deg, black 0%, transparent 100%);
}
.timeline-transition__image--in {
mask-image: linear-gradient(90deg, transparent 0%, black 100%);
}
.timeline-transition__badge {
position: absolute;
left: -14px;
top: -14px;
display: grid;
width: 28px;
height: 28px;
place-items: center;
border: 1px solid color-mix(in srgb, var(--timeline-transition-badge-ink) 22%, transparent);
border-radius: 999px;
background: var(--timeline-transition-badge);
box-shadow: 0 2px 5px var(--timeline-playhead-shadow);
}
.timeline-transition__badge svg {
width: 17px;
height: 17px;
}
.timeline-transition__frame,
.timeline-transition__frame--out,
.timeline-transition__frame--in {
fill: none;
stroke: var(--timeline-transition-badge-ink);
stroke-width: 1.5;
}
.timeline-transition__intersection {
fill: var(--timeline-transition-badge-ink);
opacity: 0.7;
}
.timeline-clip::before {
content: "";
position: absolute;
+3
View File
@@ -152,6 +152,9 @@
--timeline-clip-shadow-dragging: rgba(0, 0, 0, 0.4);
--timeline-playhead-glow: rgba(60, 230, 172, 0.14);
--timeline-playhead-shadow: rgba(0, 0, 0, 0.55);
--timeline-transition-zone: rgba(255, 255, 255, 0.2);
--timeline-transition-badge: rgba(237, 244, 239, 0.98);
--timeline-transition-badge-ink: rgba(36, 56, 45, 0.96);
/* -------------------------------------------------------------- geometry */
+1 -1
View File
@@ -6,7 +6,7 @@
"files": 142
},
"faceless-explainer": {
"hash": "39e1e4d42e9a5ebe",
"hash": "37dffa6e8f81bffc",
"files": 24
},
"figma": {
@@ -248,6 +248,7 @@ function runInject(argv) {
gsapLines.push(
...buildGsap(rec, outgoing.id, incoming.id, dur, T, spec.direction, CW, CH, die),
);
gsapLines.push(`tl.addLabel("hf:transition:${outgoing.id}:${incoming.id}:${rec.name}", ${T});`);
applied.push({ from: outgoing.id, to: incoming.id, type: rec.name, dur, T });
}
@@ -65,4 +65,6 @@ format: 1920x1080
const outgoing = readFileSync(join(project, "compositions/frames/01-a.html"), "utf8");
assert.match(outgoing, /data-composition-id="01-a" data-duration="2.5"/);
assert.match(outgoing, /class="clip"[^>]*data-duration="2.5"/);
const index = readFileSync(join(project, "index.html"), "utf8");
assert.match(index, /tl\.addLabel\("hf:transition:01-a:02-b:crossfade", 2\)/);
});