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
25 changes: 23 additions & 2 deletions packages/studio/src/components/TimelineToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ import {
import { useTimelineZoom } from "../player/components/useTimelineZoom";
import { usePlayerStore, type TimelineElement } from "../player";
import { Tooltip } from "./ui";
import { Scissors } from "../icons/SystemIcons";
import { Compare, Scissors } from "../icons/SystemIcons";
import { TRIM_TOOL_ICONS } from "../icons/TrimToolIcons";
import { TIMELINE_TRIM_TOOLS } from "../player/components/timelineTrimTools";
import { activeTrimMode, TIMELINE_TRIM_TOOLS } from "../player/components/timelineTrimTools";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "./editor/domEditingTypes";
import { canSplitElement } from "../utils/timelineElementSplit";
Expand Down Expand Up @@ -127,6 +127,8 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
const activeTool = usePlayerStore((s) => s.activeTool);
const setActiveTool = usePlayerStore((s) => s.setActiveTool);
const timelineSnapEnabled = usePlayerStore((s) => s.timelineSnapEnabled);
const precisionTrimViewEnabled = usePlayerStore((s) => s.precisionTrimViewEnabled);
const setPrecisionTrimViewEnabled = usePlayerStore((s) => s.setPrecisionTrimViewEnabled);
const setTimelineSnapEnabled = usePlayerStore((s) => s.setTimelineSnapEnabled);
const autoKeyframeEnabled = usePlayerStore((s) => s.autoKeyframeEnabled);
const setAutoKeyframeEnabled = usePlayerStore((s) => s.setAutoKeyframeEnabled);
Expand Down Expand Up @@ -236,6 +238,25 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
</Tooltip>
);
})}
{activeTrimMode(activeTool) && (
<Tooltip
label={
precisionTrimViewEnabled
? "Precision view on — both sides of the cut, live"
: "Precision view off"
}
>
<button
type="button"
onClick={() => setPrecisionTrimViewEnabled(!precisionTrimViewEnabled)}
aria-label="Toggle precision trim view"
aria-pressed={precisionTrimViewEnabled}
className={precisionTrimViewEnabled ? flatActive : flatIdle}
>
<Compare size={16} aria-hidden="true" />
</button>
</Tooltip>
)}
{/* Divider: tool-mode | editing-actions */}
<div aria-hidden="true" className="mx-1 h-4 w-px bg-neutral-800" />
<Tooltip label={timelineSnapEnabled ? "Snapping on (N)" : "Snapping off (N)"}>
Expand Down
174 changes: 174 additions & 0 deletions packages/studio/src/components/nle/PrecisionTrimView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { usePlayerStore } from "../../player";
import { activeTrimMode } from "../../player/components/timelineTrimTools";
import type { TimelineTrimPreview } from "../../player/store/trimPreviewSlice";

/**
* The two-up precision view: the frame on each side of the edit point being
* trimmed, live, while the gesture runs.
*
* Each pane is its own preview iframe of the current composition, seeked to its
* own time — the same live-preview-in-an-iframe pattern the composition cards
* use. Two players is why the panel mounts as soon as a trim tool is picked
* rather than when the drag starts: a composition takes a moment to load, and a
* pane that arrives after the gesture is over is worth nothing. While idle the
* panes straddle the playhead, so they always show something true.
*/

interface PrecisionTrimViewProps {
/** Preview URL of the composition the timeline is currently editing. */
previewUrl: string | null;
}

const PANE_LABELS: Record<TimelineTrimPreview["mode"], [string, string]> = {
ripple: ["Outgoing", "Incoming"],
roll: ["Outgoing", "Incoming"],
slide: ["Outgoing", "Incoming"],
// Slip moves no edit point: what changes is which part of the source plays.
slip: ["Clip in", "Clip out"],
};

interface PreviewWindow extends Window {
__player?: { seek?: (time: number) => void; pause?: () => void };
}

const DEFAULT_STAGE = { width: 1920, height: 1080 };

