summaryrefslogtreecommitdiff
path: root/apps/web/src/timeline
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/timeline')
-rw-r--r--apps/web/src/timeline/__tests__/audio-state.test.ts65
-rw-r--r--apps/web/src/timeline/audio-state.ts20
-rw-r--r--apps/web/src/timeline/components/__tests__/drop-target-transition.test.ts221
-rw-r--r--apps/web/src/timeline/components/drop-target.ts90
-rw-r--r--apps/web/src/timeline/components/index.tsx140
-rw-r--r--apps/web/src/timeline/components/timeline-toolbar.tsx27
-rw-r--r--apps/web/src/timeline/controllers/__tests__/drag-drop-controller-transition.test.ts190
-rw-r--r--apps/web/src/timeline/controllers/drag-drop-controller.ts71
-rw-r--r--apps/web/src/timeline/defaults.ts9
-rw-r--r--apps/web/src/timeline/drag.ts92
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-drag-drop.ts1
-rw-r--r--apps/web/src/timeline/placement/track-factory.ts2
-rw-r--r--apps/web/src/timeline/scenes.ts229
-rw-r--r--apps/web/src/timeline/types.ts10
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 {