Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions packages/studio/src/player/components/TimelineClip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { defaultTimelineTheme, getClipHandleOpacity, type TimelineTheme } from "
import type { TimelineEditCapabilities } from "./timelineEditing";
import { isAudioTimelineElement } from "../../utils/timelineInspector";
import { timelineClipFocusId } from "./timelineNavigationIdentity";
import { TimelineClipFades, type TimelineClipFadesProps } from "./TimelineClipFades";

interface TimelineClipProps {
el: TimelineElement;
Expand All @@ -27,6 +28,12 @@ interface TimelineClipProps {
onClick: (e: React.MouseEvent) => void;
onDoubleClick: (e: React.MouseEvent) => void;
onContextMenu?: (e: React.MouseEvent) => void;
/**
* Fade grips for this clip, when it can carry a fade at all. Geometry is not
* part of it — the clip already knows its own width and length, and passing
* them in would be two owners for one number.
*/
fades?: Omit<TimelineClipFadesProps, "duration" | "pixelsPerSecond" | "width" | "showGrips">;
children?: ReactNode;
}

Expand All @@ -53,6 +60,7 @@ export const TimelineClip = memo(function TimelineClip({
onClick,
onDoubleClick,
onContextMenu,
fades,
children,
}: TimelineClipProps) {
const leftPx = el.start * pps;
Expand Down Expand Up @@ -181,6 +189,15 @@ export const TimelineClip = memo(function TimelineClip({
/>
</div>
)}
{fades && (
<TimelineClipFades
{...fades}
duration={el.duration}
pixelsPerSecond={pps}
width={widthPx}
showGrips={showHandles}
/>
)}
{showLabel && <span className="timeline-clip__label">{displayLabel}</span>}
{showDefaultText && (
<span className="timeline-clip__timecode">
Expand Down
210 changes: 210 additions & 0 deletions packages/studio/src/player/components/TimelineClipFades.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import { useCallback, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import {
clampClipFades,
fadeWedgePath,
MIN_FADE_SECONDS,
type ClipFades,
type FadeCurve,
} from "./clipFades";

/**
* The fade grips on a clip's top corners, and the wedges they draw.
*
* The gesture is deliberately local rather than folded into the timeline's drag
* coordinator: a fade has no lane to change, nothing to snap to and nothing to
* collide with, so all the machinery that gesture owns would sit unused. What
* it does need — a live preview on every move and one persisted write on
* release — the automation binding already provides.
*/

export interface TimelineClipFadesProps {
/** Committed fades, as read back out of the clip's own envelope. */
fades: ClipFades;
/** Clip length in seconds; the fades share it and cannot outrun it. */
duration: number;
pixelsPerSecond: number;
width: number;
curve: FadeCurve;
/** Grips are hidden until the clip is worth aiming at. */
showGrips: boolean;
/** True when this is not the selected clip, which is what makes it editable. */
readOnly: boolean;
/** Double-clicking a grip steps the fade through its curve shapes. */
onCycleCurve(): void;
/** Live during the drag: preview only, never persisted. */
onPreview(next: ClipFades): void;
/** Once, on release. */
onCommit(next: ClipFades): void;
}

/** Side of the grip square, in px. Matches the trim handle's visual weight. */
const GRIP = 9;

/**
* Vertical units the wedge is drawn in. A clip's height is set by the row, and
* sometimes by `bottom` rather than a number, so the overlay draws in its own
* space and lets the SVG stretch it — the horizontal axis stays in real pixels,
* which is the axis a fade's length is read off.
*/
const VIEW_HEIGHT = 100;

export function TimelineClipFades({
fades,
duration,
pixelsPerSecond,
width,
curve,
showGrips,
readOnly,
onPreview,
onCommit,
onCycleCurve,
}: TimelineClipFadesProps) {
// While dragging, the drawn fades come from here: the committed value only
// catches up once the write lands, and the wedge has to track the pointer.
const [draft, setDraft] = useState<ClipFades | null>(null);
const dragRef = useRef<{ edge: "in" | "out"; originX: number; from: ClipFades } | null>(null);
const shown = draft ?? fades;

const onGripDown = useCallback(
(edge: "in" | "out", event: ReactPointerEvent) => {
if (event.button !== 0) return;
// The grip sits on top of the trim handle and inside the clip body; both
// would otherwise start their own gesture from this same press. NOT
// preventDefault: that suppresses the compatibility click events, and the
// double-click that cycles the curve is one of them.
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
dragRef.current = { edge, originX: event.clientX, from: fades };
},
[fades],
);

const resolveDrag = useCallback(
(clientX: number): ClipFades | null => {
const drag = dragRef.current;
if (!drag || pixelsPerSecond <= 0) return null;
// Both grips are dragged INTO the clip, so the out grip reads the
// opposite sign — its fade grows as the pointer travels left.
const travel = (clientX - drag.originX) / pixelsPerSecond;
const delta = drag.edge === "in" ? travel : -travel;
const next =
drag.edge === "in"
? { ...drag.from, fadeIn: Math.max(0, drag.from.fadeIn + delta) }
: { ...drag.from, fadeOut: Math.max(0, drag.from.fadeOut + delta) };
return clampClipFades(next, duration);
},
[duration, pixelsPerSecond],
);

const onGripMove = useCallback(
(event: ReactPointerEvent) => {
const next = resolveDrag(event.clientX);
if (!next) return;
setDraft(next);
onPreview(next);
},
[onPreview, resolveDrag],
);

const onGripUp = useCallback(
(event: ReactPointerEvent) => {
const next = resolveDrag(event.clientX);
const from = dragRef.current?.from;
dragRef.current = null;
setDraft(null);
// A press that moved nothing — the first half of a double-click, or a
// mis-aimed click — must not write the same fade back to the file.
if (next && from && (next.fadeIn !== from.fadeIn || next.fadeOut !== from.fadeOut)) {
onCommit(next);
}
},
[onCommit, resolveDrag],
);

const gripFor = (edge: "in" | "out") => {
const seconds = edge === "in" ? shown.fadeIn : shown.fadeOut;
const span = Math.min(seconds * pixelsPerSecond, width);
// Parked on the corner when there is no fade, which is where you grab to
// start one; otherwise it rides the top of the wedge it drew.
const x = edge === "in" ? span : width - span;
return (
<div
key={edge}
role="slider"
tabIndex={-1}
aria-label={edge === "in" ? "Fade in" : "Fade out"}
aria-valuemin={0}
aria-valuemax={duration}
aria-valuenow={seconds}
aria-valuetext={seconds >= MIN_FADE_SECONDS ? `${seconds.toFixed(2)} seconds` : "No fade"}
data-clip-fade-grip={edge}
onPointerDown={(event) => onGripDown(edge, event)}
onPointerMove={onGripMove}
onPointerUp={onGripUp}
onPointerCancel={onGripUp}
onDoubleClick={(event) => {
event.stopPropagation();
if (seconds > 0) onCycleCurve();
}}
title={`Fade ${edge}: drag to set its length, double-click to change its ${curve} curve`}
style={{
position: "absolute",
left: x - GRIP / 2,
top: 1,
width: GRIP,
height: GRIP,
borderRadius: 2,
background: "rgba(255,255,255,0.9)",
boxShadow: "0 0 0 1px rgba(0,0,0,0.5)",
cursor: "ew-resize",
zIndex: 6,
}}
/>
);
};

return (
<>
<svg
aria-hidden="true"
width="100%"
height="100%"
viewBox={`0 0 ${Math.max(width, 1)} ${VIEW_HEIGHT}`}
preserveAspectRatio="none"
style={{ position: "absolute", inset: 0, pointerEvents: "none", zIndex: 3 }}
>
{(["in", "out"] as const).map((edge) => {
const seconds = edge === "in" ? shown.fadeIn : shown.fadeOut;
if (seconds <= 0) return null;
const { line, fill } = fadeWedgePath({
edge,
seconds,
curve,
pixelsPerSecond,
width,
height: VIEW_HEIGHT,
});
// Two paths, not one: stroking the closed wedge would outline the
// fill's straight top and side as well, which reads as a rectangle
// butted onto the curve instead of one continuous level line.
return (
<g key={edge}>
<path d={fill} fill="rgba(0,0,0,0.45)" stroke="none" />
<path
d={line}
fill="none"
stroke="rgba(255,255,255,0.75)"
strokeWidth={1}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
</g>
);
})}
</svg>
{showGrips && !readOnly && (["in", "out"] as const).map(gripFor)}
</>
);
}
7 changes: 7 additions & 0 deletions packages/studio/src/player/components/TimelineLanes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type { TimelineLanesProps } from "./timelineLaneProps";
import { trackStudioKeyframeLaneExpand } from "../../telemetry/events";
import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector";
import { createClipGestureHandlers } from "./timelineClipGestureHandlers";
import { resolveClipFadeBinding } from "./clipFadeBinding";
import { renderClipChildren, resolveClipRenderContext } from "./timelineClipChildren";
import { TimelineTrackRow } from "./TimelineTrackRow";
import { isTimelineClipActive } from "./useTimelineActiveClips";
Expand Down Expand Up @@ -389,6 +390,11 @@ export function TimelineLanes({
const passengerOffsetPx = isPassenger
? multiDragPassengerOffsetPx(clipKey, pps, multiDragPreview)
: 0;
// Fades ride the clip's own volume envelope; the binding is
// read-only until the clip is selected, exactly as its lanes are.
const fadeBinding = resolveClipFadeBinding(el, (target) =>
automationLanes.bind(target, isSelected),
);
const clipGestures = createClipGestureHandlers(
el,
elementKey,
Expand Down Expand Up @@ -436,6 +442,7 @@ export function TimelineLanes({
}
onHoverStart={() => setHoveredClip(clipKey)}
onHoverEnd={() => setHoveredClip(null)}
fades={fadeBinding}
onResizeStart={clipGestures.onResizeStart}
onPointerDown={clipGestures.onPointerDown}
onClick={clipGestures.onClick}
Expand Down
Loading
Loading