diff options
| author | cashmere <cashmere@autistici.org> | 2026-08-16 06:16:56 +0200 |
|---|---|---|
| committer | cashmere <cashmere@autistici.org> | 2026-08-16 06:16:56 +0200 |
| commit | f1043734438c8518a302f698d9ae6583a75f0c14 (patch) | |
| tree | d3e636a96e6349c1ad17afa7b28c9ffe5c882e37 /apps/web/src/timeline | |
| parent | 37d22b1e390bd6cea04bf8e15069281ee125cf10 (diff) | |
| download | opencut-f1043734438c8518a302f698d9ae6583a75f0c14.tar.gz | |
feat: add transitions, track mixing, and voiceover controls
Diffstat (limited to 'apps/web/src/timeline')
| -rw-r--r-- | apps/web/src/timeline/__tests__/audio-state.test.ts | 65 | ||||
| -rw-r--r-- | apps/web/src/timeline/audio-state.ts | 20 | ||||
| -rw-r--r-- | apps/web/src/timeline/components/__tests__/drop-target-transition.test.ts | 221 | ||||
| -rw-r--r-- | apps/web/src/timeline/components/drop-target.ts | 90 | ||||
| -rw-r--r-- | apps/web/src/timeline/components/index.tsx | 140 | ||||
| -rw-r--r-- | apps/web/src/timeline/components/timeline-toolbar.tsx | 27 | ||||
| -rw-r--r-- | apps/web/src/timeline/controllers/__tests__/drag-drop-controller-transition.test.ts | 190 | ||||
| -rw-r--r-- | apps/web/src/timeline/controllers/drag-drop-controller.ts | 71 | ||||
| -rw-r--r-- | apps/web/src/timeline/defaults.ts | 9 | ||||
| -rw-r--r-- | apps/web/src/timeline/drag.ts | 92 | ||||
| -rw-r--r-- | apps/web/src/timeline/hooks/use-timeline-drag-drop.ts | 1 | ||||
| -rw-r--r-- | apps/web/src/timeline/placement/track-factory.ts | 2 | ||||
| -rw-r--r-- | apps/web/src/timeline/scenes.ts | 229 | ||||
| -rw-r--r-- | apps/web/src/timeline/types.ts | 10 |
14 files changed, 934 insertions, 233 deletions
diff --git a/apps/web/src/timeline/__tests__/audio-state.test.ts b/apps/web/src/timeline/__tests__/audio-state.test.ts new file mode 100644 index 00000000..91c422cb --- /dev/null +++ b/apps/web/src/timeline/__tests__/audio-state.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "bun:test"; +import { + dBToLinear, + getTrackVolume, + resolveEffectiveAudioGain, +} from "@/timeline/audio-state"; +import type { AudioElement, AudioTrack } from "@/timeline"; +import { ZERO_MEDIA_TIME } from "@/wasm"; + +function audioElement(params?: AudioElement["params"]): AudioElement { + return { + id: "audio-1", + name: "Audio", + type: "audio", + sourceType: "upload", + mediaId: "media-1", + duration: ZERO_MEDIA_TIME, + startTime: ZERO_MEDIA_TIME, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, + params: params ?? { volume: 0, muted: false }, + }; +} + +describe("audio state", () => { + test("track volume offsets element gain in decibels", () => { + const gain = resolveEffectiveAudioGain({ + element: audioElement({ volume: -6, muted: false }), + trackVolume: -6, + localTime: 0, + }); + + expect(gain).toBeCloseTo(dBToLinear(-12), 6); + }); + test("track volume and mute resolve to playback gain", () => { + const element = audioElement({ volume: 0, muted: false }); + + expect( + resolveEffectiveAudioGain({ element, trackVolume: 0, localTime: 0 }), + ).toBe(1); + expect( + resolveEffectiveAudioGain({ element, trackVolume: -6, localTime: 0 }), + ).toBeCloseTo(dBToLinear(-6), 6); + expect( + resolveEffectiveAudioGain({ + element, + trackMuted: true, + trackVolume: 0, + localTime: 0, + }), + ).toBe(0); + }); + + test("missing track volume defaults to unity", () => { + const track: AudioTrack = { + id: "track-1", + name: "Audio track", + type: "audio", + elements: [], + muted: false, + }; + + expect(getTrackVolume({ track })).toBe(0); + }); +}); diff --git a/apps/web/src/timeline/audio-state.ts b/apps/web/src/timeline/audio-state.ts index 6c8f3044..34220cc9 100644 --- a/apps/web/src/timeline/audio-state.ts +++ b/apps/web/src/timeline/audio-state.ts @@ -1,7 +1,8 @@ import { hasKeyframesForPath } from "@/animation/keyframe-query"; import { resolveNumberAtTime } from "@/animation/values"; import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "./audio-constants"; -import type { TimelineElement } from "./types"; +import type { TimelineElement, TimelineTrack } from "./types"; +import { TICKS_PER_SECOND } from "@/wasm"; const DEFAULT_STEP_SECONDS = 1 / 60; export type AudioCapableElement = Extract< @@ -29,6 +30,13 @@ export function getElementVolume({ const value = element.params.volume; return typeof value === "number" ? value : 0; } +export function getTrackVolume({ + track, +}: { + track: Extract<TimelineTrack, { muted: boolean }>; +}): number { + return typeof track.volume === "number" ? track.volume : 0; +} export function isElementMuted({ element, @@ -49,15 +57,15 @@ export function hasAnimatedVolume({ }); } -import { TICKS_PER_SECOND } from "@/wasm"; - export function resolveEffectiveAudioGain({ element, trackMuted = false, + trackVolume = 0, localTime, }: { element: AudioCapableElement; trackMuted?: boolean; + trackVolume?: number; localTime: number; }): number { if (trackMuted || isElementMuted({ element })) { @@ -71,7 +79,7 @@ export function resolveEffectiveAudioGain({ localTime: Math.round(localTime * TICKS_PER_SECOND), }); - return dBToLinear(resolvedDb); + return dBToLinear(resolvedDb + trackVolume); } export function buildWaveformGainSamples({ @@ -91,12 +99,14 @@ export function buildWaveformGainSamples({ export function buildAudioGainAutomation({ element, trackMuted = false, + trackVolume = 0, fromLocalTime, toLocalTime, stepSeconds = DEFAULT_STEP_SECONDS, }: { element: AudioCapableElement; trackMuted?: boolean; + trackVolume?: number; fromLocalTime: number; toLocalTime: number; stepSeconds?: number; @@ -115,6 +125,7 @@ export function buildAudioGainAutomation({ gain: resolveEffectiveAudioGain({ element, trackMuted, + trackVolume, localTime, }), }); @@ -126,6 +137,7 @@ export function buildAudioGainAutomation({ element, trackMuted, localTime: endTime, + trackVolume, }), }); 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} />} diff --git a/apps/web/src/timeline/controllers/__tests__/drag-drop-controller-transition.test.ts b/apps/web/src/timeline/controllers/__tests__/drag-drop-controller-transition.test.ts new file mode 100644 index 00000000..b24f9ad9 --- /dev/null +++ b/apps/web/src/timeline/controllers/__tests__/drag-drop-controller-transition.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, mock, test } from "bun:test"; +import type { DragEvent } from "react"; +import type { SceneTracks, VideoElement, VideoTrack } from "@/timeline"; +import { + DragDropController, + type DragDropConfig, + type DragDropConfigRef, +} from "@/timeline/controllers/drag-drop-controller"; +import { TimelineDragSource } from "@/timeline/drag-source"; +import { mediaTime, TICKS_PER_SECOND, ZERO_MEDIA_TIME } from "@/wasm"; + +function buildVideo({ + id, + start, +}: { + id: string; + start: number; +}): VideoElement { + return { + id, + type: "video", + name: id, + mediaId: `media-${id}`, + startTime: mediaTime({ ticks: start }), + duration: mediaTime({ ticks: TICKS_PER_SECOND }), + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, + params: { opacity: id === "incoming" ? 0.8 : 1 }, + }; +} + +function buildTracks(): SceneTracks { + const main: VideoTrack = { + id: "visual", + type: "video", + name: "Visual", + elements: [ + buildVideo({ id: "outgoing", start: 0 }), + buildVideo({ id: "incoming", start: TICKS_PER_SECOND }), + ], + muted: false, + hidden: false, + }; + return { overlay: [], main, audio: [] }; +} + +function buildDataTransfer(): DataTransfer { + return { + types: [], + files: [], + dropEffect: "none", + effectAllowed: "none", + setData: mock(() => {}), + } as unknown as DataTransfer; +} + +function buildDragEvent({ + clientX, + dataTransfer, +}: { + clientX: number; + dataTransfer: DataTransfer; +}): DragEvent { + return { + clientX, + clientY: 10, + dataTransfer, + preventDefault: mock(() => {}), + } as unknown as DragEvent; +} + +function setup() { + const dragSource = new TimelineDragSource(); + const updateElements = mock<DragDropConfig["updateElements"]>(() => {}); + const insertElement = mock<DragDropConfig["insertElement"]>(() => {}); + const executeCommand = mock<DragDropConfig["executeCommand"]>(() => {}); + const tracksElement = { + scrollLeft: 0, + scrollTop: 0, + getBoundingClientRect: () => ({ left: 0, top: 0 }), + } as unknown as HTMLDivElement; + const config: DragDropConfig = { + zoomLevel: 1, + getContainerEl: () => tracksElement, + getHeaderEl: () => null, + getTracksScrollEl: () => tracksElement, + getActiveProjectFps: () => null, + getActiveProjectId: () => null, + getSceneTracks: buildTracks, + getCurrentPlayheadTime: () => ZERO_MEDIA_TIME, + getMediaAssets: () => [], + dragSource, + addMediaAsset: () => Promise.resolve(null), + executeCommand, + insertElement, + updateElements, + addClipEffect: mock(() => {}), + }; + const configRef: DragDropConfigRef = { current: config }; + return { + controller: new DragDropController({ configRef }), + dragSource, + updateElements, + insertElement, + executeCommand, + }; +} + +function beginTransitionDrag( + dragSource: TimelineDragSource, + dataTransfer: DataTransfer, +) { + dragSource.begin({ + dataTransfer, + dragData: { + type: "transition", + id: "push-left", + name: "Slide left", + transitionKind: "push-left", + inDuration: 0.45, + outDuration: 0, + }, + }); +} + +describe("DragDropController transition drops", () => { + test("hovers and applies a transition only to the incoming clip", () => { + const { + controller, + dragSource, + updateElements, + insertElement, + executeCommand, + } = setup(); + const dataTransfer = buildDataTransfer(); + beginTransitionDrag(dragSource, dataTransfer); + + controller.onDragOver(buildDragEvent({ clientX: 50, dataTransfer })); + + expect(dataTransfer.dropEffect).toBe("copy"); + expect(controller.dropTarget?.targetCut).toEqual({ + trackId: "visual", + outgoingElementId: "outgoing", + incomingElementId: "incoming", + time: mediaTime({ ticks: TICKS_PER_SECOND }), + }); + + controller.onDrop(buildDragEvent({ clientX: 50, dataTransfer })); + + expect(updateElements).toHaveBeenCalledTimes(1); + expect(updateElements).toHaveBeenCalledWith({ + updates: [ + { + trackId: "visual", + elementId: "incoming", + patch: { + params: { + opacity: 0.8, + "transition.kind": "push-left", + "transition.inDuration": 0.45, + "transition.outDuration": 0, + }, + }, + }, + ], + }); + expect(insertElement).not.toHaveBeenCalled(); + expect(executeCommand).not.toHaveBeenCalled(); + }); + + test("does nothing when the transition has no cut target", () => { + const { + controller, + dragSource, + updateElements, + insertElement, + executeCommand, + } = setup(); + const dataTransfer = buildDataTransfer(); + beginTransitionDrag(dragSource, dataTransfer); + + controller.onDragOver(buildDragEvent({ clientX: 80, dataTransfer })); + expect(controller.dropTarget).toBeNull(); + controller.onDrop(buildDragEvent({ clientX: 80, dataTransfer })); + + expect(updateElements).not.toHaveBeenCalled(); + expect(insertElement).not.toHaveBeenCalled(); + expect(executeCommand).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/timeline/controllers/drag-drop-controller.ts b/apps/web/src/timeline/controllers/drag-drop-controller.ts index a419101f..6b4c8bbf 100644 --- a/apps/web/src/timeline/controllers/drag-drop-controller.ts +++ b/apps/web/src/timeline/controllers/drag-drop-controller.ts @@ -17,7 +17,10 @@ import { import { AddTrackCommand, InsertElementCommand } from "@/commands/timeline"; import { BatchCommand } from "@/commands"; import type { Command } from "@/commands/base-command"; -import { computeDropTarget } from "@/timeline/components/drop-target"; +import { + computeDropTarget, + computeTransitionCutDropTarget, +} from "@/timeline/components/drop-target"; import type { TimelineDragSource } from "@/timeline/drag-source"; import type { TrackType, @@ -26,11 +29,13 @@ import type { SceneTracks, TimelineTrack, CreateTimelineElement, + TimelineElement, } from "@/timeline"; import type { TimelineDragData } from "@/timeline/drag"; import type { MediaAsset } from "@/media/types"; import type { ProcessedMediaAsset } from "@/media/processing"; import { roundFrameTime, type MediaTime } from "@/wasm"; +import { withVisualTransitionParams } from "@/rendering/transition"; // --- Config --- @@ -54,6 +59,14 @@ export interface DragDropConfig { placement: { mode: "explicit"; trackId: string }; element: CreateTimelineElement; }) => void; + updateElements: (args: { + updates: Array<{ + trackId: string; + elementId: string; + patch: Partial<TimelineElement>; + }>; + pushHistory?: boolean; + }) => void; addClipEffect: (args: { trackId: string; elementId: string; @@ -85,7 +98,7 @@ interface TimelineCoords { function elementTypeFromDrag({ dragData, }: { - dragData: TimelineDragData; + dragData: Exclude<TimelineDragData, { type: "transition" }>; }): ElementType { switch (dragData.type) { case "text": @@ -104,7 +117,7 @@ function elementTypeFromDrag({ function getTargetElementTypesForDrag({ dragData, }: { - dragData: TimelineDragData; + dragData: Exclude<TimelineDragData, { type: "transition" }>; }): string[] | undefined { if (dragData.type === "effect") return dragData.targetElementTypes; if (dragData.type === "media") return dragData.targetElementTypes; @@ -115,7 +128,7 @@ function getDurationForDrag({ dragData, mediaAssets, }: { - dragData: TimelineDragData; + dragData: Exclude<TimelineDragData, { type: "transition" }>; mediaAssets: MediaAsset[]; }): MediaTime { if (dragData.type !== "media") return DEFAULT_NEW_ELEMENT_DURATION; @@ -202,6 +215,18 @@ export class DragDropController { } return; } + if (dragData.type === "transition") { + const target = computeTransitionCutDropTarget({ + mouseX: coords.mouseX, + mouseY: coords.mouseY, + tracks: this.config.getSceneTracks(), + pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND, + zoomLevel: this.config.zoomLevel, + }); + this.setOver({ dropTarget: target, elementType: null }); + if (target) event.dataTransfer.dropEffect = "copy"; + return; + } const elementType = elementTypeFromDrag({ dragData }); const duration = getDurationForDrag({ @@ -374,9 +399,47 @@ export class DragDropController { case "media": this.executeMediaDrop({ target, dragData }); return; + case "transition": + this.executeTransitionDrop({ target, dragData }); + return; } } + private executeTransitionDrop({ + target, + dragData, + }: { + target: DropTarget; + dragData: Extract<TimelineDragData, { type: "transition" }>; + }): void { + const cut = target.targetCut; + if (!cut) return; + + const tracks = orderedTracks({ sceneTracks: this.config.getSceneTracks() }); + const track = tracks.find(({ id }) => id === cut.trackId); + const incoming = track?.elements.find( + ({ id }) => id === cut.incomingElementId, + ); + if (!incoming) return; + + this.config.updateElements({ + updates: [ + { + trackId: cut.trackId, + elementId: cut.incomingElementId, + patch: { + params: withVisualTransitionParams({ + params: incoming.params, + transitionKind: dragData.transitionKind, + inDuration: dragData.inDuration, + outDuration: dragData.outDuration, + }), + }, + }, + ], + }); + } + private executeTextDrop({ target, dragData, diff --git a/apps/web/src/timeline/defaults.ts b/apps/web/src/timeline/defaults.ts index d32fb649..bc679ab9 100644 --- a/apps/web/src/timeline/defaults.ts +++ b/apps/web/src/timeline/defaults.ts @@ -14,6 +14,9 @@ const defaultTransform: Transform = { const defaultOpacity = 1; const defaultBlendMode: BlendMode = "normal"; const defaultVolume = 0; +const defaultTransitionKind = "fade"; +const defaultTransitionDuration = 0; +const defaultTrackVolume = 0; const defaultTextLetterSpacing = 0; const defaultTextLineHeight = 1.2; @@ -60,6 +63,9 @@ const defaultTextElement: Omit<TextElement, "id"> = { "transform.rotate": defaultTransform.rotate, opacity: defaultOpacity, blendMode: defaultBlendMode, + "transition.kind": defaultTransitionKind, + "transition.inDuration": defaultTransitionDuration, + "transition.outDuration": defaultTransitionDuration, }, }; @@ -75,6 +81,9 @@ export const DEFAULTS = { opacity: defaultOpacity, blendMode: defaultBlendMode, volume: defaultVolume, + transitionKind: defaultTransitionKind, + transitionDuration: defaultTransitionDuration, + trackVolume: defaultTrackVolume, }, text: { letterSpacing: defaultTextLetterSpacing, diff --git a/apps/web/src/timeline/drag.ts b/apps/web/src/timeline/drag.ts index 7bf99a6d..06f721da 100644 --- a/apps/web/src/timeline/drag.ts +++ b/apps/web/src/timeline/drag.ts @@ -1,42 +1,50 @@ -import type { MaskableElement, VisualElement } from "./types";
-import type { ParamValues } from "@/params";
-
-interface BaseDragData {
- id: string;
- name: string;
-}
-
-export interface MediaDragData extends BaseDragData {
- type: "media";
- mediaType: "image" | "video" | "audio";
- targetElementTypes?: MaskableElement["type"][];
-}
-
-export interface TextDragData extends BaseDragData {
- type: "text";
- content: string;
-}
-
-export interface StickerDragData extends BaseDragData {
- type: "sticker";
- stickerId: string;
-}
-
-export interface GraphicDragData extends BaseDragData {
- type: "graphic";
- definitionId: string;
- params: Partial<ParamValues>;
-}
-
-export interface EffectDragData extends BaseDragData {
- type: "effect";
- effectType: string;
- targetElementTypes: VisualElement["type"][];
-}
-
-export type TimelineDragData =
- | MediaDragData
- | TextDragData
- | StickerDragData
- | GraphicDragData
- | EffectDragData;
+import type { MaskableElement, VisualElement } from "./types"; +import type { ParamValues } from "@/params"; +import type { TransitionKind } from "@/rendering/transition"; + +interface BaseDragData { + id: string; + name: string; +} + +export interface MediaDragData extends BaseDragData { + type: "media"; + mediaType: "image" | "video" | "audio"; + targetElementTypes?: MaskableElement["type"][]; +} + +export interface TextDragData extends BaseDragData { + type: "text"; + content: string; +} + +export interface StickerDragData extends BaseDragData { + type: "sticker"; + stickerId: string; +} + +export interface GraphicDragData extends BaseDragData { + type: "graphic"; + definitionId: string; + params: Partial<ParamValues>; +} + +export interface EffectDragData extends BaseDragData { + type: "effect"; + effectType: string; + targetElementTypes: VisualElement["type"][]; +} +export interface TransitionDragData extends BaseDragData { + type: "transition"; + transitionKind: TransitionKind; + inDuration: number; + outDuration: number; +} + +export type TimelineDragData = + | MediaDragData + | TextDragData + | StickerDragData + | GraphicDragData + | EffectDragData + | TransitionDragData; diff --git a/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts b/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts index c405819e..cde3874f 100644 --- a/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts +++ b/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts @@ -36,6 +36,7 @@ export function useTimelineDragDrop({ addMediaAsset: (args) => editor.media.addMediaAsset(args), executeCommand: (command) => editor.command.execute({ command }), insertElement: (args) => editor.timeline.insertElement(args), + updateElements: (args) => editor.timeline.updateElements(args), addClipEffect: (args) => editor.timeline.addClipEffect(args), }; const configRef = useCommittedRef(config); diff --git a/apps/web/src/timeline/placement/track-factory.ts b/apps/web/src/timeline/placement/track-factory.ts index 9c69ea8c..290141fc 100644 --- a/apps/web/src/timeline/placement/track-factory.ts +++ b/apps/web/src/timeline/placement/track-factory.ts @@ -84,6 +84,7 @@ export function buildEmptyTrack({ elements: [], hidden: false, muted: false, + volume: 0, }; case "text": return { @@ -108,6 +109,7 @@ export function buildEmptyTrack({ type: "audio", elements: [], muted: false, + volume: 0, }; case "effect": return { diff --git a/apps/web/src/timeline/scenes.ts b/apps/web/src/timeline/scenes.ts index 8aa1b6b5..155bda96 100644 --- a/apps/web/src/timeline/scenes.ts +++ b/apps/web/src/timeline/scenes.ts @@ -1,114 +1,115 @@ -import type { TScene } from "@/timeline";
-import { generateUUID } from "@/utils/id";
-import { calculateTotalDuration } from "@/timeline";
-import { MAIN_TRACK_NAME } from "@/timeline/placement/main-track";
-import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
-
-export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null {
- return scenes.find((scene) => scene.isMain) || null;
-}
-
-export function ensureMainScene({ scenes }: { scenes: TScene[] }): TScene[] {
- const hasMain = scenes.some((scene) => scene.isMain);
- if (!hasMain) {
- const mainScene = buildDefaultScene({ name: "Main scene", isMain: true });
- return [mainScene, ...scenes];
- }
- return scenes;
-}
-
-export function buildDefaultScene({
- name,
- isMain,
-}: {
- name: string;
- isMain: boolean;
-}): TScene {
- return {
- id: generateUUID(),
- name,
- isMain,
- tracks: {
- overlay: [],
- main: {
- id: generateUUID(),
- name: MAIN_TRACK_NAME,
- type: "video",
- elements: [],
- muted: false,
- hidden: false,
- },
- audio: [],
- },
- bookmarks: [],
- createdAt: new Date(),
- updatedAt: new Date(),
- };
-}
-
-export function canDeleteScene({ scene }: { scene: TScene }): {
- canDelete: boolean;
- reason?: string;
-} {
- if (scene.isMain) {
- return { canDelete: false, reason: "Cannot delete main scene" };
- }
- return { canDelete: true };
-}
-
-export function getFallbackSceneAfterDelete({
- scenes,
- deletedSceneId,
- currentSceneId,
-}: {
- scenes: TScene[];
- deletedSceneId: string;
- currentSceneId: string | null;
-}): TScene | null {
- if (currentSceneId !== deletedSceneId) {
- return scenes.find((s) => s.id === currentSceneId) || null;
- }
- return getMainScene({ scenes });
-}
-
-export function findCurrentScene({
- scenes,
- currentSceneId,
-}: {
- scenes: TScene[];
- currentSceneId: string;
-}): TScene | null {
- return (
- scenes.find((s) => s.id === currentSceneId) ||
- getMainScene({ scenes }) ||
- scenes[0] ||
- null
- );
-}
-
-export function getProjectDurationFromScenes({
- scenes,
-}: {
- scenes: TScene[];
-}): MediaTime {
- const mainScene = getMainScene({ scenes }) ?? scenes[0] ?? null;
- if (!mainScene?.tracks) {
- return ZERO_MEDIA_TIME;
- }
-
- return calculateTotalDuration({ tracks: mainScene.tracks });
-}
-
-export function updateSceneInArray({
- scenes,
- sceneId,
- updates,
-}: {
- scenes: TScene[];
- sceneId: string;
- updates: Partial<TScene>;
-}): TScene[] {
- return scenes.map((scene) =>
- scene.id === sceneId ? { ...scene, ...updates } : scene,
- );
-}
+import type { TScene } from "@/timeline"; +import { generateUUID } from "@/utils/id"; +import { calculateTotalDuration } from "@/timeline"; +import { MAIN_TRACK_NAME } from "@/timeline/placement/main-track"; +import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; + +export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null { + return scenes.find((scene) => scene.isMain) || null; +} + +export function ensureMainScene({ scenes }: { scenes: TScene[] }): TScene[] { + const hasMain = scenes.some((scene) => scene.isMain); + if (!hasMain) { + const mainScene = buildDefaultScene({ name: "Main scene", isMain: true }); + return [mainScene, ...scenes]; + } + return scenes; +} + +export function buildDefaultScene({ + name, + isMain, +}: { + name: string; + isMain: boolean; +}): TScene { + return { + id: generateUUID(), + name, + isMain, + tracks: { + overlay: [], + main: { + id: generateUUID(), + name: MAIN_TRACK_NAME, + type: "video", + elements: [], + muted: false, + volume: 0, + hidden: false, + }, + audio: [], + }, + bookmarks: [], + createdAt: new Date(), + updatedAt: new Date(), + }; +} + +export function canDeleteScene({ scene }: { scene: TScene }): { + canDelete: boolean; + reason?: string; +} { + if (scene.isMain) { + return { canDelete: false, reason: "Cannot delete main scene" }; + } + return { canDelete: true }; +} + +export function getFallbackSceneAfterDelete({ + scenes, + deletedSceneId, + currentSceneId, +}: { + scenes: TScene[]; + deletedSceneId: string; + currentSceneId: string | null; +}): TScene | null { + if (currentSceneId !== deletedSceneId) { + return scenes.find((s) => s.id === currentSceneId) || null; + } + return getMainScene({ scenes }); +} + +export function findCurrentScene({ + scenes, + currentSceneId, +}: { + scenes: TScene[]; + currentSceneId: string; +}): TScene | null { + return ( + scenes.find((s) => s.id === currentSceneId) || + getMainScene({ scenes }) || + scenes[0] || + null + ); +} + +export function getProjectDurationFromScenes({ + scenes, +}: { + scenes: TScene[]; +}): MediaTime { + const mainScene = getMainScene({ scenes }) ?? scenes[0] ?? null; + if (!mainScene?.tracks) { + return ZERO_MEDIA_TIME; + } + + return calculateTotalDuration({ tracks: mainScene.tracks }); +} + +export function updateSceneInArray({ + scenes, + sceneId, + updates, +}: { + scenes: TScene[]; + sceneId: string; + updates: Partial<TScene>; +}): TScene[] { + return scenes.map((scene) => + scene.id === sceneId ? { ...scene, ...updates } : scene, + ); +} diff --git a/apps/web/src/timeline/types.ts b/apps/web/src/timeline/types.ts index 5b3e4d29..48f3f592 100644 --- a/apps/web/src/timeline/types.ts +++ b/apps/web/src/timeline/types.ts @@ -37,6 +37,7 @@ export interface VideoTrack extends BaseTrack { type: "video"; elements: (VideoElement | ImageElement)[]; muted: boolean; + volume?: number; hidden: boolean; } @@ -50,6 +51,7 @@ export interface AudioTrack extends BaseTrack { type: "audio"; elements: AudioElement[]; muted: boolean; + volume?: number; } export interface GraphicTrack extends BaseTrack { @@ -256,12 +258,20 @@ export type ElementDragView = readonly dropTarget: DropTarget | null; }; +export interface TransitionCutTarget { + trackId: string; + outgoingElementId: string; + incomingElementId: string; + time: MediaTime; +} + export interface DropTarget { trackIndex: number; isNewTrack: boolean; insertPosition: "above" | "below" | null; xPosition: MediaTime; targetElement: { elementId: string; trackId: string } | null; + targetCut: TransitionCutTarget | null; } export interface ComputeDropTargetParams { |
