summaryrefslogtreecommitdiff
path: root/apps/web/src/timeline/components
diff options
context:
space:
mode:
authorcashmere <cashmere@autistici.org>2026-08-16 06:16:56 +0200
committercashmere <cashmere@autistici.org>2026-08-16 06:16:56 +0200
commitf1043734438c8518a302f698d9ae6583a75f0c14 (patch)
treed3e636a96e6349c1ad17afa7b28c9ffe5c882e37 /apps/web/src/timeline/components
parent37d22b1e390bd6cea04bf8e15069281ee125cf10 (diff)
downloadopencut-f1043734438c8518a302f698d9ae6583a75f0c14.tar.gz
feat: add transitions, track mixing, and voiceover controls
Diffstat (limited to 'apps/web/src/timeline/components')
-rw-r--r--apps/web/src/timeline/components/__tests__/drop-target-transition.test.ts221
-rw-r--r--apps/web/src/timeline/components/drop-target.ts90
-rw-r--r--apps/web/src/timeline/components/index.tsx140
-rw-r--r--apps/web/src/timeline/components/timeline-toolbar.tsx27
4 files changed, 409 insertions, 69 deletions
diff --git a/apps/web/src/timeline/components/__tests__/drop-target-transition.test.ts b/apps/web/src/timeline/components/__tests__/drop-target-transition.test.ts
new file mode 100644
index 00000000..d3246a19
--- /dev/null
+++ b/apps/web/src/timeline/components/__tests__/drop-target-transition.test.ts
@@ -0,0 +1,221 @@
+import { describe, expect, test } from "bun:test";
+import type {
+ AudioTrack,
+ EffectTrack,
+ ImageElement,
+ SceneTracks,
+ TextTrack,
+ VideoElement,
+ VideoTrack,
+} from "@/timeline";
+import { computeTransitionCutDropTarget } from "@/timeline/components/drop-target";
+import { mediaTime, TICKS_PER_SECOND, ZERO_MEDIA_TIME } from "@/wasm";
+
+const PIXELS_PER_SECOND = 50;
+
+function buildImage({
+ id,
+ start,
+ duration,
+}: {
+ id: string;
+ start: number;
+ duration: number;
+}): ImageElement {
+ return {
+ id,
+ type: "image",
+ name: id,
+ mediaId: `media-${id}`,
+ startTime: mediaTime({ ticks: start }),
+ duration: mediaTime({ ticks: duration }),
+ trimStart: ZERO_MEDIA_TIME,
+ trimEnd: ZERO_MEDIA_TIME,
+ params: { opacity: 0.75 },
+ };
+}
+
+function buildVideo({
+ id,
+ start,
+ duration,
+}: {
+ id: string;
+ start: number;
+ duration: number;
+}): VideoElement {
+ return {
+ id,
+ type: "video",
+ name: id,
+ mediaId: `media-${id}`,
+ startTime: mediaTime({ ticks: start }),
+ duration: mediaTime({ ticks: duration }),
+ trimStart: ZERO_MEDIA_TIME,
+ trimEnd: ZERO_MEDIA_TIME,
+ params: { opacity: 1 },
+ };
+}
+
+function buildVideoTrack({
+ elements,
+}: {
+ elements: VideoTrack["elements"];
+}): VideoTrack {
+ return {
+ id: "visual",
+ type: "video",
+ name: "Visual",
+ elements,
+ muted: false,
+ hidden: false,
+ };
+}
+
+function buildTracks({
+ main,
+ overlay = [],
+ audio = [],
+}: {
+ main: VideoTrack;
+ overlay?: SceneTracks["overlay"];
+ audio?: AudioTrack[];
+}): SceneTracks {
+ return { main, overlay, audio };
+}
+
+function resolve({
+ tracks,
+ mouseX,
+ mouseY = 10,
+}: {
+ tracks: SceneTracks;
+ mouseX: number;
+ mouseY?: number;
+}) {
+ return computeTransitionCutDropTarget({
+ mouseX,
+ mouseY,
+ tracks,
+ pixelsPerSecond: PIXELS_PER_SECOND,
+ zoomLevel: 1,
+ });
+}
+
+const oneSecond = TICKS_PER_SECOND;
+
+function adjacentTrack(): VideoTrack {
+ return buildVideoTrack({
+ elements: [
+ buildImage({ id: "outgoing", start: 0, duration: oneSecond }),
+ buildVideo({ id: "incoming", start: oneSecond, duration: oneSecond }),
+ ],
+ });
+}
+
+describe("computeTransitionCutDropTarget", () => {
+ test("resolves an adjacent image and video cut within 12 screen pixels", () => {
+ const target = resolve({
+ tracks: buildTracks({ main: adjacentTrack() }),
+ mouseX: 55,
+ });
+
+ expect(target).toEqual({
+ trackIndex: 0,
+ isNewTrack: false,
+ insertPosition: null,
+ xPosition: mediaTime({ ticks: oneSecond }),
+ targetElement: null,
+ targetCut: {
+ trackId: "visual",
+ outgoingElementId: "outgoing",
+ incomingElementId: "incoming",
+ time: mediaTime({ ticks: oneSecond }),
+ },
+ });
+ });
+
+ test("rejects a pointer beyond 12 screen pixels", () => {
+ expect(
+ resolve({ tracks: buildTracks({ main: adjacentTrack() }), mouseX: 63 }),
+ ).toBeNull();
+ });
+
+ test("rejects gaps and overlaps", () => {
+ const incoming = buildVideo({
+ id: "incoming",
+ start: oneSecond,
+ duration: oneSecond,
+ });
+ const gap = buildVideoTrack({
+ elements: [
+ buildImage({ id: "outgoing", start: 0, duration: oneSecond - 1 }),
+ incoming,
+ ],
+ });
+ const overlap = buildVideoTrack({
+ elements: [
+ buildImage({ id: "outgoing", start: 0, duration: oneSecond + 1 }),
+ incoming,
+ ],
+ });
+
+ expect(
+ resolve({ tracks: buildTracks({ main: gap }), mouseX: 50 }),
+ ).toBeNull();
+ expect(
+ resolve({ tracks: buildTracks({ main: overlap }), mouseX: 50 }),
+ ).toBeNull();
+ });
+
+ test("rejects the first visual clip boundary", () => {
+ expect(
+ resolve({ tracks: buildTracks({ main: adjacentTrack() }), mouseX: 0 }),
+ ).toBeNull();
+ });
+
+ test("rejects audio, text, and effect tracks", () => {
+ const textTrack: TextTrack = {
+ id: "text",
+ type: "text",
+ name: "Text",
+ elements: [],
+ hidden: false,
+ };
+ const effectTrack: EffectTrack = {
+ id: "effect",
+ type: "effect",
+ name: "Effect",
+ elements: [],
+ hidden: false,
+ };
+ const audioTrack: AudioTrack = {
+ id: "audio",
+ type: "audio",
+ name: "Audio",
+ elements: [],
+ muted: false,
+ };
+ const main = adjacentTrack();
+
+ expect(
+ resolve({
+ tracks: buildTracks({ main, overlay: [textTrack] }),
+ mouseX: 50,
+ }),
+ ).toBeNull();
+ expect(
+ resolve({
+ tracks: buildTracks({ main, overlay: [effectTrack] }),
+ mouseX: 50,
+ }),
+ ).toBeNull();
+ expect(
+ resolve({
+ tracks: buildTracks({ main, audio: [audioTrack] }),
+ mouseX: 50,
+ mouseY: 72,
+ }),
+ ).toBeNull();
+ });
+});
diff --git a/apps/web/src/timeline/components/drop-target.ts b/apps/web/src/timeline/components/drop-target.ts
index f68a4b72..e22c8af9 100644
--- a/apps/web/src/timeline/components/drop-target.ts
+++ b/apps/web/src/timeline/components/drop-target.ts
@@ -1,5 +1,9 @@
-import type { TimelineTrack, TimelineElement } from "@/timeline";
-import type { ComputeDropTargetParams, DropTarget } from "@/timeline";
+import type {
+ ComputeDropTargetParams,
+ DropTarget,
+ TimelineElement,
+ TimelineTrack,
+} from "@/timeline";
import { resolveTrackPlacement } from "@/timeline/placement";
import { TIMELINE_TRACK_GAP_PX } from "./layout";
import { getTrackHeight } from "./track-layout";
@@ -97,6 +101,71 @@ function fallbackNewTrackDropTarget({
insertPosition: null,
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
+ targetCut: null,
+ };
+}
+
+export function computeTransitionCutDropTarget({
+ mouseX,
+ mouseY,
+ tracks,
+ pixelsPerSecond,
+ zoomLevel,
+}: {
+ mouseX: number;
+ mouseY: number;
+ tracks: ComputeDropTargetParams["tracks"];
+ pixelsPerSecond: number;
+ zoomLevel: number;
+}): DropTarget | null {
+ const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
+ const trackAtMouse = getTrackAtY({ mouseY, tracks: orderedTracks });
+ if (!trackAtMouse) return null;
+
+ const track = orderedTracks[trackAtMouse.trackIndex];
+ if (!track || track.type !== "video") return null;
+
+ const elements = track.elements
+ .filter((element) => element.type === "video" || element.type === "image")
+ .slice()
+ .sort((left, right) => left.startTime - right.startTime);
+ let nearest: {
+ outgoingElementId: string;
+ incomingElementId: string;
+ time: MediaTime;
+ distance: number;
+ } | null = null;
+
+ for (let index = 1; index < elements.length; index += 1) {
+ const outgoing = elements[index - 1];
+ const incoming = elements[index];
+ const cutTime = incoming.startTime;
+ if (outgoing.startTime + outgoing.duration !== cutTime) continue;
+
+ const cutX = (cutTime / TICKS_PER_SECOND) * pixelsPerSecond * zoomLevel;
+ const distance = Math.abs(mouseX - cutX);
+ if (distance > 12 || (nearest && nearest.distance <= distance)) continue;
+ nearest = {
+ outgoingElementId: outgoing.id,
+ incomingElementId: incoming.id,
+ time: cutTime,
+ distance,
+ };
+ }
+
+ if (!nearest) return null;
+ return {
+ trackIndex: trackAtMouse.trackIndex,
+ isNewTrack: false,
+ insertPosition: null,
+ xPosition: nearest.time,
+ targetElement: null,
+ targetCut: {
+ trackId: track.id,
+ outgoingElementId: nearest.outgoingElementId,
+ incomingElementId: nearest.incomingElementId,
+ time: nearest.time,
+ },
};
}
@@ -133,7 +202,9 @@ export function computeDropTarget({
const placementResult = resolveTrackPlacement({
tracks,
elementType,
- timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
+ timeSpans: [
+ { startTime: xPosition, duration: elementDuration, excludeElementId },
+ ],
strategy: {
type: "preferIndex",
trackIndex: 0,
@@ -153,6 +224,7 @@ export function computeDropTarget({
insertPosition: emptyTimelineResult.insertPosition,
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
+ targetCut: null,
};
}
@@ -168,7 +240,9 @@ export function computeDropTarget({
const placementResult = resolveTrackPlacement({
tracks,
elementType,
- timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
+ timeSpans: [
+ { startTime: xPosition, duration: elementDuration, excludeElementId },
+ ],
strategy: {
type: "preferIndex",
trackIndex: isAboveAllTracks ? 0 : orderedTracks.length - 1,
@@ -188,6 +262,7 @@ export function computeDropTarget({
insertPosition: outOfBoundsResult.insertPosition,
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
+ targetCut: null,
};
}
@@ -210,6 +285,7 @@ export function computeDropTarget({
insertPosition: null,
xPosition,
targetElement,
+ targetCut: null,
};
}
}
@@ -218,7 +294,9 @@ export function computeDropTarget({
const placementResult = resolveTrackPlacement({
tracks,
elementType,
- timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
+ timeSpans: [
+ { startTime: xPosition, duration: elementDuration, excludeElementId },
+ ],
strategy: {
type: "preferIndex",
trackIndex,
@@ -242,6 +320,7 @@ export function computeDropTarget({
insertPosition: null,
xPosition: adjustedXPosition,
targetElement: EMPTY_TARGET_ELEMENT,
+ targetCut: null,
};
}
@@ -251,6 +330,7 @@ export function computeDropTarget({
insertPosition: placementResult.insertPosition,
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
+ targetCut: null,
};
}
diff --git a/apps/web/src/timeline/components/index.tsx b/apps/web/src/timeline/components/index.tsx
index 8edbb6fc..527f6d8d 100644
--- a/apps/web/src/timeline/components/index.tsx
+++ b/apps/web/src/timeline/components/index.tsx
@@ -53,6 +53,8 @@ import {
getTimelineZoomMin,
getTimelinePaddingPx,
} from "@/timeline";
+import { doesElementHaveEnabledAudio } from "@/timeline/audio-separation";
+import type { MediaAsset } from "@/media/types";
import { timelineTimeToPixels } from "@/timeline/pixel-utils";
import {
getTrackHeight,
@@ -84,9 +86,11 @@ import { useEditor } from "@/editor/use-editor";
import { useScrollPosition } from "@/timeline/hooks/use-scroll-position";
import { useTimelinePlayhead } from "@/timeline/hooks/use-timeline-playhead";
import { DragLine } from "./drag-line";
+import { TIMELINE_LAYERS } from "./layers";
import { invokeAction } from "@/actions";
import { resolveTimelineElementIntersections } from "./selection-hit-testing";
import { cn } from "@/utils/ui";
+import { usePropertiesStore } from "@/components/editor/panels/properties/stores/properties-store";
const TRACKS_CONTAINER_MAX_HEIGHT = 800;
const FALLBACK_CONTAINER_WIDTH = 1000;
@@ -302,12 +306,12 @@ export function Timeline() {
const { dragView, handleElementMouseDown, handleElementClick } =
useElementInteraction({
- zoomLevel,
- tracksContainerRef,
- tracksScrollRef,
- snappingEnabled,
- onSnapPointChange: handleSnapPointChange,
- });
+ zoomLevel,
+ tracksContainerRef,
+ tracksScrollRef,
+ snappingEnabled,
+ onSnapPointChange: handleSnapPointChange,
+ });
const isElementDragging = dragView.kind === "dragging";
const {
@@ -455,13 +459,13 @@ export function Timeline() {
className="relative isolate flex flex-1 flex-col overflow-hidden"
ref={tracksContainerRef}
>
- <SelectionBox
- bounds={selectionBox?.bounds ?? null}
- />
+ <SelectionBox bounds={selectionBox?.bounds ?? null} />
<DragLine
dropTarget={dropTarget}
tracks={tracks}
- isVisible={isDragOver && !dropTarget?.targetElement}
+ isVisible={
+ isDragOver && !dropTarget?.targetElement && !dropTarget?.targetCut
+ }
headerHeight={timelineHeaderHeight}
/>
<DragLine
@@ -612,6 +616,8 @@ function TrackLabelsPanel({
}) {
const editor = useEditor();
const scene = useEditor((e) => e.scenes.getActiveSceneOrNull());
+ const mediaAssets = useEditor((e) => e.media.getAssets());
+ const selectTrack = usePropertiesStore((state) => state.selectTrack);
const tracks = useMemo<TimelineTrack[]>(
() =>
scene
@@ -619,7 +625,7 @@ function TrackLabelsPanel({
: [],
[scene],
);
- const { selectedElements } = useElementSelection();
+ const { selectedElements, clearElementSelection } = useElementSelection();
const tracksWithSelection = useMemo(
() => new Set(selectedElements.map((el) => el.trackId)),
[selectedElements],
@@ -653,6 +659,7 @@ function TrackLabelsPanel({
{tracks.map((track, index) => {
const expandedRows = trackExpandedRowsMap[index];
const baseHeight = getTrackHeight({ type: track.type });
+ const hasAudio = trackContainsAudio({ track, mediaAssets });
return (
<div
@@ -667,21 +674,16 @@ function TrackLabelsPanel({
}}
>
<div
- className="flex shrink-0 items-center justify-end gap-2 px-3"
+ className="flex shrink-0 items-center justify-end gap-1.5 px-2"
style={{ height: `${baseHeight}px` }}
>
- {canTrackHaveAudio(track) && (
- <TrackToggleIcon
- isOff={track.muted}
- icons={{
- on: VolumeHighIcon,
- off: VolumeOffIcon,
+ {hasAudio && canTrackHaveAudio(track) && (
+ <TrackAudioButton
+ track={track}
+ onClick={() => {
+ clearElementSelection();
+ selectTrack({ trackId: track.id });
}}
- onClick={() =>
- editor.timeline.toggleTrackMute({
- trackId: track.id,
- })
- }
/>
)}
{canTrackBeHidden(track) && (
@@ -780,8 +782,8 @@ function TimelineTrackRows({
const draggingElementIds = useMemo(
() =>
dragView.kind === "dragging"
- ? dragView.memberTimeOffsets
- : (null as ReadonlyMap<string, MediaTime> | null),
+ ? dragView.memberTimeOffsets
+ : (null as ReadonlyMap<string, MediaTime> | null),
[dragView],
);
const sortedTracks = useMemo(() => {
@@ -833,6 +835,18 @@ function TimelineTrackRows({
: null
}
/>
+ {isDragOver && dropTarget?.targetCut?.trackId === track.id && (
+ <div
+ className="bg-primary pointer-events-none absolute inset-y-0 w-0.5 -translate-x-1/2 shadow-[0_0_8px_var(--primary)]"
+ style={{
+ left: timelineTimeToPixels({
+ time: dropTarget.targetCut.time,
+ zoomLevel,
+ }),
+ zIndex: TIMELINE_LAYERS.dragLine,
+ }}
+ />
+ )}
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-40">
@@ -915,25 +929,73 @@ function TrackToggleIcon({
};
onClick: () => void;
}) {
+ const icon = isOff ? icons.off : icons.on;
return (
- <>
- {isOff ? (
- <HugeiconsIcon
- icon={icons.off}
- className="text-destructive size-4 cursor-pointer"
- onClick={onClick}
- />
- ) : (
- <HugeiconsIcon
- icon={icons.on}
- className="text-muted-foreground size-4 cursor-pointer"
- onClick={onClick}
- />
+ <button
+ type="button"
+ className={cn(
+ "flex size-6 items-center justify-center rounded-sm hover:bg-accent",
+ isOff ? "text-destructive" : "text-muted-foreground",
)}
- </>
+ onClick={onClick}
+ >
+ <HugeiconsIcon icon={icon} className="size-4" />
+ </button>
+ );
+}
+function TrackAudioButton({
+ track,
+ onClick,
+}: {
+ track: Extract<TimelineTrack, { muted: boolean }>;
+ onClick: () => void;
+}) {
+ return (
+ <button
+ type="button"
+ className={cn(
+ "flex size-6 items-center justify-center rounded-sm hover:bg-accent",
+ track.muted ? "text-destructive" : "text-muted-foreground",
+ )}
+ aria-label={`Edit ${track.name} audio`}
+ onClick={onClick}
+ >
+ <HugeiconsIcon
+ icon={track.muted ? VolumeOffIcon : VolumeHighIcon}
+ className="size-4"
+ />
+ </button>
);
}
+function trackContainsAudio({
+ track,
+ mediaAssets,
+}: {
+ track: TimelineTrack;
+ mediaAssets: MediaAsset[];
+}): boolean {
+ if (!canTrackHaveAudio(track)) {
+ return false;
+ }
+
+ for (const element of track.elements) {
+ if (element.type === "audio") {
+ return true;
+ }
+ if (element.type !== "video") {
+ continue;
+ }
+ const mediaAsset = mediaAssets.find(
+ (asset) => asset.id === element.mediaId,
+ );
+ if (doesElementHaveEnabledAudio({ element, mediaAsset })) {
+ return true;
+ }
+ }
+ return false;
+}
+
function PropertyTree({ rows }: { rows: ExpandedRow[] }) {
return (
<div className="flex flex-col overflow-hidden">
diff --git a/apps/web/src/timeline/components/timeline-toolbar.tsx b/apps/web/src/timeline/components/timeline-toolbar.tsx
index 47906931..ba9a99fa 100644
--- a/apps/web/src/timeline/components/timeline-toolbar.tsx
+++ b/apps/web/src/timeline/components/timeline-toolbar.tsx
@@ -43,17 +43,13 @@ import {
Layers01Icon,
Chart03Icon,
Unlink02Icon,
- MicIcon,
- StopIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { OcRippleIcon } from "@/components/icons";
import { GraphEditorPopover } from "./graph-editor/popover";
import { PopoverTrigger } from "@/components/ui/popover";
import { useGraphEditorController } from "./graph-editor/use-controller";
-import { formatElapsedSeconds } from "@/voiceover/recorder";
-import { useRecordingElapsedSeconds } from "@/voiceover/use-recording-elapsed";
-import { useVoiceOverStore } from "@/voiceover/voiceover-store";
+import { VoiceOverRecorderPopover } from "@/voiceover/voiceover-popover";
export function TimelineToolbar({
zoomLevel,
@@ -100,10 +96,6 @@ function ToolbarLeftSection() {
const isCurrentlyBookmarked = useEditor((e) =>
e.scenes.isBookmarked({ time: e.playback.getCurrentTime() }),
);
- const isRecordingVoiceOver = useVoiceOverStore(
- (state) => state.status === "recording",
- );
- const voiceOverElapsedSeconds = useRecordingElapsedSeconds();
const canFreezeFrame = useEditor((e) => {
const time = e.playback.getCurrentTime();
const { overlay, main } = e.scenes.getActiveScene().tracks;
@@ -211,22 +203,7 @@ function ToolbarLeftSection() {
onClick={({ event }) => handleAction({ action: "freeze-frame", event })}
/>
- <ToolbarButton
- icon={
- <HugeiconsIcon icon={isRecordingVoiceOver ? StopIcon : MicIcon} />
- }
- tooltip={
- isRecordingVoiceOver ? "Stop voice-over" : "Record voice-over"
- }
- isActive={isRecordingVoiceOver}
- onClick={({ event }) => handleAction({ action: "voice-over", event })}
- />
-
- {isRecordingVoiceOver && (
- <span className="text-destructive min-w-9 text-center text-xs tabular-nums">
- {formatElapsedSeconds({ seconds: voiceOverElapsedSeconds })}
- </span>
- )}
+ <VoiceOverRecorderPopover />
<ToolbarButton
icon={<HugeiconsIcon icon={Delete02Icon} />}