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 --- .../web/src/timeline/__tests__/audio-state.test.ts | 65 ++++++ apps/web/src/timeline/audio-state.ts | 20 +- .../__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 +-- .../drag-drop-controller-transition.test.ts | 190 +++++++++++++++++ .../timeline/controllers/drag-drop-controller.ts | 71 ++++++- apps/web/src/timeline/defaults.ts | 9 + apps/web/src/timeline/drag.ts | 92 +++++---- .../src/timeline/hooks/use-timeline-drag-drop.ts | 1 + apps/web/src/timeline/placement/track-factory.ts | 2 + apps/web/src/timeline/scenes.ts | 229 +++++++++++---------- apps/web/src/timeline/types.ts | 10 + 14 files changed, 934 insertions(+), 233 deletions(-) create mode 100644 apps/web/src/timeline/__tests__/audio-state.test.ts create mode 100644 apps/web/src/timeline/components/__tests__/drop-target-transition.test.ts create mode 100644 apps/web/src/timeline/controllers/__tests__/drag-drop-controller-transition.test.ts (limited to 'apps/web/src/timeline') 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; +}): 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} > - + 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 })} - - )} + } 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(() => {}); + const insertElement = mock(() => {}); + const executeCommand = mock(() => {}); + 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; + }>; + pushHistory?: boolean; + }) => void; addClipEffect: (args: { trackId: string; elementId: string; @@ -85,7 +98,7 @@ interface TimelineCoords { function elementTypeFromDrag({ dragData, }: { - dragData: TimelineDragData; + dragData: Exclude; }): ElementType { switch (dragData.type) { case "text": @@ -104,7 +117,7 @@ function elementTypeFromDrag({ function getTargetElementTypesForDrag({ dragData, }: { - dragData: TimelineDragData; + dragData: Exclude; }): 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; 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; + }): 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 = { "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; -} - -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; +} + +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[] { - 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[] { + 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 { -- cgit v1.3.1