/** One pane: a paused preview of the composition held at a single frame. */
function PrecisionPane({
previewUrl,
time,
label,
align,
}: {
previewUrl: string;
time: number;
label: string;
align: "left" | "right";
}) {
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const paneRef = useRef<HTMLDivElement | null>(null);
const readyRef = useRef(false);
const [stage, setStage] = useState(DEFAULT_STAGE);
const [paneSize, setPaneSize] = useState({ width: 0, height: 0 });

const hold = useCallback((at: number) => {
try {
const player = (iframeRef.current?.contentWindow as PreviewWindow | null)?.__player;
player?.pause?.();
player?.seek?.(at);
} catch {
/* the preview is still loading, or gone */
}
}, []);

// Seek imperatively: the gesture republishes a new time on every pointer
// move, and re-rendering an iframe would reload the composition each frame.
useEffect(() => {
if (readyRef.current) hold(time);
}, [time, hold]);

useEffect(() => {
const pane = paneRef.current;
if (!pane) return;
const observer = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
setPaneSize({ width, height });
});
observer.observe(pane);
return () => observer.disconnect();
}, []);

// The composition renders at its authored size; letterbox it into the pane
// rather than showing the top-left corner of a 1920×1080 frame.
const scale =
paneSize.width > 0 ? Math.min(paneSize.width / stage.width, paneSize.height / stage.height) : 0;

return (
<div
ref={paneRef}
className="relative flex-1 min-w-0 overflow-hidden rounded-md border border-neutral-800/70 bg-black"
>
<iframe
ref={iframeRef}
src={previewUrl}
sandbox="allow-scripts allow-same-origin"
title={`${label} frame`}
tabIndex={-1}
className="pointer-events-none absolute border-none"
style={{
width: stage.width,
height: stage.height,
transformOrigin: "0 0",
left: (paneSize.width - stage.width * scale) / 2,
top: (paneSize.height - stage.height * scale) / 2,
transform: `scale(${scale})`,
opacity: scale > 0 ? 1 : 0,
}}
onLoad={() => {
readyRef.current = true;
try {
const root = iframeRef.current?.contentDocument?.querySelector("[data-composition-id]");
setStage({
width: Number(root?.getAttribute("data-width")) || DEFAULT_STAGE.width,
height: Number(root?.getAttribute("data-height")) || DEFAULT_STAGE.height,
});
} catch {
setStage(DEFAULT_STAGE);
}
hold(time);
}}
/>
<div
className={`absolute bottom-1 ${align === "left" ? "left-1" : "right-1"} rounded bg-black/70 px-1.5 py-0.5 font-mono text-[10px] leading-none tabular-nums text-neutral-300`}
>
{label} · {time.toFixed(2)}s
</div>
</div>
);
}

