Skip to content
Merged
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
4 changes: 4 additions & 0 deletions packages/studio/src/contexts/TimelineEditContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ export function useTimelineEditContextOptional(): TimelineEditCallbacks {
return useContext(TimelineEditContext) ?? {};
}

export function useTimelineEditContextValue(): TimelineEditCallbacks | null {
return useContext(TimelineEditContext);
}

export function TimelineEditProvider({
value,
children,
Expand Down
16 changes: 16 additions & 0 deletions packages/studio/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,22 @@ export type {
TimelineElement,
TimelineTimeRange,
} from "./player";
export {
TimelineFrame,
TimelineLanes,
TimelineOverlays,
TimelinePlayhead,
TimelineRazorGuide,
TimelineRuler,
TimelineEmptyStatePart,
TimelineEditPopover,
TimelineClipMenu,
TimelineGapMenu,
TimelineKeyframeMenu,
TimelineShortcutHint,
} from "./player/components/TimelineParts";
export { TimelineProvider, useTimelineContext } from "./player/components/TimelineProvider";
export type { TimelineTheme } from "./player/components/timelineTheme";

// Host overlays: draw over the preview in composition coordinates (see EditorShellProps.gestureOverlay)
export { usePreviewCompositionRect } from "./components/editor/usePreviewCompositionRect";
Expand Down
53 changes: 22 additions & 31 deletions packages/studio/src/player/components/Timeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,26 @@ function renderBasicTimeline() {
return { host, root };
}

function renderSharedAutomationTimeline(selectedElementId?: string) {
const host = createSizedTimelineHost(720);
const automation = JSON.stringify({
version: 1,
lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }],
});
usePlayerStore.setState({
duration: 8,
timelineReady: true,
...(selectedElementId ? { selectedElementId } : {}),
elements: [
{ id: "narration-1", tag: "audio", start: 0, duration: 4, track: 0, automation },
{ id: "narration-2", tag: "audio", start: 4, duration: 4, track: 0, automation },
],
});
const root = createRoot(host);
act(() => root.render(React.createElement(Timeline)));
return { host, root };
}

