From f1043734438c8518a302f698d9ae6583a75f0c14 Mon Sep 17 00:00:00 2001 From: cashmere Date: Sun, 16 Aug 2026 06:16:56 +0200 Subject: feat: add transitions, track mixing, and voiceover controls --- .../__tests__/drop-target-transition.test.ts | 221 +++++++++++++++++++++ apps/web/src/timeline/components/drop-target.ts | 90 ++++++++- apps/web/src/timeline/components/index.tsx | 140 +++++++++---- .../src/timeline/components/timeline-toolbar.tsx | 27 +-- 4 files changed, 409 insertions(+), 69 deletions(-) create mode 100644 apps/web/src/timeline/components/__tests__/drop-target-transition.test.ts (limited to 'apps/web/src/timeline/components') 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} > - + e.scenes.getActiveSceneOrNull()); + const mediaAssets = useEditor((e) => e.media.getAssets()); + const selectTrack = usePropertiesStore((state) => state.selectTrack); const tracks = useMemo( () => 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 (
- {canTrackHaveAudio(track) && ( - { + 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 | null), + ? dragView.memberTimeOffsets + : (null as ReadonlyMap | null), [dragView], ); const sortedTracks = useMemo(() => { @@ -833,6 +835,18 @@ function TimelineTrackRows({ : null } /> + {isDragOver && dropTarget?.targetCut?.trackId === track.id && ( +
+ )}
@@ -915,24 +929,72 @@ function TrackToggleIcon({ }; onClick: () => void; }) { + const icon = isOff ? icons.off : icons.on; return ( - <> - {isOff ? ( - - ) : ( - + ); } +function TrackAudioButton({ + track, + onClick, +}: { + track: Extract; + onClick: () => void; +}) { + return ( + + ); +} + +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 ( 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 })} /> - - } - tooltip={ - isRecordingVoiceOver ? "Stop voice-over" : "Record voice-over" - } - isActive={isRecordingVoiceOver} - onClick={({ event }) => handleAction({ action: "voice-over", event })} - /> - - {isRecordingVoiceOver && ( - - {formatElapsedSeconds({ seconds: voiceOverElapsedSeconds })} - - )} + } -- cgit v1.3.1