export function PrecisionTrimView({ previewUrl }: PrecisionTrimViewProps) {
const activeTool = usePlayerStore((s) => s.activeTool);
const enabled = usePlayerStore((s) => s.precisionTrimViewEnabled);
const trimPreview = usePlayerStore((s) => s.trimPreview);
const currentTime = usePlayerStore((s) => s.currentTime);
const setTrimPreview = usePlayerStore((s) => s.setTrimPreview);
const mode = activeTrimMode(activeTool);
const shown = Boolean(mode && enabled && previewUrl);

// A finished gesture leaves its cut on screen — you have just trimmed it and
// that is what you want to look at. It only goes stale once the panel closes
// (the tool changed, or the view was switched off), so that is where it is
// dropped. Guarded so the common closed case is not a null-to-null write.
useEffect(() => {
if (!shown && usePlayerStore.getState().trimPreview) setTrimPreview(null);
}, [shown, setTrimPreview]);

if (!shown || !previewUrl || !mode) return null;

const [outLabel, inLabel] = PANE_LABELS[trimPreview?.mode ?? mode];
const outTime = trimPreview?.outTime ?? Math.max(0, currentTime - 1 / 30);
const inTime = trimPreview?.inTime ?? currentTime;
const delta = trimPreview?.delta ?? 0;

return (
<div
data-precision-trim-view
className="flex flex-shrink-0 items-stretch gap-1.5 border-b border-neutral-800/60 bg-neutral-950 px-2 pb-1.5"
style={{ height: 132 }}
>
<PrecisionPane previewUrl={previewUrl} time={outTime} label={outLabel} align="left" />
<div className="flex w-20 flex-col items-center justify-center gap-0.5 text-center">
<span className="text-[10px] uppercase tracking-wide text-neutral-500">{mode}</span>
<span
className={`font-mono text-xs tabular-nums ${delta === 0 ? "text-neutral-500" : "text-studio-accent"}`}
>
{delta > 0 ? "+" : ""}
{delta.toFixed(2)}s
</span>
</div>
<PrecisionPane previewUrl={previewUrl} time={inTime} label={inLabel} align="right" />
</div>
);
}
2 changes: 2 additions & 0 deletions packages/studio/src/components/nle/TimelinePane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Timeline } from "../../player";
import type { TimelineElement } from "../../player";
import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
import { TimelineResizeDivider } from "./TimelineResizeDivider";
import { PrecisionTrimView } from "./PrecisionTrimView";
import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
import { trackStudioExpandedClipEdit } from "../../telemetry/events";
import { useNLEContext } from "./NLEContext";
Expand Down Expand Up @@ -271,6 +272,7 @@ export function TimelinePane({
}}
>
<div className="flex-shrink-0">{timelineToolbar}</div>
<PrecisionTrimView previewUrl={compositionStack.at(-1)?.previewUrl ?? null} />
<Timeline
sessionEpoch={timelineSessionEpoch}
onSeek={seek}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import {
} from "./timelineOptimisticRevision";
import type { TimelineEditCallbacks } from "./timelineCallbacks";
import type { StackingPatch } from "./timelineStackingSync";
import type { TimelineElement, usePlayerStore } from "../store/playerStore";
import { usePlayerStore } from "../store/playerStore";
import type { TimelineElement } from "../store/playerStore";

export type TimelineGestureKind = "drag" | "resize";
type TimelineGesturePhase = "active" | "committing" | "cancelled" | "complete";
Expand Down
41 changes: 41 additions & 0 deletions packages/studio/src/player/components/timelineTrimOps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
resolveTrimDeltaBounds,
resolveTrimPlan,
trimPlanKeys,
trimPreviewFrames,
trimSnapAnchor,
type TrimClip,
type TrimPlan,
Expand Down Expand Up @@ -214,6 +215,46 @@ describe("gesture plumbing helpers", () => {
expect(trimSnapAnchor(planOf("b", "slip"))).toBeNull();
});

it("frames the precision view on the edit point the gesture is moving", () => {
const frame = 1 / 30;
// Out-point ripple: the cut lands at the clip's new end.
expect(trimPreviewFrames(planOf("b", "ripple", "end"), 1, frame)).toEqual({
outTime: 7 - frame,
inTime: 7,
});
// Head ripple pins the start, so the edit point does not move with the drag.
expect(trimPreviewFrames(planOf("b", "ripple", "start"), 1, frame)).toEqual({
outTime: 4 - frame,
inTime: 4,
});
// Roll: the shared cut, moved.
expect(trimPreviewFrames(planOf("a", "roll", "end"), 1, frame)).toEqual({
outTime: 5 - frame,
inTime: 5,
});
// Slide: the clip's new start.
expect(trimPreviewFrames(planOf("b", "slide"), 1, frame)).toEqual({
outTime: 5 - frame,
inTime: 5,
});
});

it("frames a slip on the clip's own first and last frame — it has no edit point", () => {
const frame = 1 / 30;
expect(trimPreviewFrames(planOf("b", "slip"), 1, frame)).toEqual({
outTime: 4,
inTime: 6 - frame,
});
});

it("never asks the preview for a negative time", () => {
const lane: TrimClip[] = [{ key: "head", start: 0, duration: 4 }];
expect(trimPreviewFrames(planOf("head", "ripple", "start", lane), 0, 1 / 30)).toEqual({
outTime: 0,
inTime: 0,
});
});