describe("Timeline provider boundary", () => {
it("keeps all-collapsed horizontal positions at the gutter plus the pre-t=0 pad", () => {
usePlayerStore.setState({
Expand Down Expand Up @@ -621,21 +641,7 @@ describe("Timeline provider boundary", () => {
// clip left the row's state depending on the selection, and a collapse that
// only dropped the active clip left the row stuck open.
it("expands and collapses every clip on a shared track together", () => {
const host = createSizedTimelineHost(720);
const automation = JSON.stringify({
version: 1,
lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }],
});
usePlayerStore.setState({
duration: 8,
timelineReady: true,
elements: [
{ id: "narration-1", tag: "audio", start: 0, duration: 4, track: 0, automation },
{ id: "narration-2", tag: "audio", start: 4, duration: 4, track: 0, automation },
],
});
const root = createRoot(host);
act(() => root.render(React.createElement(Timeline)));
const { host, root } = renderSharedAutomationTimeline();

const row = host.querySelector<HTMLElement>('[data-el-id="narration-1"]')?.parentElement
?.parentElement;
Expand Down Expand Up @@ -668,22 +674,7 @@ describe("Timeline provider boundary", () => {
// a lane to select its clip therefore made the handles vanish under the
// pointer, which is the one gesture the read-only lane exists to support.
it("keeps the automation lanes mounted when the selection moves along the row", () => {
const host = createSizedTimelineHost(720);
const automation = JSON.stringify({
version: 1,
lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }],
});
usePlayerStore.setState({
duration: 8,
timelineReady: true,
selectedElementId: "narration-2",
elements: [
{ id: "narration-1", tag: "audio", start: 0, duration: 4, track: 0, automation },
{ id: "narration-2", tag: "audio", start: 4, duration: 4, track: 0, automation },
],
});
const root = createRoot(host);
act(() => root.render(React.createElement(Timeline)));
const { host, root } = renderSharedAutomationTimeline("narration-2");
act(() => host.querySelector<HTMLButtonElement>('button[aria-label$=" lanes"]')?.click());

const before = [...host.querySelectorAll(".hf-automation-lane")];
Expand Down
41 changes: 34 additions & 7 deletions packages/studio/src/player/components/Timeline.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
import { memo } from "react";
import type { TimelineProps } from "./TimelineTypes";
import { TimelineEmptyState } from "./TimelineEmptyState";
import { TimelineCanvas } from "./TimelineCanvas";
import { TimelineOverlays } from "./TimelineOverlays";
import { TimelineProvider, useTimelineContext } from "./TimelineProvider";
import {
TimelineEmptyStatePart,
TimelineEditPopover,
TimelineClipMenu,
TimelineFrame,
TimelineGapMenu,
TimelineKeyframeMenu,
TimelineLanes,
TimelineOverlays,
TimelinePlayhead,
TimelineRazorGuide,
TimelineRuler,
TimelineShortcutHint,
} from "./TimelineParts";

export * from "./TimelineProvider";
export {
Expand All @@ -28,23 +39,39 @@ function TimelineView() {
const { state, meta } = useTimelineContext();
const { timelineReady, elements } = state;
if (!timelineReady || elements.length === 0) {
return <TimelineEmptyState {...meta.emptyState} />;
return <TimelineEmptyStatePart />;
}
return (
<div {...meta.containerProps}>
<div {...meta.viewportProps}>
<TimelineCanvas />
{meta.razorGuide}
<TimelineFrame />
<TimelineRazorGuide />
</div>
<TimelineOverlays />
</div>
);
}

export const Timeline = memo(function Timeline(props: TimelineProps = {}) {
const TimelineComposed = memo(function TimelineComposed(props: TimelineProps = {}) {
return (
<TimelineProvider {...props}>
<TimelineView />
</TimelineProvider>
);
});

export const Timeline = Object.assign(TimelineComposed, {
Provider: TimelineProvider,
Frame: TimelineFrame,
Ruler: TimelineRuler,
Lanes: TimelineLanes,
Playhead: TimelinePlayhead,
RazorGuide: TimelineRazorGuide,
ShortcutHint: TimelineShortcutHint,
EditPopover: TimelineEditPopover,
ClipMenu: TimelineClipMenu,
KeyframeMenu: TimelineKeyframeMenu,
GapMenu: TimelineGapMenu,
EmptyState: TimelineEmptyStatePart,
Overlays: TimelineOverlays,
});
66 changes: 10 additions & 56 deletions packages/studio/src/player/components/TimelineCanvas.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { memo } from "react";
import { TimelineRuler } from "./TimelineRuler";
import { TimelineRulerPart } from "./TimelineRulerPart";
import { PlayheadIndicator } from "./PlayheadIndicator";
import {
RULER_H,
Expand All @@ -12,12 +12,8 @@ import {
getTimelineRowTop,
getTimelineRowHeight,
} from "./timelineLayout";
import { type MultiDragPreviewInput } from "./timelineMultiDragPreview";
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
import { TimelineLanes } from "./TimelineLanes";
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
import { TimelineGestureOverlay } from "./TimelineGestureOverlay";
import { resolveSnapGuide } from "./timelineSnapping";
import { useTimelineContext } from "./TimelineProvider";

// A dropped clip's length is unknown until it lands; the preview shows a default.
Expand All @@ -26,8 +22,7 @@ const DROP_PREVIEW_SECONDS = 3;
export const TimelineCanvas = memo(function TimelineCanvas() {
const { state, actions } = useTimelineContext();
const props = state.canvas;
const { draggedClip, resizingClip, scrollRef, selectedElementIds, displayTrackOrder } = props;
const snapGuide = resolveSnapGuide(draggedClip, resizingClip);
const { draggedClip, scrollRef, displayTrackOrder } = props;
const draggedRowIndex =
draggedClip?.started === true ? displayTrackOrder.indexOf(draggedClip.previewTrack) : -1;
const dropTrackIndex = props.dropPreview
Expand All @@ -43,55 +38,14 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
// ghost and drop placeholder must clamp to it or they stretch to the full
// expanded row height and stop matching the clip being dragged.
const draggedClipHeight = Math.min(draggedRowHeight, TRACK_H) - CLIP_Y * 2;
const {
onResizeElement,
onMoveElement,
onToggleTrackHidden,
onTogglePropertyGroupKeyframe,
onRazorSplit,
onRazorSplitAll,
} = useTimelineEditContextOptional();
const beatDragging = props.beatDragging;
const draggedElement = draggedClip?.element ?? null;
const draggedElementIdentity = draggedElement ? getTimelineElementIdentity(draggedElement) : null;
// The drag ghost follows the cursor freely (both axes) — CapCut-style. The
// "magnetic" affordance is a highlight on the destination lane (draggedRowIndex),
// which flips at the MAGNETIC_TRACK_THRESHOLD point; the clip drops into it.
// Live multi-selection drag: while a selected clip is dragged, ALL selected
// clips move together as one rigid formation. The GRABBED clip is the free
// ghost below; its co-selected "passengers" slide by the SAME group-clamped
// delta (cheap translateX, no re-layout) — the delta is derived from the
// grabbed clip's ALREADY-clamped previewStart, so the whole formation stops at
// the wall together and never deforms. Matches what the commit will do — see
// timelineMultiDragPreview + commit.
const multiDragPreview: MultiDragPreviewInput | null =
draggedClip?.started === true && draggedElement && draggedElementIdentity
? {
dragStarted: true,
draggedKey: draggedElementIdentity,
draggedOriginStart: draggedElement.start,
draggedPreviewStart: draggedClip.previewStart,
selectedKeys: selectedElementIds,
}
: null;
const { draggedElement, snapGuide, multiDragPreview } = props;
return (
<div
className="relative"
style={{ height: props.totalH, width: props.contentOrigin + props.trackContentWidth }}
>
<TimelineRuler
major={props.major}
minor={props.minor}
pps={props.pps}
trackContentWidth={props.trackContentWidth}
totalH={props.totalH}
effectiveDuration={props.effectiveDuration}
majorTickInterval={props.majorTickInterval}
theme={props.theme}
beatAnalysis={props.beatAnalysis}
contentOrigin={props.contentOrigin}
renderTimeRange={props.rowsVirtualized ? props.renderTimeRange : undefined}
/>
<TimelineRulerPart />

{/* Breathing room between the sticky ruler and the first track lane — the
top half of the CapCut-style padding (see TRACKS_TOP_PAD). */}
Expand All @@ -104,12 +58,12 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
snapGuide={snapGuide}
draggedElement={draggedElement}
multiDragPreview={multiDragPreview}
onToggleTrackHidden={onToggleTrackHidden}
onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
onResizeElement={onResizeElement}
onMoveElement={onMoveElement}
onRazorSplit={onRazorSplit}
onRazorSplitAll={onRazorSplitAll}
onToggleTrackHidden={props.onToggleTrackHidden}
onTogglePropertyGroupKeyframe={props.onTogglePropertyGroupKeyframe}
onResizeElement={props.onResizeElement}
onMoveElement={props.onMoveElement}
onRazorSplit={props.onRazorSplit}
onRazorSplitAll={props.onRazorSplitAll}
/>

{/* Breathing room below the last track lane (~1.5 track heights) — a real
Expand Down
Loading
Loading