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