it("lists every clip a plan may rewrite so the snap pass can ignore them", () => {
expect([...trimPlanKeys(planOf("b", "ripple", "end"))].sort()).toEqual(["b", "c"]);
expect([...trimPlanKeys(planOf("b", "slide"))].sort()).toEqual(["a", "b", "c"]);
Expand Down
32 changes: 32 additions & 0 deletions packages/studio/src/player/components/timelineTrimOps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,38 @@ export function trimSnapAnchor(plan: TrimPlan): { time: number; sign: 1 | -1 } |
}
}

/**
* The pair of composition times the precision view shows for this gesture:
* the frame on either side of the edit point being moved. Slip has no edit
* point — it moves the media inside one clip — so it reports that clip's own
* first and last frame instead, which is what actually changes.
*/
export function trimPreviewFrames(
plan: TrimPlan,
delta: number,
frame: number,
): { outTime: number; inTime: number } {
const cutAt = (boundary: number) => ({
outTime: Math.max(0, boundary - frame),
inTime: Math.max(0, boundary),
});
switch (plan.mode) {
case "ripple":
// A head ripple pins the clip's start, so the edit point never moves —
// what changes is the material that starts there.
return cutAt(plan.edge === "end" ? endOf(plan.grabbed) + delta : plan.grabbed.start);
case "roll":
return cutAt(endOf(plan.left) + delta);
case "slide":
return cutAt(plan.grabbed.start + delta);
case "slip":
return {
outTime: Math.max(0, plan.grabbed.start),
inTime: Math.max(0, endOf(plan.grabbed) - frame),
};
}
}

/** Every clip key a plan may rewrite — the set the snap pass must ignore. */
export function trimPlanKeys(plan: TrimPlan): Set<string> {
switch (plan.mode) {
Expand Down
9 changes: 9 additions & 0 deletions packages/studio/src/player/components/timelineTrimSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,14 @@ import {
type TimelineSnapTarget,
} from "./timelineSnapping";
import { isMusicTrack } from "../../utils/timelineInspector";
import { frameToSeconds } from "../lib/time";
import type { TimelineTrimPreview } from "../store/trimPreviewSlice";
import {
applyTrimDelta,
clampTrimDelta,
resolveTrimPlan,
trimPlanKeys,
trimPreviewFrames,
trimSnapAnchor,
type TimelineTrimEdge,
type TimelineTrimMode,
Expand Down Expand Up @@ -266,6 +269,7 @@ export function applyTimelineTrimPreview(
session: TimelineTrimSession,
rawDeltaSeconds: number,
pps: number,
publishPreview?: (preview: TimelineTrimPreview) => void,
): TimelineGroupResizeChange | undefined {
const { plan, laneFloor } = session.trim;
const delta = clampTrimDelta(
Expand All @@ -274,6 +278,11 @@ export function applyTimelineTrimPreview(
resolveTimelineMinDuration(),
laneFloor,
);
publishPreview?.({
mode: session.trim.mode,
delta,
...trimPreviewFrames(plan, delta, frameToSeconds(1)),
});
const byKey = new Map(session.members.map((member) => [member.key, member]));
session.changes = applyTrimDelta(plan, delta).flatMap((change) => {
const member = byKey.get(change.key);
Expand Down
7 changes: 6 additions & 1 deletion packages/studio/src/player/components/useTimelineClipDrag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,12 @@ export function useTimelineClipDrag({
// A refused gesture (pointerdown already reported why) holds the clip at
// its authored timing rather than falling back to a plain trim.
const grabbed = session
? applyTimelineTrimPreview(session, (effectiveClientX - resize.originClientX) / pps, pps)
? applyTimelineTrimPreview(
session,
(effectiveClientX - resize.originClientX) / pps,
pps,
usePlayerStore.getState().setTrimPreview,
)
: undefined;
setResizeState({
originScrollLeft,
Expand Down
Loading
Loading