refactor: structured track layout per scene
This commit is contained in:
parent
4f7d401a97
commit
cbd1b82123
|
|
@ -59,7 +59,7 @@ export function Captions() {
|
||||||
setProcessingStep("Extracting audio...");
|
setProcessingStep("Extracting audio...");
|
||||||
|
|
||||||
const audioBlob = await extractTimelineAudio({
|
const audioBlob = await extractTimelineAudio({
|
||||||
tracks: editor.timeline.getTracks(),
|
tracks: editor.scenes.getActiveScene().tracks,
|
||||||
mediaAssets: editor.media.getAssets(),
|
mediaAssets: editor.media.getAssets(),
|
||||||
totalDuration: editor.timeline.getTotalDuration(),
|
totalDuration: editor.timeline.getTotalDuration(),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,9 @@ export function PreviewPanel() {
|
||||||
|
|
||||||
function RenderTreeController() {
|
function RenderTreeController() {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const tracks = useEditor((e) => e.timeline.getRenderTracks());
|
const tracks = useEditor(
|
||||||
|
(e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks,
|
||||||
|
);
|
||||||
const mediaAssets = useEditor((e) => e.media.getAssets());
|
const mediaAssets = useEditor((e) => e.media.getAssets());
|
||||||
const activeProject = useEditor((e) => e.project.getActive());
|
const activeProject = useEditor((e) => e.project.getActive());
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import { EmptyView } from "./empty-view";
|
||||||
|
|
||||||
export function PropertiesPanel() {
|
export function PropertiesPanel() {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
useEditor((e) => e.timeline.getTracks());
|
useEditor((e) => e.scenes.getActiveSceneOrNull());
|
||||||
useEditor((e) => e.media.getAssets());
|
useEditor((e) => e.media.getAssets());
|
||||||
const { selectedElements } = useElementSelection();
|
const { selectedElements } = useElementSelection();
|
||||||
const { activeTabPerType, setActiveTab } = usePropertiesStore();
|
const { activeTabPerType, setActiveTab } = usePropertiesStore();
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,9 @@ export function MasksTab({ element, trackId }: MasksTabProps) {
|
||||||
fallback: element,
|
fallback: element,
|
||||||
});
|
});
|
||||||
const maskDefs = masksRegistry.getAll();
|
const maskDefs = masksRegistry.getAll();
|
||||||
const tracks = useEditor((e) => e.timeline.getRenderTracks());
|
const tracks = useEditor(
|
||||||
|
(e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks,
|
||||||
|
);
|
||||||
const currentTime = useEditor((e) => e.playback.getCurrentTime());
|
const currentTime = useEditor((e) => e.playback.getCurrentTime());
|
||||||
const mediaAssets = useEditor((e) => e.media.getAssets());
|
const mediaAssets = useEditor((e) => e.media.getAssets());
|
||||||
const canvasSize = useEditor(
|
const canvasSize = useEditor(
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,8 @@ export function computeDropTarget({
|
||||||
excludeElementId,
|
excludeElementId,
|
||||||
targetElementTypes,
|
targetElementTypes,
|
||||||
}: ComputeDropTargetParams): DropTarget {
|
}: ComputeDropTargetParams): DropTarget {
|
||||||
|
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
|
||||||
|
const mainTrackIndex = tracks.overlay.length;
|
||||||
const xPosition =
|
const xPosition =
|
||||||
typeof startTimeOverride === "number"
|
typeof startTimeOverride === "number"
|
||||||
? startTimeOverride
|
? startTimeOverride
|
||||||
|
|
@ -112,7 +114,7 @@ export function computeDropTarget({
|
||||||
? playheadTime
|
? playheadTime
|
||||||
: Math.max(0, mouseX / (pixelsPerSecond * zoomLevel));
|
: Math.max(0, mouseX / (pixelsPerSecond * zoomLevel));
|
||||||
|
|
||||||
if (tracks.length === 0) {
|
if (orderedTracks.length === 0) {
|
||||||
const placementResult = resolveTrackPlacement({
|
const placementResult = resolveTrackPlacement({
|
||||||
tracks,
|
tracks,
|
||||||
elementType,
|
elementType,
|
||||||
|
|
@ -139,7 +141,11 @@ export function computeDropTarget({
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const trackAtMouse = getTrackAtY({ mouseY, tracks, verticalDragDirection });
|
const trackAtMouse = getTrackAtY({
|
||||||
|
mouseY,
|
||||||
|
tracks: orderedTracks,
|
||||||
|
verticalDragDirection,
|
||||||
|
});
|
||||||
|
|
||||||
if (!trackAtMouse) {
|
if (!trackAtMouse) {
|
||||||
const isAboveAllTracks = mouseY < 0;
|
const isAboveAllTracks = mouseY < 0;
|
||||||
|
|
@ -150,7 +156,7 @@ export function computeDropTarget({
|
||||||
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
|
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
|
||||||
strategy: {
|
strategy: {
|
||||||
type: "preferIndex",
|
type: "preferIndex",
|
||||||
trackIndex: isAboveAllTracks ? 0 : tracks.length - 1,
|
trackIndex: isAboveAllTracks ? 0 : orderedTracks.length - 1,
|
||||||
hoverDirection: isAboveAllTracks ? "above" : "below",
|
hoverDirection: isAboveAllTracks ? "above" : "below",
|
||||||
createNewTrackOnly: true,
|
createNewTrackOnly: true,
|
||||||
},
|
},
|
||||||
|
|
@ -171,12 +177,12 @@ export function computeDropTarget({
|
||||||
}
|
}
|
||||||
|
|
||||||
const { trackIndex, relativeY } = trackAtMouse;
|
const { trackIndex, relativeY } = trackAtMouse;
|
||||||
const track = tracks[trackIndex];
|
const track = orderedTracks[trackIndex];
|
||||||
|
|
||||||
if (targetElementTypes && targetElementTypes.length > 0) {
|
if (targetElementTypes && targetElementTypes.length > 0) {
|
||||||
const targetElement = findElementAtPosition({
|
const targetElement = findElementAtPosition({
|
||||||
mouseX,
|
mouseX,
|
||||||
tracks,
|
tracks: orderedTracks,
|
||||||
trackIndex,
|
trackIndex,
|
||||||
targetElementTypes,
|
targetElementTypes,
|
||||||
pixelsPerSecond,
|
pixelsPerSecond,
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import type {
|
||||||
ScalarGraphKeyframeContext,
|
ScalarGraphKeyframeContext,
|
||||||
SelectedKeyframeRef,
|
SelectedKeyframeRef,
|
||||||
} from "@/lib/animation/types";
|
} from "@/lib/animation/types";
|
||||||
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
|
import type { SceneTracks, TimelineElement } from "@/lib/timeline";
|
||||||
|
|
||||||
const GRAPH_LINEAR_CURVE: NormalizedCubicBezier = [0, 0, 1, 1];
|
const GRAPH_LINEAR_CURVE: NormalizedCubicBezier = [0, 0, 1, 1];
|
||||||
const FLAT_VALUE_EPSILON = 1e-6;
|
const FLAT_VALUE_EPSILON = 1e-6;
|
||||||
|
|
@ -91,10 +91,10 @@ function findElementByKeyframe({
|
||||||
tracks,
|
tracks,
|
||||||
keyframe,
|
keyframe,
|
||||||
}: {
|
}: {
|
||||||
tracks: TimelineTrack[];
|
tracks: SceneTracks;
|
||||||
keyframe: SelectedKeyframeRef;
|
keyframe: SelectedKeyframeRef;
|
||||||
}): { element: TimelineElement; trackId: string; elementId: string } | null {
|
}): { element: TimelineElement; trackId: string; elementId: string } | null {
|
||||||
for (const track of tracks) {
|
for (const track of [...tracks.overlay, tracks.main, ...tracks.audio]) {
|
||||||
if (track.id !== keyframe.trackId) {
|
if (track.id !== keyframe.trackId) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -180,7 +180,7 @@ export function resolveGraphEditorSelectionState({
|
||||||
selectedKeyframes,
|
selectedKeyframes,
|
||||||
preferredComponentKey,
|
preferredComponentKey,
|
||||||
}: {
|
}: {
|
||||||
tracks: TimelineTrack[];
|
tracks: SceneTracks;
|
||||||
selectedKeyframes: SelectedKeyframeRef[];
|
selectedKeyframes: SelectedKeyframeRef[];
|
||||||
preferredComponentKey?: string | null;
|
preferredComponentKey?: string | null;
|
||||||
}): GraphEditorSelectionState {
|
}): GraphEditorSelectionState {
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@ import {
|
||||||
export function useGraphEditorController() {
|
export function useGraphEditorController() {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const renderTracks = useEditor((currentEditor) =>
|
const renderTracks = useEditor((currentEditor) =>
|
||||||
currentEditor.timeline.getRenderTracks(),
|
currentEditor.timeline.getPreviewTracks() ??
|
||||||
|
currentEditor.scenes.getActiveScene().tracks,
|
||||||
);
|
);
|
||||||
const { selectedKeyframes } = useKeyframeSelection();
|
const { selectedKeyframes } = useKeyframeSelection();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,6 @@ import {
|
||||||
} from "./track-layout";
|
} from "./track-layout";
|
||||||
import { SELECTED_TRACK_ROW_CLASS } from "./theme";
|
import { SELECTED_TRACK_ROW_CLASS } from "./theme";
|
||||||
import { TIMELINE_HORIZONTAL_WHEEL_STEP_PX } from "./interaction";
|
import { TIMELINE_HORIZONTAL_WHEEL_STEP_PX } from "./interaction";
|
||||||
import { isMainTrack } from "@/lib/timeline/placement";
|
|
||||||
import { TimelineToolbar } from "./timeline-toolbar";
|
import { TimelineToolbar } from "./timeline-toolbar";
|
||||||
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
||||||
import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek";
|
import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek";
|
||||||
|
|
@ -112,7 +111,15 @@ export function Timeline() {
|
||||||
} = useElementSelection();
|
} = useElementSelection();
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const timeline = editor.timeline;
|
const timeline = editor.timeline;
|
||||||
const tracks = useEditor((editor) => editor.timeline.getTracks());
|
const scene = useEditor((currentEditor) => currentEditor.scenes.getActiveSceneOrNull());
|
||||||
|
const tracks = useMemo<TimelineTrack[]>(
|
||||||
|
() =>
|
||||||
|
scene
|
||||||
|
? [...scene.tracks.overlay, scene.tracks.main, ...scene.tracks.audio]
|
||||||
|
: [],
|
||||||
|
[scene],
|
||||||
|
);
|
||||||
|
const mainTrackId = scene?.tracks.main.id ?? null;
|
||||||
const seek = (time: number) => editor.playback.seek({ time });
|
const seek = (time: number) => editor.playback.seek({ time });
|
||||||
|
|
||||||
const timelineRef = useRef<HTMLDivElement>(null);
|
const timelineRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -424,13 +431,13 @@ export function Timeline() {
|
||||||
/>
|
/>
|
||||||
<DragLine
|
<DragLine
|
||||||
dropTarget={dropTarget}
|
dropTarget={dropTarget}
|
||||||
tracks={timeline.getTracks()}
|
tracks={tracks}
|
||||||
isVisible={isDragOver && !dropTarget?.targetElement}
|
isVisible={isDragOver && !dropTarget?.targetElement}
|
||||||
headerHeight={timelineHeaderHeight}
|
headerHeight={timelineHeaderHeight}
|
||||||
/>
|
/>
|
||||||
<DragLine
|
<DragLine
|
||||||
dropTarget={dragDropTarget}
|
dropTarget={dragDropTarget}
|
||||||
tracks={timeline.getTracks()}
|
tracks={tracks}
|
||||||
isVisible={dragState.isDragging}
|
isVisible={dragState.isDragging}
|
||||||
headerHeight={timelineHeaderHeight}
|
headerHeight={timelineHeaderHeight}
|
||||||
/>
|
/>
|
||||||
|
|
@ -508,6 +515,7 @@ export function Timeline() {
|
||||||
{tracks.length > 0 && (
|
{tracks.length > 0 && (
|
||||||
<TimelineTrackRows
|
<TimelineTrackRows
|
||||||
dragElementId={dragState.elementId}
|
dragElementId={dragState.elementId}
|
||||||
|
mainTrackId={mainTrackId}
|
||||||
zoomLevel={zoomLevel}
|
zoomLevel={zoomLevel}
|
||||||
dragState={dragState}
|
dragState={dragState}
|
||||||
tracksScrollRef={tracksScrollRef}
|
tracksScrollRef={tracksScrollRef}
|
||||||
|
|
@ -574,7 +582,14 @@ function TrackLabelsPanel({
|
||||||
hasHorizontalScrollbar: boolean;
|
hasHorizontalScrollbar: boolean;
|
||||||
}) {
|
}) {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const tracks = useEditor((e) => e.timeline.getTracks());
|
const scene = useEditor((e) => e.scenes.getActiveSceneOrNull());
|
||||||
|
const tracks = useMemo<TimelineTrack[]>(
|
||||||
|
() =>
|
||||||
|
scene
|
||||||
|
? [...scene.tracks.overlay, scene.tracks.main, ...scene.tracks.audio]
|
||||||
|
: [],
|
||||||
|
[scene],
|
||||||
|
);
|
||||||
const { selectedElements } = useElementSelection();
|
const { selectedElements } = useElementSelection();
|
||||||
const tracksWithSelection = useMemo(
|
const tracksWithSelection = useMemo(
|
||||||
() => new Set(selectedElements.map((el) => el.trackId)),
|
() => new Set(selectedElements.map((el) => el.trackId)),
|
||||||
|
|
@ -650,6 +665,7 @@ function TrackLabelsPanel({
|
||||||
|
|
||||||
function TimelineTrackRows({
|
function TimelineTrackRows({
|
||||||
dragElementId,
|
dragElementId,
|
||||||
|
mainTrackId,
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
dragState,
|
dragState,
|
||||||
tracksScrollRef,
|
tracksScrollRef,
|
||||||
|
|
@ -665,6 +681,7 @@ function TimelineTrackRows({
|
||||||
dropTarget,
|
dropTarget,
|
||||||
}: {
|
}: {
|
||||||
dragElementId: string | null;
|
dragElementId: string | null;
|
||||||
|
mainTrackId: string | null;
|
||||||
zoomLevel: number;
|
zoomLevel: number;
|
||||||
dragState: ElementDragState;
|
dragState: ElementDragState;
|
||||||
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
|
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
|
@ -684,7 +701,14 @@ function TimelineTrackRows({
|
||||||
dropTarget: DropTarget | null;
|
dropTarget: DropTarget | null;
|
||||||
}) {
|
}) {
|
||||||
const timeline = useEditor((e) => e.timeline);
|
const timeline = useEditor((e) => e.timeline);
|
||||||
const tracks = useEditor((e) => e.timeline.getTracks());
|
const scene = useEditor((e) => e.scenes.getActiveSceneOrNull());
|
||||||
|
const tracks = useMemo<TimelineTrack[]>(
|
||||||
|
() =>
|
||||||
|
scene
|
||||||
|
? [...scene.tracks.overlay, scene.tracks.main, ...scene.tracks.audio]
|
||||||
|
: [],
|
||||||
|
[scene],
|
||||||
|
);
|
||||||
const { selectedElements } = useElementSelection();
|
const { selectedElements } = useElementSelection();
|
||||||
const tracksWithSelection = useMemo(
|
const tracksWithSelection = useMemo(
|
||||||
() => new Set(selectedElements.map((el) => el.trackId)),
|
() => new Set(selectedElements.map((el) => el.trackId)),
|
||||||
|
|
@ -779,7 +803,7 @@ function TimelineTrackRows({
|
||||||
? "Show track"
|
? "Show track"
|
||||||
: "Hide track"}
|
: "Hide track"}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
{!isMainTrack(track) && (
|
{track.id !== mainTrackId && (
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
icon={<HugeiconsIcon icon={Delete02Icon} />}
|
icon={<HugeiconsIcon icon={Delete02Icon} />}
|
||||||
onClick={(event: React.MouseEvent) => {
|
onClick={(event: React.MouseEvent) => {
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import { AudioManager } from "./managers/audio-manager";
|
||||||
import { SelectionManager } from "./managers/selection-manager";
|
import { SelectionManager } from "./managers/selection-manager";
|
||||||
import { registerDefaultEffects } from "@/lib/effects";
|
import { registerDefaultEffects } from "@/lib/effects";
|
||||||
import { registerDefaultMasks } from "@/lib/masks";
|
import { registerDefaultMasks } from "@/lib/masks";
|
||||||
import { isMainTrack } from "@/lib/timeline/placement";
|
|
||||||
|
|
||||||
export class EditorCore {
|
export class EditorCore {
|
||||||
private static instance: EditorCore | null = null;
|
private static instance: EditorCore | null = null;
|
||||||
|
|
@ -39,11 +38,21 @@ export class EditorCore {
|
||||||
this.audio = new AudioManager(this);
|
this.audio = new AudioManager(this);
|
||||||
this.selection = new SelectionManager(this);
|
this.selection = new SelectionManager(this);
|
||||||
this.command.registerReactor(() => {
|
this.command.registerReactor(() => {
|
||||||
const tracks = this.timeline.getTracks();
|
const activeScene = this.scenes.getActiveSceneOrNull();
|
||||||
const prunedTracks = tracks.filter(
|
if (!activeScene) {
|
||||||
(track) => track.elements.length > 0 || isMainTrack(track),
|
return;
|
||||||
);
|
}
|
||||||
if (prunedTracks.length !== tracks.length) {
|
|
||||||
|
const tracks = activeScene.tracks;
|
||||||
|
const prunedTracks = {
|
||||||
|
...tracks,
|
||||||
|
overlay: tracks.overlay.filter((track) => track.elements.length > 0),
|
||||||
|
audio: tracks.audio.filter((track) => track.elements.length > 0),
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
prunedTracks.overlay.length !== tracks.overlay.length ||
|
||||||
|
prunedTracks.audio.length !== tracks.audio.length
|
||||||
|
) {
|
||||||
this.timeline.updateTracks(prunedTracks);
|
this.timeline.updateTracks(prunedTracks);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -163,7 +163,7 @@ export class AudioManager {
|
||||||
this.playbackSessionId++;
|
this.playbackSessionId++;
|
||||||
this.playbackLatencyCompensationSeconds = 0;
|
this.playbackLatencyCompensationSeconds = 0;
|
||||||
|
|
||||||
const tracks = this.editor.timeline.getTracks();
|
const tracks = this.editor.scenes.getActiveScene().tracks;
|
||||||
const mediaAssets = this.editor.media.getAssets();
|
const mediaAssets = this.editor.media.getAssets();
|
||||||
const duration = this.editor.timeline.getTotalDuration();
|
const duration = this.editor.timeline.getTotalDuration();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { EditorCore } from "@/core";
|
import type { EditorCore } from "@/core";
|
||||||
import type { Command, CommandResult } from "@/lib/commands";
|
import type { Command, CommandResult } from "@/lib/commands";
|
||||||
import { applyRippleAdjustments, computeRippleAdjustments } from "@/lib/ripple";
|
import { applyRippleAdjustments, computeRippleAdjustments } from "@/lib/ripple";
|
||||||
import type { TimelineTrack, ElementRef } from "@/lib/timeline/types";
|
import type { ElementRef, SceneTracks } from "@/lib/timeline/types";
|
||||||
|
|
||||||
interface CommandHistoryEntry {
|
interface CommandHistoryEntry {
|
||||||
command: Command;
|
command: Command;
|
||||||
|
|
@ -18,7 +18,9 @@ export class CommandManager {
|
||||||
constructor(private editor: EditorCore) {}
|
constructor(private editor: EditorCore) {}
|
||||||
|
|
||||||
execute({ command }: { command: Command }): Command {
|
execute({ command }: { command: Command }): Command {
|
||||||
const beforeTracks = this.isRippleEnabled ? this.editor.timeline.getTracks() : null;
|
const beforeTracks = this.isRippleEnabled
|
||||||
|
? this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null
|
||||||
|
: null;
|
||||||
const previousSelection = this.getSelectionSnapshot();
|
const previousSelection = this.getSelectionSnapshot();
|
||||||
const result = command.execute();
|
const result = command.execute();
|
||||||
this.applyRippleIfEnabled({ beforeTracks });
|
this.applyRippleIfEnabled({ beforeTracks });
|
||||||
|
|
@ -71,7 +73,9 @@ export class CommandManager {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const beforeTracks = this.isRippleEnabled ? this.editor.timeline.getTracks() : null;
|
const beforeTracks = this.isRippleEnabled
|
||||||
|
? this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null
|
||||||
|
: null;
|
||||||
const previousSelection = this.getSelectionSnapshot();
|
const previousSelection = this.getSelectionSnapshot();
|
||||||
const result = entry.command.redo();
|
const result = entry.command.redo();
|
||||||
this.applyRippleIfEnabled({ beforeTracks });
|
this.applyRippleIfEnabled({ beforeTracks });
|
||||||
|
|
@ -123,13 +127,16 @@ export class CommandManager {
|
||||||
private applyRippleIfEnabled({
|
private applyRippleIfEnabled({
|
||||||
beforeTracks,
|
beforeTracks,
|
||||||
}: {
|
}: {
|
||||||
beforeTracks: TimelineTrack[] | null;
|
beforeTracks: SceneTracks | null;
|
||||||
}): void {
|
}): void {
|
||||||
if (!this.isRippleEnabled || !beforeTracks) {
|
if (!this.isRippleEnabled || !beforeTracks) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const afterTracks = this.editor.timeline.getTracks();
|
const afterTracks = this.editor.scenes.getActiveSceneOrNull()?.tracks;
|
||||||
|
if (!afterTracks) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const adjustments = computeRippleAdjustments({
|
const adjustments = computeRippleAdjustments({
|
||||||
beforeTracks,
|
beforeTracks,
|
||||||
afterTracks,
|
afterTracks,
|
||||||
|
|
|
||||||
|
|
@ -156,9 +156,14 @@ export class ProjectManager {
|
||||||
|
|
||||||
await this.editor.media.loadProjectMedia({ projectId: id });
|
await this.editor.media.loadProjectMedia({ projectId: id });
|
||||||
|
|
||||||
const allTracks = (project.scenes ?? []).flatMap((scene) => scene.tracks);
|
|
||||||
await loadFonts({
|
await loadFonts({
|
||||||
families: getElementFontFamilies({ tracks: allTracks }),
|
families: [
|
||||||
|
...new Set(
|
||||||
|
(project.scenes ?? []).flatMap((scene) =>
|
||||||
|
getElementFontFamilies({ tracks: scene.tracks }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!project.metadata.thumbnail) {
|
if (!project.metadata.thumbnail) {
|
||||||
|
|
@ -643,7 +648,7 @@ export class ProjectManager {
|
||||||
private async updateThumbnailFromTimeline(): Promise<boolean> {
|
private async updateThumbnailFromTimeline(): Promise<boolean> {
|
||||||
if (!this.active) return false;
|
if (!this.active) return false;
|
||||||
|
|
||||||
const tracks = this.editor.timeline.getTracks();
|
const tracks = this.editor.scenes.getActiveScene().tracks;
|
||||||
const mediaAssets = this.editor.media.getAssets();
|
const mediaAssets = this.editor.media.getAssets();
|
||||||
const duration = this.editor.timeline.getTotalDuration();
|
const duration = this.editor.timeline.getTotalDuration();
|
||||||
const { canvasSize, background } = this.active.settings;
|
const { canvasSize, background } = this.active.settings;
|
||||||
|
|
|
||||||
|
|
@ -139,7 +139,7 @@ export class RendererManager {
|
||||||
const { format, quality, fps, includeAudio } = options;
|
const { format, quality, fps, includeAudio } = options;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tracks = this.editor.timeline.getTracks();
|
const tracks = this.editor.scenes.getActiveScene().tracks;
|
||||||
const mediaAssets = this.editor.media.getAssets();
|
const mediaAssets = this.editor.media.getAssets();
|
||||||
const activeProject = this.editor.project.getActive();
|
const activeProject = this.editor.project.getActive();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { EditorCore } from "@/core";
|
import type { EditorCore } from "@/core";
|
||||||
import type { TimelineTrack, TScene } from "@/lib/timeline";
|
import type { SceneTracks, TScene } from "@/lib/timeline";
|
||||||
import { storageService } from "@/services/storage/service";
|
import { storageService } from "@/services/storage/service";
|
||||||
import {
|
import {
|
||||||
getMainScene,
|
getMainScene,
|
||||||
|
|
@ -12,7 +12,6 @@ import {
|
||||||
getFrameTime,
|
getFrameTime,
|
||||||
isBookmarkAtTime,
|
isBookmarkAtTime,
|
||||||
} from "@/lib/timeline/bookmarks";
|
} from "@/lib/timeline/bookmarks";
|
||||||
import { ensureMainTrack } from "@/lib/timeline/placement";
|
|
||||||
import {
|
import {
|
||||||
CreateSceneCommand,
|
CreateSceneCommand,
|
||||||
DeleteSceneCommand,
|
DeleteSceneCommand,
|
||||||
|
|
@ -174,10 +173,7 @@ export class ScenesManager {
|
||||||
try {
|
try {
|
||||||
const result = await storageService.loadProject({ id: projectId });
|
const result = await storageService.loadProject({ id: projectId });
|
||||||
if (result?.project.scenes) {
|
if (result?.project.scenes) {
|
||||||
const { scenes: ensuredScenes, hasAddedMainTrack } =
|
const ensuredScenes = result.project.scenes ?? [];
|
||||||
this.ensureScenesHaveMainTrack({
|
|
||||||
scenes: result.project.scenes ?? [],
|
|
||||||
});
|
|
||||||
const currentScene = findCurrentScene({
|
const currentScene = findCurrentScene({
|
||||||
scenes: ensuredScenes,
|
scenes: ensuredScenes,
|
||||||
currentSceneId: result.project.currentSceneId,
|
currentSceneId: result.project.currentSceneId,
|
||||||
|
|
@ -186,22 +182,6 @@ export class ScenesManager {
|
||||||
this.list = ensuredScenes;
|
this.list = ensuredScenes;
|
||||||
this.active = currentScene;
|
this.active = currentScene;
|
||||||
this.notify();
|
this.notify();
|
||||||
|
|
||||||
if (hasAddedMainTrack) {
|
|
||||||
const activeProject = this.editor.project.getActive();
|
|
||||||
if (activeProject) {
|
|
||||||
const updatedProject = {
|
|
||||||
...activeProject,
|
|
||||||
scenes: ensuredScenes,
|
|
||||||
metadata: {
|
|
||||||
...activeProject.metadata,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
this.editor.project.setActiveProject({ project: updatedProject });
|
|
||||||
this.editor.save.markDirty({ force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load project scenes:", error);
|
console.error("Failed to load project scenes:", error);
|
||||||
|
|
@ -219,26 +199,24 @@ export class ScenesManager {
|
||||||
currentSceneId?: string;
|
currentSceneId?: string;
|
||||||
}): void {
|
}): void {
|
||||||
const ensuredScenes = ensureMainScene({ scenes });
|
const ensuredScenes = ensureMainScene({ scenes });
|
||||||
const { scenes: scenesWithMainTracks, hasAddedMainTrack } =
|
|
||||||
this.ensureScenesHaveMainTrack({ scenes: ensuredScenes });
|
|
||||||
const currentScene = currentSceneId
|
const currentScene = currentSceneId
|
||||||
? scenesWithMainTracks.find((s) => s.id === currentSceneId)
|
? ensuredScenes.find((s) => s.id === currentSceneId)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const fallbackScene = getMainScene({ scenes: scenesWithMainTracks });
|
const fallbackScene = getMainScene({ scenes: ensuredScenes });
|
||||||
|
|
||||||
this.list = scenesWithMainTracks;
|
this.list = ensuredScenes;
|
||||||
this.active = currentScene || fallbackScene;
|
this.active = currentScene || fallbackScene;
|
||||||
this.notify();
|
this.notify();
|
||||||
|
|
||||||
const hasAddedMainScene = ensuredScenes.length > scenes.length;
|
const hasAddedMainScene = ensuredScenes.length > scenes.length;
|
||||||
if (hasAddedMainScene || hasAddedMainTrack) {
|
if (hasAddedMainScene) {
|
||||||
const activeProject = this.editor.project.getActive();
|
const activeProject = this.editor.project.getActive();
|
||||||
|
|
||||||
if (activeProject) {
|
if (activeProject) {
|
||||||
const updatedProject = {
|
const updatedProject = {
|
||||||
...activeProject,
|
...activeProject,
|
||||||
scenes: scenesWithMainTracks,
|
scenes: ensuredScenes,
|
||||||
metadata: {
|
metadata: {
|
||||||
...activeProject.metadata,
|
...activeProject.metadata,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
|
|
@ -264,6 +242,10 @@ export class ScenesManager {
|
||||||
return this.active;
|
return this.active;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getActiveSceneOrNull(): TScene | null {
|
||||||
|
return this.active;
|
||||||
|
}
|
||||||
|
|
||||||
getScenes(): TScene[] {
|
getScenes(): TScene[] {
|
||||||
return this.list;
|
return this.list;
|
||||||
}
|
}
|
||||||
|
|
@ -307,7 +289,7 @@ export class ScenesManager {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSceneTracks({ tracks }: { tracks: TimelineTrack[] }): void {
|
updateSceneTracks({ tracks }: { tracks: SceneTracks }): void {
|
||||||
if (!this.active) return;
|
if (!this.active) return;
|
||||||
|
|
||||||
const updatedScene: TScene = {
|
const updatedScene: TScene = {
|
||||||
|
|
@ -335,29 +317,4 @@ export class ScenesManager {
|
||||||
this.editor.project.setActiveProject({ project: updatedProject });
|
this.editor.project.setActiveProject({ project: updatedProject });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private ensureScenesHaveMainTrack({ scenes }: { scenes: TScene[] }): {
|
|
||||||
scenes: TScene[];
|
|
||||||
hasAddedMainTrack: boolean;
|
|
||||||
} {
|
|
||||||
let hasAddedMainTrack = false;
|
|
||||||
const ensuredScenes: TScene[] = [];
|
|
||||||
|
|
||||||
for (const scene of scenes) {
|
|
||||||
const existingTracks = scene.tracks ?? [];
|
|
||||||
const updatedTracks = ensureMainTrack({ tracks: existingTracks });
|
|
||||||
if (updatedTracks !== existingTracks) {
|
|
||||||
hasAddedMainTrack = true;
|
|
||||||
ensuredScenes.push({
|
|
||||||
...scene,
|
|
||||||
tracks: updatedTracks,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
ensuredScenes.push(scene);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { scenes: ensuredScenes, hasAddedMainTrack };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { EditorCore } from "@/core";
|
import type { EditorCore } from "@/core";
|
||||||
import type { ParamValues } from "@/lib/params";
|
import type { ParamValues } from "@/lib/params";
|
||||||
import type {
|
import type {
|
||||||
|
SceneTracks,
|
||||||
TrackType,
|
TrackType,
|
||||||
TimelineTrack,
|
TimelineTrack,
|
||||||
TimelineElement,
|
TimelineElement,
|
||||||
|
|
@ -8,6 +9,10 @@ import type {
|
||||||
RetimeConfig,
|
RetimeConfig,
|
||||||
} from "@/lib/timeline";
|
} from "@/lib/timeline";
|
||||||
import { calculateTotalDuration } from "@/lib/timeline";
|
import { calculateTotalDuration } from "@/lib/timeline";
|
||||||
|
import {
|
||||||
|
findTrackInSceneTracks,
|
||||||
|
updateElementInSceneTracks,
|
||||||
|
} from "@/lib/timeline/track-element-update";
|
||||||
import {
|
import {
|
||||||
canElementBeHidden,
|
canElementBeHidden,
|
||||||
canElementHaveAudio,
|
canElementHaveAudio,
|
||||||
|
|
@ -53,7 +58,7 @@ import type { InsertElementParams } from "@/lib/commands/timeline/element/insert
|
||||||
export class TimelineManager {
|
export class TimelineManager {
|
||||||
private listeners = new Set<() => void>();
|
private listeners = new Set<() => void>();
|
||||||
private previewOverlay = new Map<string, Partial<TimelineElement>>();
|
private previewOverlay = new Map<string, Partial<TimelineElement>>();
|
||||||
private previewTracks: TimelineTrack[] | null = null;
|
private previewTracks: SceneTracks | null = null;
|
||||||
|
|
||||||
constructor(private editor: EditorCore) {}
|
constructor(private editor: EditorCore) {}
|
||||||
|
|
||||||
|
|
@ -193,7 +198,12 @@ export class TimelineManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
getTotalDuration(): number {
|
getTotalDuration(): number {
|
||||||
return calculateTotalDuration({ tracks: this.getTracks() });
|
const activeScene = this.editor.scenes.getActiveSceneOrNull();
|
||||||
|
if (!activeScene) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return calculateTotalDuration({ tracks: activeScene.tracks });
|
||||||
}
|
}
|
||||||
|
|
||||||
getLastFrameTime(): number {
|
getLastFrameTime(): number {
|
||||||
|
|
@ -204,7 +214,12 @@ export class TimelineManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
getTrackById({ trackId }: { trackId: string }): TimelineTrack | null {
|
getTrackById({ trackId }: { trackId: string }): TimelineTrack | null {
|
||||||
return this.getTracks().find((track) => track.id === trackId) ?? null;
|
const activeScene = this.editor.scenes.getActiveSceneOrNull();
|
||||||
|
if (!activeScene) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return findTrackInSceneTracks({ tracks: activeScene.tracks, trackId });
|
||||||
}
|
}
|
||||||
|
|
||||||
getElementsWithTracks({
|
getElementsWithTracks({
|
||||||
|
|
@ -629,14 +644,20 @@ export class TimelineManager {
|
||||||
} as Partial<TimelineElement>;
|
} as Partial<TimelineElement>;
|
||||||
this.previewOverlay.set(elementId, mergedOverlay);
|
this.previewOverlay.set(elementId, mergedOverlay);
|
||||||
}
|
}
|
||||||
const committedTracks = this.editor.scenes.getActiveScene()?.tracks ?? [];
|
const committedTracks = this.editor.scenes.getActiveSceneOrNull()?.tracks;
|
||||||
|
if (!committedTracks) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.previewTracks = this.applyPreviewOverlay(committedTracks);
|
this.previewTracks = this.applyPreviewOverlay(committedTracks);
|
||||||
this.notify();
|
this.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
commitPreview(): void {
|
commitPreview(): void {
|
||||||
if (this.previewOverlay.size === 0) return;
|
if (this.previewOverlay.size === 0) return;
|
||||||
const committedTracks = this.editor.scenes.getActiveScene()?.tracks ?? [];
|
const committedTracks = this.editor.scenes.getActiveSceneOrNull()?.tracks;
|
||||||
|
if (!committedTracks) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const afterTracks =
|
const afterTracks =
|
||||||
this.previewTracks ?? this.applyPreviewOverlay(committedTracks);
|
this.previewTracks ?? this.applyPreviewOverlay(committedTracks);
|
||||||
const command = new TracksSnapshotCommand(committedTracks, afterTracks);
|
const command = new TracksSnapshotCommand(committedTracks, afterTracks);
|
||||||
|
|
@ -653,19 +674,32 @@ export class TimelineManager {
|
||||||
this.notify();
|
this.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyPreviewOverlay(tracks: TimelineTrack[]): TimelineTrack[] {
|
private applyPreviewOverlay(tracks: SceneTracks): SceneTracks {
|
||||||
if (this.previewOverlay.size === 0) return tracks;
|
if (this.previewOverlay.size === 0) return tracks;
|
||||||
return tracks.map((track) => {
|
|
||||||
const hasOverlay = track.elements.some((el) =>
|
const applyTrackOverlay = <TTrack extends TimelineTrack>(track: TTrack): TTrack => {
|
||||||
this.previewOverlay.has(el.id),
|
const hasOverlay = track.elements.some((element) =>
|
||||||
|
this.previewOverlay.has(element.id),
|
||||||
);
|
);
|
||||||
if (!hasOverlay) return track;
|
if (!hasOverlay) {
|
||||||
const newElements = track.elements.map((el) => {
|
return track;
|
||||||
const overlay = this.previewOverlay.get(el.id);
|
}
|
||||||
return overlay ? ({ ...el, ...overlay } as TimelineElement) : el;
|
|
||||||
|
const nextElements = track.elements.map((element) => {
|
||||||
|
const overlay = this.previewOverlay.get(element.id);
|
||||||
|
return overlay
|
||||||
|
? ({ ...element, ...overlay } as TimelineElement)
|
||||||
|
: element;
|
||||||
});
|
});
|
||||||
return { ...track, elements: newElements } as TimelineTrack;
|
|
||||||
});
|
return { ...track, elements: nextElements } as TTrack;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
overlay: tracks.overlay.map((track) => applyTrackOverlay(track)),
|
||||||
|
main: applyTrackOverlay(tracks.main),
|
||||||
|
audio: tracks.audio.map((track) => applyTrackOverlay(track)),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
duplicateElements({
|
duplicateElements({
|
||||||
|
|
@ -734,13 +768,8 @@ export class TimelineManager {
|
||||||
this.updateElements({ updates: nextUpdates });
|
this.updateElements({ updates: nextUpdates });
|
||||||
}
|
}
|
||||||
|
|
||||||
getTracks(): TimelineTrack[] {
|
getPreviewTracks(): SceneTracks | null {
|
||||||
return this.editor.scenes.getActiveScene()?.tracks ?? [];
|
return this.previewTracks ?? this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null;
|
||||||
}
|
|
||||||
|
|
||||||
getRenderTracks(): TimelineTrack[] {
|
|
||||||
if (this.previewTracks !== null) return this.previewTracks;
|
|
||||||
return this.getTracks();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
subscribe(listener: () => void): () => void {
|
subscribe(listener: () => void): () => void {
|
||||||
|
|
@ -771,14 +800,35 @@ export class TimelineManager {
|
||||||
}: {
|
}: {
|
||||||
elementId: string;
|
elementId: string;
|
||||||
}): string | null {
|
}): string | null {
|
||||||
return (
|
const activeScene = this.editor.scenes.getActiveSceneOrNull();
|
||||||
this.getTracks().find((track) =>
|
if (!activeScene) {
|
||||||
track.elements.some((element) => element.id === elementId),
|
return null;
|
||||||
)?.id ?? null
|
}
|
||||||
);
|
|
||||||
|
if (
|
||||||
|
activeScene.tracks.main.elements.some(
|
||||||
|
(element) => element.id === elementId,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return activeScene.tracks.main.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const track of activeScene.tracks.overlay) {
|
||||||
|
if (track.elements.some((element) => element.id === elementId)) {
|
||||||
|
return track.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const track of activeScene.tracks.audio) {
|
||||||
|
if (track.elements.some((element) => element.id === elementId)) {
|
||||||
|
return track.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateTracks(newTracks: TimelineTrack[]): void {
|
updateTracks(newTracks: SceneTracks): void {
|
||||||
this.previewOverlay.clear();
|
this.previewOverlay.clear();
|
||||||
this.previewTracks = null;
|
this.previewTracks = null;
|
||||||
this.editor.scenes.updateSceneTracks({ tracks: newTracks });
|
this.editor.scenes.updateSceneTracks({ tracks: newTracks });
|
||||||
|
|
|
||||||
|
|
@ -179,11 +179,12 @@ export function useEditorActions() {
|
||||||
"split",
|
"split",
|
||||||
() => {
|
() => {
|
||||||
const currentTime = editor.playback.getCurrentTime();
|
const currentTime = editor.playback.getCurrentTime();
|
||||||
|
const tracks = editor.scenes.getActiveScene().tracks;
|
||||||
const elementsToSplit =
|
const elementsToSplit =
|
||||||
selectedElements.length > 0
|
selectedElements.length > 0
|
||||||
? selectedElements
|
? selectedElements
|
||||||
: getElementsAtTime({
|
: getElementsAtTime({
|
||||||
tracks: editor.timeline.getTracks(),
|
tracks,
|
||||||
time: currentTime,
|
time: currentTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -201,11 +202,12 @@ export function useEditorActions() {
|
||||||
"split-left",
|
"split-left",
|
||||||
() => {
|
() => {
|
||||||
const currentTime = editor.playback.getCurrentTime();
|
const currentTime = editor.playback.getCurrentTime();
|
||||||
|
const tracks = editor.scenes.getActiveScene().tracks;
|
||||||
const elementsToSplit =
|
const elementsToSplit =
|
||||||
selectedElements.length > 0
|
selectedElements.length > 0
|
||||||
? selectedElements
|
? selectedElements
|
||||||
: getElementsAtTime({
|
: getElementsAtTime({
|
||||||
tracks: editor.timeline.getTracks(),
|
tracks,
|
||||||
time: currentTime,
|
time: currentTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -233,11 +235,12 @@ export function useEditorActions() {
|
||||||
"split-right",
|
"split-right",
|
||||||
() => {
|
() => {
|
||||||
const currentTime = editor.playback.getCurrentTime();
|
const currentTime = editor.playback.getCurrentTime();
|
||||||
|
const tracks = editor.scenes.getActiveScene().tracks;
|
||||||
const elementsToSplit =
|
const elementsToSplit =
|
||||||
selectedElements.length > 0
|
selectedElements.length > 0
|
||||||
? selectedElements
|
? selectedElements
|
||||||
: getElementsAtTime({
|
: getElementsAtTime({
|
||||||
tracks: editor.timeline.getTracks(),
|
tracks,
|
||||||
time: currentTime,
|
time: currentTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -310,7 +313,12 @@ export function useEditorActions() {
|
||||||
useActionHandler(
|
useActionHandler(
|
||||||
"select-all",
|
"select-all",
|
||||||
() => {
|
() => {
|
||||||
const allElements = editor.timeline.getTracks().flatMap((track) =>
|
const scene = editor.scenes.getActiveScene();
|
||||||
|
const allElements = [
|
||||||
|
...scene.tracks.overlay,
|
||||||
|
scene.tracks.main,
|
||||||
|
...scene.tracks.audio,
|
||||||
|
].flatMap((track) =>
|
||||||
track.elements.map((element) => ({
|
track.elements.map((element) => ({
|
||||||
trackId: track.id,
|
trackId: track.id,
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import { registerCanceller } from "@/lib/cancel-interaction";
|
||||||
import type {
|
import type {
|
||||||
DropTarget,
|
DropTarget,
|
||||||
ElementDragState,
|
ElementDragState,
|
||||||
|
SceneTracks,
|
||||||
TimelineElement,
|
TimelineElement,
|
||||||
TimelineTrack,
|
TimelineTrack,
|
||||||
} from "@/lib/timeline";
|
} from "@/lib/timeline";
|
||||||
|
|
@ -101,7 +102,7 @@ function getDragDropTarget({
|
||||||
clientY: number;
|
clientY: number;
|
||||||
elementId: string;
|
elementId: string;
|
||||||
trackId: string;
|
trackId: string;
|
||||||
tracks: TimelineTrack[];
|
tracks: SceneTracks;
|
||||||
tracksContainerRef: RefObject<HTMLDivElement | null>;
|
tracksContainerRef: RefObject<HTMLDivElement | null>;
|
||||||
tracksScrollRef: RefObject<HTMLDivElement | null>;
|
tracksScrollRef: RefObject<HTMLDivElement | null>;
|
||||||
headerRef?: RefObject<HTMLElement | null>;
|
headerRef?: RefObject<HTMLElement | null>;
|
||||||
|
|
@ -113,7 +114,11 @@ function getDragDropTarget({
|
||||||
const scrollContainer = tracksScrollRef.current;
|
const scrollContainer = tracksScrollRef.current;
|
||||||
if (!containerRect || !scrollContainer) return null;
|
if (!containerRect || !scrollContainer) return null;
|
||||||
|
|
||||||
const sourceTrack = tracks.find(({ id }) => id === trackId);
|
const sourceTrack = [
|
||||||
|
...tracks.overlay,
|
||||||
|
tracks.main,
|
||||||
|
...tracks.audio,
|
||||||
|
].find(({ id }) => id === trackId);
|
||||||
const movingElement = sourceTrack?.elements.find(
|
const movingElement = sourceTrack?.elements.find(
|
||||||
({ id }) => id === elementId,
|
({ id }) => id === elementId,
|
||||||
);
|
);
|
||||||
|
|
@ -163,7 +168,12 @@ export function useElementInteraction({
|
||||||
}: UseElementInteractionProps) {
|
}: UseElementInteractionProps) {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const isShiftHeldRef = useShiftKey();
|
const isShiftHeldRef = useShiftKey();
|
||||||
const tracks = editor.timeline.getTracks();
|
const sceneTracks = editor.scenes.getActiveScene().tracks;
|
||||||
|
const tracks = [
|
||||||
|
...sceneTracks.overlay,
|
||||||
|
sceneTracks.main,
|
||||||
|
...sceneTracks.audio,
|
||||||
|
];
|
||||||
const {
|
const {
|
||||||
isElementSelected,
|
isElementSelected,
|
||||||
selectElement,
|
selectElement,
|
||||||
|
|
@ -242,7 +252,7 @@ export function useElementInteraction({
|
||||||
const startSnap = snapElementEdge({
|
const startSnap = snapElementEdge({
|
||||||
targetTime: frameSnappedTime,
|
targetTime: frameSnappedTime,
|
||||||
elementDuration,
|
elementDuration,
|
||||||
tracks,
|
tracks: sceneTracks,
|
||||||
playheadTime,
|
playheadTime,
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
excludeElementId: movingElement.id,
|
excludeElementId: movingElement.id,
|
||||||
|
|
@ -252,7 +262,7 @@ export function useElementInteraction({
|
||||||
const endSnap = snapElementEdge({
|
const endSnap = snapElementEdge({
|
||||||
targetTime: frameSnappedTime,
|
targetTime: frameSnappedTime,
|
||||||
elementDuration,
|
elementDuration,
|
||||||
tracks,
|
tracks: sceneTracks,
|
||||||
playheadTime,
|
playheadTime,
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
excludeElementId: movingElement.id,
|
excludeElementId: movingElement.id,
|
||||||
|
|
@ -376,7 +386,7 @@ export function useElementInteraction({
|
||||||
clientY,
|
clientY,
|
||||||
elementId: dragState.elementId,
|
elementId: dragState.elementId,
|
||||||
trackId: dragState.trackId,
|
trackId: dragState.trackId,
|
||||||
tracks,
|
tracks: sceneTracks,
|
||||||
tracksContainerRef,
|
tracksContainerRef,
|
||||||
tracksScrollRef,
|
tracksScrollRef,
|
||||||
headerRef,
|
headerRef,
|
||||||
|
|
@ -436,7 +446,7 @@ export function useElementInteraction({
|
||||||
clientY,
|
clientY,
|
||||||
elementId: dragState.elementId,
|
elementId: dragState.elementId,
|
||||||
trackId: dragState.trackId,
|
trackId: dragState.trackId,
|
||||||
tracks,
|
tracks: sceneTracks,
|
||||||
tracksContainerRef,
|
tracksContainerRef,
|
||||||
tracksScrollRef,
|
tracksScrollRef,
|
||||||
headerRef,
|
headerRef,
|
||||||
|
|
|
||||||
|
|
@ -196,7 +196,7 @@ export function useTimelineElementResize({
|
||||||
const minDuration = Math.round(TICKS_PER_SECOND * projectFps.denominator / projectFps.numerator);
|
const minDuration = Math.round(TICKS_PER_SECOND * projectFps.denominator / projectFps.numerator);
|
||||||
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
|
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
|
||||||
if (shouldSnap) {
|
if (shouldSnap) {
|
||||||
const tracks = editor.timeline.getTracks();
|
const tracks = editor.scenes.getActiveScene().tracks;
|
||||||
const playheadTime = editor.playback.getCurrentTime();
|
const playheadTime = editor.playback.getCurrentTime();
|
||||||
const snapPoints = findSnapPoints({
|
const snapPoints = findSnapPoints({
|
||||||
tracks,
|
tracks,
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ export function useBookmarkDrag({
|
||||||
}: UseBookmarkDragProps) {
|
}: UseBookmarkDragProps) {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const isShiftHeldRef = useShiftKey();
|
const isShiftHeldRef = useShiftKey();
|
||||||
const tracks = editor.timeline.getTracks();
|
const tracks = editor.scenes.getActiveScene().tracks;
|
||||||
const activeScene = editor.scenes.getActiveScene();
|
const activeScene = editor.scenes.getActiveScene();
|
||||||
const bookmarks = activeScene?.bookmarks ?? [];
|
const bookmarks = activeScene?.bookmarks ?? [];
|
||||||
const playheadTime = editor.playback.getCurrentTime();
|
const playheadTime = editor.playback.getCurrentTime();
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ import { AddTrackCommand, InsertElementCommand } from "@/lib/commands/timeline";
|
||||||
import { BatchCommand } from "@/lib/commands";
|
import { BatchCommand } from "@/lib/commands";
|
||||||
import { computeDropTarget } from "@/components/editor/panels/timeline/drop-target";
|
import { computeDropTarget } from "@/components/editor/panels/timeline/drop-target";
|
||||||
import { getDragData, hasDragData } from "@/lib/drag-data";
|
import { getDragData, hasDragData } from "@/lib/drag-data";
|
||||||
import { isMainTrack } from "@/lib/timeline/placement";
|
|
||||||
import type { TrackType, DropTarget, ElementType } from "@/lib/timeline";
|
import type { TrackType, DropTarget, ElementType } from "@/lib/timeline";
|
||||||
import type {
|
import type {
|
||||||
MediaDragData,
|
MediaDragData,
|
||||||
|
|
@ -149,13 +148,13 @@ export function useTimelineDragDrop({
|
||||||
? (dragData as MediaDragData).targetElementTypes
|
? (dragData as MediaDragData).targetElementTypes
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const tracks = editor.timeline.getTracks();
|
const sceneTracks = editor.scenes.getActiveScene().tracks;
|
||||||
const currentTime = editor.playback.getCurrentTime();
|
const currentTime = editor.playback.getCurrentTime();
|
||||||
const target = computeDropTarget({
|
const target = computeDropTarget({
|
||||||
elementType,
|
elementType,
|
||||||
mouseX,
|
mouseX,
|
||||||
mouseY,
|
mouseY,
|
||||||
tracks,
|
tracks: sceneTracks,
|
||||||
playheadTime: currentTime,
|
playheadTime: currentTime,
|
||||||
isExternalDrop: isExternal,
|
isExternalDrop: isExternal,
|
||||||
elementDuration: duration,
|
elementDuration: duration,
|
||||||
|
|
@ -230,7 +229,11 @@ export function useTimelineDragDrop({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tracks = editor.timeline.getTracks();
|
const tracks = [
|
||||||
|
...editor.scenes.getActiveScene().tracks.overlay,
|
||||||
|
editor.scenes.getActiveScene().tracks.main,
|
||||||
|
...editor.scenes.getActiveScene().tracks.audio,
|
||||||
|
];
|
||||||
const track = tracks[target.trackIndex];
|
const track = tracks[target.trackIndex];
|
||||||
if (!track) return;
|
if (!track) return;
|
||||||
editor.timeline.insertElement({
|
editor.timeline.insertElement({
|
||||||
|
|
@ -267,7 +270,11 @@ export function useTimelineDragDrop({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tracks = editor.timeline.getTracks();
|
const tracks = [
|
||||||
|
...editor.scenes.getActiveScene().tracks.overlay,
|
||||||
|
editor.scenes.getActiveScene().tracks.main,
|
||||||
|
...editor.scenes.getActiveScene().tracks.audio,
|
||||||
|
];
|
||||||
const track = tracks[target.trackIndex];
|
const track = tracks[target.trackIndex];
|
||||||
if (!track) return;
|
if (!track) return;
|
||||||
editor.timeline.insertElement({
|
editor.timeline.insertElement({
|
||||||
|
|
@ -305,7 +312,11 @@ export function useTimelineDragDrop({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tracks = editor.timeline.getTracks();
|
const tracks = [
|
||||||
|
...editor.scenes.getActiveScene().tracks.overlay,
|
||||||
|
editor.scenes.getActiveScene().tracks.main,
|
||||||
|
...editor.scenes.getActiveScene().tracks.audio,
|
||||||
|
];
|
||||||
const track = tracks[target.trackIndex];
|
const track = tracks[target.trackIndex];
|
||||||
if (!track) return;
|
if (!track) return;
|
||||||
editor.timeline.insertElement({
|
editor.timeline.insertElement({
|
||||||
|
|
@ -352,7 +363,11 @@ export function useTimelineDragDrop({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tracks = editor.timeline.getTracks();
|
const tracks = [
|
||||||
|
...editor.scenes.getActiveScene().tracks.overlay,
|
||||||
|
editor.scenes.getActiveScene().tracks.main,
|
||||||
|
...editor.scenes.getActiveScene().tracks.audio,
|
||||||
|
];
|
||||||
const track = tracks[target.trackIndex];
|
const track = tracks[target.trackIndex];
|
||||||
if (!track) return;
|
if (!track) return;
|
||||||
editor.timeline.insertElement({
|
editor.timeline.insertElement({
|
||||||
|
|
@ -380,7 +395,11 @@ export function useTimelineDragDrop({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tracks = editor.timeline.getTracks();
|
const tracks = [
|
||||||
|
...editor.scenes.getActiveScene().tracks.overlay,
|
||||||
|
editor.scenes.getActiveScene().tracks.main,
|
||||||
|
...editor.scenes.getActiveScene().tracks.audio,
|
||||||
|
];
|
||||||
const effectTrack = tracks.find((t) => t.type === "effect");
|
const effectTrack = tracks.find((t) => t.type === "effect");
|
||||||
let trackId: string;
|
let trackId: string;
|
||||||
|
|
||||||
|
|
@ -447,16 +466,14 @@ export function useTimelineDragDrop({
|
||||||
const duration =
|
const duration =
|
||||||
createdAsset.duration ??
|
createdAsset.duration ??
|
||||||
DEFAULT_NEW_ELEMENT_DURATION;
|
DEFAULT_NEW_ELEMENT_DURATION;
|
||||||
const currentTracks = editor.timeline.getTracks();
|
const sceneTracks = editor.scenes.getActiveScene().tracks;
|
||||||
const currentTime = editor.playback.getCurrentTime();
|
const currentTime = editor.playback.getCurrentTime();
|
||||||
const onlyTrack = currentTracks[0];
|
|
||||||
const reuseMainTrackId =
|
const reuseMainTrackId =
|
||||||
createdAsset.type !== "audio" &&
|
createdAsset.type !== "audio" &&
|
||||||
currentTracks.length === 1 &&
|
sceneTracks.overlay.length === 0 &&
|
||||||
!!onlyTrack &&
|
sceneTracks.audio.length === 0 &&
|
||||||
isMainTrack(onlyTrack) &&
|
sceneTracks.main.elements.length === 0
|
||||||
onlyTrack.elements.length === 0
|
? sceneTracks.main.id
|
||||||
? onlyTrack.id
|
|
||||||
: null;
|
: null;
|
||||||
const dropTarget = reuseMainTrackId
|
const dropTarget = reuseMainTrackId
|
||||||
? null
|
? null
|
||||||
|
|
@ -464,7 +481,7 @@ export function useTimelineDragDrop({
|
||||||
elementType: createdAsset.type,
|
elementType: createdAsset.type,
|
||||||
mouseX,
|
mouseX,
|
||||||
mouseY,
|
mouseY,
|
||||||
tracks: currentTracks,
|
tracks: sceneTracks,
|
||||||
playheadTime: currentTime,
|
playheadTime: currentTime,
|
||||||
isExternalDrop: true,
|
isExternalDrop: true,
|
||||||
elementDuration: duration,
|
elementDuration: duration,
|
||||||
|
|
@ -488,7 +505,11 @@ export function useTimelineDragDrop({
|
||||||
trackId = addTrackCmd.getTrackId();
|
trackId = addTrackCmd.getTrackId();
|
||||||
editor.command.execute({ command: addTrackCmd });
|
editor.command.execute({ command: addTrackCmd });
|
||||||
} else {
|
} else {
|
||||||
trackId = currentTracks[dropTarget.trackIndex]?.id;
|
trackId = [
|
||||||
|
...sceneTracks.overlay,
|
||||||
|
sceneTracks.main,
|
||||||
|
...sceneTracks.audio,
|
||||||
|
][dropTarget.trackIndex]?.id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@ export function useTimelinePlayhead({
|
||||||
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
|
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
|
||||||
const time = (() => {
|
const time = (() => {
|
||||||
if (!shouldSnap) return frameTime;
|
if (!shouldSnap) return frameTime;
|
||||||
const tracks = editor.timeline.getTracks();
|
const tracks = editor.scenes.getActiveScene().tracks;
|
||||||
const bookmarks = editor.scenes.getActiveScene()?.bookmarks ?? [];
|
const bookmarks = editor.scenes.getActiveScene()?.bookmarks ?? [];
|
||||||
const snapPoints = findSnapPoints({
|
const snapPoints = findSnapPoints({
|
||||||
tracks,
|
tracks,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import type { TimelineElement } from "@/lib/timeline";
|
import { findTrackInSceneTracks, type TimelineElement } from "@/lib/timeline";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribes to render tracks and returns the live (preview-aware) version of
|
* Subscribes to render tracks and returns the live (preview-aware) version of
|
||||||
|
|
@ -18,13 +18,14 @@ export function useElementPreview<T extends TimelineElement>({
|
||||||
fallback: T;
|
fallback: T;
|
||||||
}) {
|
}) {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
useEditor((e) => e.timeline.getRenderTracks());
|
useEditor((e) => e.timeline.getPreviewTracks());
|
||||||
|
|
||||||
|
const previewTracks = editor.timeline.getPreviewTracks();
|
||||||
const renderElement =
|
const renderElement =
|
||||||
(editor.timeline
|
(findTrackInSceneTracks({
|
||||||
.getRenderTracks()
|
tracks: previewTracks ?? editor.scenes.getActiveScene().tracks,
|
||||||
.find((t) => t.id === trackId)
|
trackId,
|
||||||
?.elements.find((el) => el.id === elementId) as T | undefined) ??
|
})?.elements.find((element) => element.id === elementId) as T | undefined) ??
|
||||||
fallback;
|
fallback;
|
||||||
|
|
||||||
const previewUpdates = (updates: Partial<TimelineElement>) =>
|
const previewUpdates = (updates: Partial<TimelineElement>) =>
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,9 @@ export function useMaskHandles({
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const tracks = useEditor((e) => e.timeline.getRenderTracks());
|
const tracks = useEditor(
|
||||||
|
(e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks,
|
||||||
|
);
|
||||||
const currentTime = useEditor((e) => e.playback.getCurrentTime());
|
const currentTime = useEditor((e) => e.playback.getCurrentTime());
|
||||||
const mediaAssets = useEditor((e) => e.media.getAssets());
|
const mediaAssets = useEditor((e) => e.media.getAssets());
|
||||||
const canvasSize = useEditor(
|
const canvasSize = useEditor(
|
||||||
|
|
|
||||||
|
|
@ -180,7 +180,7 @@ export function usePreviewInteraction({
|
||||||
({ clientX, clientY }: React.MouseEvent) => {
|
({ clientX, clientY }: React.MouseEvent) => {
|
||||||
if (editingText || isMaskMode) return;
|
if (editingText || isMaskMode) return;
|
||||||
|
|
||||||
const tracks = editor.timeline.getTracks();
|
const tracks = editor.scenes.getActiveScene().tracks;
|
||||||
const currentTime = editor.playback.getCurrentTime();
|
const currentTime = editor.playback.getCurrentTime();
|
||||||
const mediaAssets = editor.media.getAssets();
|
const mediaAssets = editor.media.getAssets();
|
||||||
const canvasSize = editor.project.getActive().settings.canvasSize;
|
const canvasSize = editor.project.getActive().settings.canvasSize;
|
||||||
|
|
@ -238,7 +238,7 @@ export function usePreviewInteraction({
|
||||||
if (isMaskMode) return;
|
if (isMaskMode) return;
|
||||||
if (button !== 0) return;
|
if (button !== 0) return;
|
||||||
|
|
||||||
const tracks = editor.timeline.getTracks();
|
const tracks = editor.scenes.getActiveScene().tracks;
|
||||||
const currentTime = editor.playback.getCurrentTime();
|
const currentTime = editor.playback.getCurrentTime();
|
||||||
const mediaAssets = editor.media.getAssets();
|
const mediaAssets = editor.media.getAssets();
|
||||||
const canvasSize = editor.project.getActive().settings.canvasSize;
|
const canvasSize = editor.project.getActive().settings.canvasSize;
|
||||||
|
|
|
||||||
|
|
@ -132,7 +132,9 @@ export function useTransformHandles({
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedElements = useEditor((e) => e.selection.getSelectedElements());
|
const selectedElements = useEditor((e) => e.selection.getSelectedElements());
|
||||||
const tracks = useEditor((e) => e.timeline.getRenderTracks());
|
const tracks = useEditor(
|
||||||
|
(e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks,
|
||||||
|
);
|
||||||
const currentTime = useEditor((e) => e.playback.getCurrentTime());
|
const currentTime = useEditor((e) => e.playback.getCurrentTime());
|
||||||
const currentTimeRef = useRef(currentTime);
|
const currentTimeRef = useRef(currentTime);
|
||||||
currentTimeRef.current = currentTime;
|
currentTimeRef.current = currentTime;
|
||||||
|
|
|
||||||
|
|
@ -54,11 +54,15 @@ export class AddMediaAssetCommand extends Command {
|
||||||
assets: currentAssets.filter((asset) => asset.id !== this.assetId),
|
assets: currentAssets.filter((asset) => asset.id !== this.assetId),
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentTracks = editor.timeline.getTracks();
|
const currentTracks = editor.scenes.getActiveScene().tracks;
|
||||||
const orphanedElements: Array<{ trackId: string; elementId: string }> =
|
const orphanedElements: Array<{ trackId: string; elementId: string }> =
|
||||||
[];
|
[];
|
||||||
|
|
||||||
for (const track of currentTracks) {
|
for (const track of [
|
||||||
|
...currentTracks.overlay,
|
||||||
|
currentTracks.main,
|
||||||
|
...currentTracks.audio,
|
||||||
|
]) {
|
||||||
for (const element of track.elements) {
|
for (const element of track.elements) {
|
||||||
if (hasMediaId(element) && element.mediaId === this.assetId) {
|
if (hasMediaId(element) && element.mediaId === this.assetId) {
|
||||||
orphanedElements.push({
|
orphanedElements.push({
|
||||||
|
|
|
||||||
|
|
@ -1,99 +1,103 @@
|
||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import type { MediaAsset } from "@/lib/media/types";
|
import type { MediaAsset } from "@/lib/media/types";
|
||||||
import { storageService } from "@/services/storage/service";
|
import { storageService } from "@/services/storage/service";
|
||||||
import { videoCache } from "@/services/video-cache/service";
|
import { videoCache } from "@/services/video-cache/service";
|
||||||
import { hasMediaId } from "@/lib/timeline/element-utils";
|
import { hasMediaId } from "@/lib/timeline/element-utils";
|
||||||
import type { TimelineTrack } from "@/lib/timeline";
|
import type { SceneTracks } from "@/lib/timeline";
|
||||||
|
|
||||||
export class RemoveMediaAssetCommand extends Command {
|
export class RemoveMediaAssetCommand extends Command {
|
||||||
private savedAssets: MediaAsset[] | null = null;
|
private savedAssets: MediaAsset[] | null = null;
|
||||||
private savedTracks: TimelineTrack[] | null = null;
|
private savedTracks: SceneTracks | null = null;
|
||||||
private removedAsset: MediaAsset | null = null;
|
private removedAsset: MediaAsset | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private projectId: string,
|
private projectId: string,
|
||||||
private assetId: string,
|
private assetId: string,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
const assets = editor.media.getAssets();
|
const assets = editor.media.getAssets();
|
||||||
|
|
||||||
this.savedAssets = [...assets];
|
this.savedAssets = [...assets];
|
||||||
this.savedTracks = editor.timeline.getTracks();
|
this.savedTracks = editor.scenes.getActiveScene().tracks;
|
||||||
|
|
||||||
this.removedAsset =
|
this.removedAsset =
|
||||||
assets.find((media) => media.id === this.assetId) ?? null;
|
assets.find((media) => media.id === this.assetId) ?? null;
|
||||||
|
|
||||||
if (!this.removedAsset) {
|
if (!this.removedAsset) {
|
||||||
console.error("Media asset not found:", this.assetId);
|
console.error("Media asset not found:", this.assetId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.removedAsset.url) {
|
if (this.removedAsset.url) {
|
||||||
URL.revokeObjectURL(this.removedAsset.url);
|
URL.revokeObjectURL(this.removedAsset.url);
|
||||||
}
|
}
|
||||||
if (this.removedAsset.thumbnailUrl) {
|
if (this.removedAsset.thumbnailUrl) {
|
||||||
URL.revokeObjectURL(this.removedAsset.thumbnailUrl);
|
URL.revokeObjectURL(this.removedAsset.thumbnailUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
videoCache.clearVideo({ mediaId: this.assetId });
|
videoCache.clearVideo({ mediaId: this.assetId });
|
||||||
|
|
||||||
editor.media.setAssets({
|
editor.media.setAssets({
|
||||||
assets: assets.filter((media) => media.id !== this.assetId),
|
assets: assets.filter((media) => media.id !== this.assetId),
|
||||||
});
|
});
|
||||||
|
|
||||||
const elementsToRemove: Array<{ trackId: string; elementId: string }> = [];
|
const elementsToRemove: Array<{ trackId: string; elementId: string }> = [];
|
||||||
|
|
||||||
for (const track of this.savedTracks) {
|
for (const track of [
|
||||||
for (const element of track.elements) {
|
...this.savedTracks.overlay,
|
||||||
if (hasMediaId(element) && element.mediaId === this.assetId) {
|
this.savedTracks.main,
|
||||||
elementsToRemove.push({ trackId: track.id, elementId: element.id });
|
...this.savedTracks.audio,
|
||||||
}
|
]) {
|
||||||
}
|
for (const element of track.elements) {
|
||||||
}
|
if (hasMediaId(element) && element.mediaId === this.assetId) {
|
||||||
|
elementsToRemove.push({ trackId: track.id, elementId: element.id });
|
||||||
if (elementsToRemove.length > 0) {
|
}
|
||||||
editor.timeline.deleteElements({ elements: elementsToRemove });
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
storageService
|
if (elementsToRemove.length > 0) {
|
||||||
.deleteMediaAsset({ projectId: this.projectId, id: this.assetId })
|
editor.timeline.deleteElements({ elements: elementsToRemove });
|
||||||
.catch((error) => {
|
}
|
||||||
console.error("Failed to delete media item:", error);
|
|
||||||
});
|
storageService
|
||||||
}
|
.deleteMediaAsset({ projectId: this.projectId, id: this.assetId })
|
||||||
|
.catch((error) => {
|
||||||
undo(): void {
|
console.error("Failed to delete media item:", error);
|
||||||
const editor = EditorCore.getInstance();
|
});
|
||||||
|
}
|
||||||
if (this.savedAssets && this.removedAsset) {
|
|
||||||
const restoredAsset: MediaAsset = {
|
undo(): void {
|
||||||
...this.removedAsset,
|
const editor = EditorCore.getInstance();
|
||||||
url: URL.createObjectURL(this.removedAsset.file),
|
|
||||||
};
|
if (this.savedAssets && this.removedAsset) {
|
||||||
|
const restoredAsset: MediaAsset = {
|
||||||
editor.media.setAssets({
|
...this.removedAsset,
|
||||||
assets: this.savedAssets.map((a) =>
|
url: URL.createObjectURL(this.removedAsset.file),
|
||||||
a.id === this.assetId ? restoredAsset : a,
|
};
|
||||||
),
|
|
||||||
});
|
editor.media.setAssets({
|
||||||
|
assets: this.savedAssets.map((a) =>
|
||||||
storageService
|
a.id === this.assetId ? restoredAsset : a,
|
||||||
.saveMediaAsset({
|
),
|
||||||
projectId: this.projectId,
|
});
|
||||||
mediaAsset: restoredAsset,
|
|
||||||
})
|
storageService
|
||||||
.catch((error) => {
|
.saveMediaAsset({
|
||||||
console.error("Failed to restore media item on undo:", error);
|
projectId: this.projectId,
|
||||||
});
|
mediaAsset: restoredAsset,
|
||||||
}
|
})
|
||||||
|
.catch((error) => {
|
||||||
if (this.savedTracks) {
|
console.error("Failed to restore media item on undo:", error);
|
||||||
editor.timeline.updateTracks(this.savedTracks);
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
if (this.savedTracks) {
|
||||||
|
editor.timeline.updateTracks(this.savedTracks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,23 @@
|
||||||
export class PreviewTracker<T> {
|
export class PreviewTracker<T> {
|
||||||
private snapshot: T | null = null;
|
private snapshot: T | null = null;
|
||||||
|
|
||||||
begin({ state }: { state: T }): void {
|
begin({ state }: { state: T }): void {
|
||||||
if (this.snapshot === null) {
|
if (this.snapshot === null) {
|
||||||
this.snapshot = structuredClone(state);
|
this.snapshot = structuredClone(state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isActive(): boolean {
|
isActive(): boolean {
|
||||||
return this.snapshot !== null;
|
return this.snapshot !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getSnapshot(): T | null {
|
getSnapshot(): T | null {
|
||||||
return this.snapshot;
|
return this.snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
end(): T | null {
|
end(): T | null {
|
||||||
const snapshot = this.snapshot;
|
const snapshot = this.snapshot;
|
||||||
this.snapshot = null;
|
this.snapshot = null;
|
||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,46 @@
|
||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import type { TProject, TProjectSettings } from "@/lib/project/types";
|
import type { TProject, TProjectSettings } from "@/lib/project/types";
|
||||||
|
|
||||||
export class UpdateProjectSettingsCommand extends Command {
|
export class UpdateProjectSettingsCommand extends Command {
|
||||||
private savedSettings: TProjectSettings | null = null;
|
private savedSettings: TProjectSettings | null = null;
|
||||||
private savedUpdatedAt: Date | null = null;
|
private savedUpdatedAt: Date | null = null;
|
||||||
|
|
||||||
constructor(private updates: Partial<TProjectSettings>) {
|
constructor(private updates: Partial<TProjectSettings>) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
const activeProject = editor.project.getActive();
|
const activeProject = editor.project.getActive();
|
||||||
if (!activeProject) return;
|
if (!activeProject) return;
|
||||||
|
|
||||||
this.savedSettings = activeProject.settings;
|
this.savedSettings = activeProject.settings;
|
||||||
this.savedUpdatedAt = activeProject.metadata.updatedAt;
|
this.savedUpdatedAt = activeProject.metadata.updatedAt;
|
||||||
|
|
||||||
const updatedProject: TProject = {
|
const updatedProject: TProject = {
|
||||||
...activeProject,
|
...activeProject,
|
||||||
settings: { ...activeProject.settings, ...this.updates },
|
settings: { ...activeProject.settings, ...this.updates },
|
||||||
metadata: { ...activeProject.metadata, updatedAt: new Date() },
|
metadata: { ...activeProject.metadata, updatedAt: new Date() },
|
||||||
};
|
};
|
||||||
|
|
||||||
editor.project.setActiveProject({ project: updatedProject });
|
editor.project.setActiveProject({ project: updatedProject });
|
||||||
editor.save.markDirty();
|
editor.save.markDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
undo(): void {
|
undo(): void {
|
||||||
if (!this.savedSettings || !this.savedUpdatedAt) return;
|
if (!this.savedSettings || !this.savedUpdatedAt) return;
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
const activeProject = editor.project.getActive();
|
const activeProject = editor.project.getActive();
|
||||||
if (!activeProject) return;
|
if (!activeProject) return;
|
||||||
|
|
||||||
const updatedProject: TProject = {
|
const updatedProject: TProject = {
|
||||||
...activeProject,
|
...activeProject,
|
||||||
settings: this.savedSettings,
|
settings: this.savedSettings,
|
||||||
metadata: { ...activeProject.metadata, updatedAt: this.savedUpdatedAt },
|
metadata: { ...activeProject.metadata, updatedAt: this.savedUpdatedAt },
|
||||||
};
|
};
|
||||||
|
|
||||||
editor.project.setActiveProject({ project: updatedProject });
|
editor.project.setActiveProject({ project: updatedProject });
|
||||||
editor.save.markDirty();
|
editor.save.markDirty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,59 +1,59 @@
|
||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import type { TScene } from "@/lib/timeline";
|
import type { TScene } from "@/lib/timeline";
|
||||||
import { canDeleteScene, getFallbackSceneAfterDelete } from "@/lib/scenes";
|
import { canDeleteScene, getFallbackSceneAfterDelete } from "@/lib/scenes";
|
||||||
|
|
||||||
export class DeleteSceneCommand extends Command {
|
export class DeleteSceneCommand extends Command {
|
||||||
private savedScenes: TScene[] | null = null;
|
private savedScenes: TScene[] | null = null;
|
||||||
private savedActiveSceneId: string | null = null;
|
private savedActiveSceneId: string | null = null;
|
||||||
private deletedScene: TScene | null = null;
|
private deletedScene: TScene | null = null;
|
||||||
|
|
||||||
constructor(private sceneId: string) {
|
constructor(private sceneId: string) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
const scenes = editor.scenes.getScenes();
|
const scenes = editor.scenes.getScenes();
|
||||||
const activeScene = editor.scenes.getActiveScene();
|
const activeScene = editor.scenes.getActiveScene();
|
||||||
|
|
||||||
this.savedScenes = [...scenes];
|
this.savedScenes = [...scenes];
|
||||||
this.savedActiveSceneId = activeScene?.id ?? null;
|
this.savedActiveSceneId = activeScene?.id ?? null;
|
||||||
|
|
||||||
this.deletedScene = scenes.find((s) => s.id === this.sceneId) ?? null;
|
this.deletedScene = scenes.find((s) => s.id === this.sceneId) ?? null;
|
||||||
|
|
||||||
if (!this.deletedScene) {
|
if (!this.deletedScene) {
|
||||||
console.error("Scene not found:", this.sceneId);
|
console.error("Scene not found:", this.sceneId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { canDelete, reason } = canDeleteScene({ scene: this.deletedScene });
|
const { canDelete, reason } = canDeleteScene({ scene: this.deletedScene });
|
||||||
if (!canDelete) {
|
if (!canDelete) {
|
||||||
console.error("Cannot delete scene:", reason);
|
console.error("Cannot delete scene:", reason);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedScenes = scenes.filter((s) => s.id !== this.sceneId);
|
const updatedScenes = scenes.filter((s) => s.id !== this.sceneId);
|
||||||
|
|
||||||
const newActiveScene = getFallbackSceneAfterDelete({
|
const newActiveScene = getFallbackSceneAfterDelete({
|
||||||
scenes: updatedScenes,
|
scenes: updatedScenes,
|
||||||
deletedSceneId: this.sceneId,
|
deletedSceneId: this.sceneId,
|
||||||
currentSceneId: activeScene?.id ?? null,
|
currentSceneId: activeScene?.id ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
editor.scenes.setScenes({
|
editor.scenes.setScenes({
|
||||||
scenes: updatedScenes,
|
scenes: updatedScenes,
|
||||||
activeSceneId: newActiveScene?.id,
|
activeSceneId: newActiveScene?.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
undo(): void {
|
undo(): void {
|
||||||
if (this.savedScenes && this.deletedScene) {
|
if (this.savedScenes && this.deletedScene) {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
editor.scenes.setScenes({
|
editor.scenes.setScenes({
|
||||||
scenes: this.savedScenes,
|
scenes: this.savedScenes,
|
||||||
activeSceneId: this.savedActiveSceneId ?? undefined,
|
activeSceneId: this.savedActiveSceneId ?? undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
export { CreateSceneCommand } from "./create-scene";
|
export { CreateSceneCommand } from "./create-scene";
|
||||||
export { DeleteSceneCommand } from "./delete-scene";
|
export { DeleteSceneCommand } from "./delete-scene";
|
||||||
export { RenameSceneCommand } from "./rename-scene";
|
export { RenameSceneCommand } from "./rename-scene";
|
||||||
export { ToggleBookmarkCommand } from "./toggle-bookmark";
|
export { ToggleBookmarkCommand } from "./toggle-bookmark";
|
||||||
export { RemoveBookmarkCommand } from "./remove-bookmark";
|
export { RemoveBookmarkCommand } from "./remove-bookmark";
|
||||||
export { UpdateBookmarkCommand } from "./update-bookmark";
|
export { UpdateBookmarkCommand } from "./update-bookmark";
|
||||||
export { MoveBookmarkCommand } from "./move-bookmark";
|
export { MoveBookmarkCommand } from "./move-bookmark";
|
||||||
|
|
|
||||||
|
|
@ -1,59 +1,59 @@
|
||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import type { TScene } from "@/lib/timeline";
|
import type { TScene } from "@/lib/timeline";
|
||||||
import { updateSceneInArray } from "@/lib/scenes";
|
import { updateSceneInArray } from "@/lib/scenes";
|
||||||
import { getFrameTime, moveBookmarkInArray } from "@/lib/timeline/bookmarks";
|
import { getFrameTime, moveBookmarkInArray } from "@/lib/timeline/bookmarks";
|
||||||
|
|
||||||
export class MoveBookmarkCommand extends Command {
|
export class MoveBookmarkCommand extends Command {
|
||||||
private savedScenes: TScene[] | null = null;
|
private savedScenes: TScene[] | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private fromTime: number,
|
private fromTime: number,
|
||||||
private toTime: number,
|
private toTime: number,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
const activeScene = editor.scenes.getActiveScene();
|
const activeScene = editor.scenes.getActiveScene();
|
||||||
const activeProject = editor.project.getActive();
|
const activeProject = editor.project.getActive();
|
||||||
|
|
||||||
if (!activeScene || !activeProject) {
|
if (!activeScene || !activeProject) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const scenes = editor.scenes.getScenes();
|
const scenes = editor.scenes.getScenes();
|
||||||
this.savedScenes = [...scenes];
|
this.savedScenes = [...scenes];
|
||||||
|
|
||||||
const fromFrameTime = getFrameTime({
|
const fromFrameTime = getFrameTime({
|
||||||
time: this.fromTime,
|
time: this.fromTime,
|
||||||
fps: activeProject.settings.fps,
|
fps: activeProject.settings.fps,
|
||||||
});
|
});
|
||||||
const toFrameTime = getFrameTime({
|
const toFrameTime = getFrameTime({
|
||||||
time: this.toTime,
|
time: this.toTime,
|
||||||
fps: activeProject.settings.fps,
|
fps: activeProject.settings.fps,
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedBookmarks = moveBookmarkInArray({
|
const updatedBookmarks = moveBookmarkInArray({
|
||||||
bookmarks: activeScene.bookmarks,
|
bookmarks: activeScene.bookmarks,
|
||||||
fromTime: fromFrameTime,
|
fromTime: fromFrameTime,
|
||||||
toTime: toFrameTime,
|
toTime: toFrameTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedScenes = updateSceneInArray({
|
const updatedScenes = updateSceneInArray({
|
||||||
scenes,
|
scenes,
|
||||||
sceneId: activeScene.id,
|
sceneId: activeScene.id,
|
||||||
updates: { bookmarks: updatedBookmarks },
|
updates: { bookmarks: updatedBookmarks },
|
||||||
});
|
});
|
||||||
|
|
||||||
editor.scenes.setScenes({ scenes: updatedScenes });
|
editor.scenes.setScenes({ scenes: updatedScenes });
|
||||||
}
|
}
|
||||||
|
|
||||||
undo(): void {
|
undo(): void {
|
||||||
if (this.savedScenes) {
|
if (this.savedScenes) {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
editor.scenes.setScenes({ scenes: this.savedScenes });
|
editor.scenes.setScenes({ scenes: this.savedScenes });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,59 +1,59 @@
|
||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import type { TScene } from "@/lib/timeline";
|
import type { TScene } from "@/lib/timeline";
|
||||||
import { updateSceneInArray } from "@/lib/scenes";
|
import { updateSceneInArray } from "@/lib/scenes";
|
||||||
import {
|
import {
|
||||||
getFrameTime,
|
getFrameTime,
|
||||||
removeBookmarkFromArray,
|
removeBookmarkFromArray,
|
||||||
} from "@/lib/timeline/bookmarks";
|
} from "@/lib/timeline/bookmarks";
|
||||||
|
|
||||||
export class RemoveBookmarkCommand extends Command {
|
export class RemoveBookmarkCommand extends Command {
|
||||||
private savedScenes: TScene[] | null = null;
|
private savedScenes: TScene[] | null = null;
|
||||||
private frameTime: number = 0;
|
private frameTime: number = 0;
|
||||||
|
|
||||||
constructor(private time: number) {
|
constructor(private time: number) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
const activeScene = editor.scenes.getActiveScene();
|
const activeScene = editor.scenes.getActiveScene();
|
||||||
const activeProject = editor.project.getActive();
|
const activeProject = editor.project.getActive();
|
||||||
|
|
||||||
if (!activeScene || !activeProject) {
|
if (!activeScene || !activeProject) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const scenes = editor.scenes.getScenes();
|
const scenes = editor.scenes.getScenes();
|
||||||
this.savedScenes = [...scenes];
|
this.savedScenes = [...scenes];
|
||||||
|
|
||||||
this.frameTime = getFrameTime({
|
this.frameTime = getFrameTime({
|
||||||
time: this.time,
|
time: this.time,
|
||||||
fps: activeProject.settings.fps,
|
fps: activeProject.settings.fps,
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedBookmarks = removeBookmarkFromArray({
|
const updatedBookmarks = removeBookmarkFromArray({
|
||||||
bookmarks: activeScene.bookmarks,
|
bookmarks: activeScene.bookmarks,
|
||||||
frameTime: this.frameTime,
|
frameTime: this.frameTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (updatedBookmarks.length === activeScene.bookmarks.length) {
|
if (updatedBookmarks.length === activeScene.bookmarks.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedScenes = updateSceneInArray({
|
const updatedScenes = updateSceneInArray({
|
||||||
scenes,
|
scenes,
|
||||||
sceneId: activeScene.id,
|
sceneId: activeScene.id,
|
||||||
updates: { bookmarks: updatedBookmarks },
|
updates: { bookmarks: updatedBookmarks },
|
||||||
});
|
});
|
||||||
|
|
||||||
editor.scenes.setScenes({ scenes: updatedScenes });
|
editor.scenes.setScenes({ scenes: updatedScenes });
|
||||||
}
|
}
|
||||||
|
|
||||||
undo(): void {
|
undo(): void {
|
||||||
if (this.savedScenes) {
|
if (this.savedScenes) {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
editor.scenes.setScenes({ scenes: this.savedScenes });
|
editor.scenes.setScenes({ scenes: this.savedScenes });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,46 @@
|
||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import type { TScene } from "@/lib/timeline";
|
import type { TScene } from "@/lib/timeline";
|
||||||
import { updateSceneInArray } from "@/lib/scenes";
|
import { updateSceneInArray } from "@/lib/scenes";
|
||||||
|
|
||||||
export class RenameSceneCommand extends Command {
|
export class RenameSceneCommand extends Command {
|
||||||
private savedScenes: TScene[] | null = null;
|
private savedScenes: TScene[] | null = null;
|
||||||
private previousName: string | null = null;
|
private previousName: string | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private sceneId: string,
|
private sceneId: string,
|
||||||
private newName: string,
|
private newName: string,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
const scenes = editor.scenes.getScenes();
|
const scenes = editor.scenes.getScenes();
|
||||||
|
|
||||||
this.savedScenes = [...scenes];
|
this.savedScenes = [...scenes];
|
||||||
|
|
||||||
const scene = scenes.find((s) => s.id === this.sceneId);
|
const scene = scenes.find((s) => s.id === this.sceneId);
|
||||||
if (!scene) {
|
if (!scene) {
|
||||||
console.error("Scene not found:", this.sceneId);
|
console.error("Scene not found:", this.sceneId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.previousName = scene.name;
|
this.previousName = scene.name;
|
||||||
|
|
||||||
const updatedScenes = updateSceneInArray({
|
const updatedScenes = updateSceneInArray({
|
||||||
scenes,
|
scenes,
|
||||||
sceneId: this.sceneId,
|
sceneId: this.sceneId,
|
||||||
updates: { name: this.newName, updatedAt: new Date() },
|
updates: { name: this.newName, updatedAt: new Date() },
|
||||||
});
|
});
|
||||||
|
|
||||||
editor.scenes.setScenes({ scenes: updatedScenes });
|
editor.scenes.setScenes({ scenes: updatedScenes });
|
||||||
}
|
}
|
||||||
|
|
||||||
undo(): void {
|
undo(): void {
|
||||||
if (this.savedScenes && this.previousName !== null) {
|
if (this.savedScenes && this.previousName !== null) {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
editor.scenes.setScenes({ scenes: this.savedScenes });
|
editor.scenes.setScenes({ scenes: this.savedScenes });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,52 @@
|
||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import type { TScene } from "@/lib/timeline";
|
import type { TScene } from "@/lib/timeline";
|
||||||
import { updateSceneInArray } from "@/lib/scenes";
|
import { updateSceneInArray } from "@/lib/scenes";
|
||||||
import { getFrameTime, toggleBookmarkInArray } from "@/lib/timeline/bookmarks";
|
import { getFrameTime, toggleBookmarkInArray } from "@/lib/timeline/bookmarks";
|
||||||
|
|
||||||
export class ToggleBookmarkCommand extends Command {
|
export class ToggleBookmarkCommand extends Command {
|
||||||
private savedScenes: TScene[] | null = null;
|
private savedScenes: TScene[] | null = null;
|
||||||
private frameTime: number = 0;
|
private frameTime: number = 0;
|
||||||
|
|
||||||
constructor(private time: number) {
|
constructor(private time: number) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
const activeScene = editor.scenes.getActiveScene();
|
const activeScene = editor.scenes.getActiveScene();
|
||||||
const activeProject = editor.project.getActive();
|
const activeProject = editor.project.getActive();
|
||||||
|
|
||||||
if (!activeScene || !activeProject) {
|
if (!activeScene || !activeProject) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const scenes = editor.scenes.getScenes();
|
const scenes = editor.scenes.getScenes();
|
||||||
this.savedScenes = [...scenes];
|
this.savedScenes = [...scenes];
|
||||||
|
|
||||||
this.frameTime = getFrameTime({
|
this.frameTime = getFrameTime({
|
||||||
time: this.time,
|
time: this.time,
|
||||||
fps: activeProject.settings.fps,
|
fps: activeProject.settings.fps,
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedBookmarks = toggleBookmarkInArray({
|
const updatedBookmarks = toggleBookmarkInArray({
|
||||||
bookmarks: activeScene.bookmarks,
|
bookmarks: activeScene.bookmarks,
|
||||||
frameTime: this.frameTime,
|
frameTime: this.frameTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedScenes = updateSceneInArray({
|
const updatedScenes = updateSceneInArray({
|
||||||
scenes,
|
scenes,
|
||||||
sceneId: activeScene.id,
|
sceneId: activeScene.id,
|
||||||
updates: { bookmarks: updatedBookmarks },
|
updates: { bookmarks: updatedBookmarks },
|
||||||
});
|
});
|
||||||
|
|
||||||
editor.scenes.setScenes({ scenes: updatedScenes });
|
editor.scenes.setScenes({ scenes: updatedScenes });
|
||||||
}
|
}
|
||||||
|
|
||||||
undo(): void {
|
undo(): void {
|
||||||
if (this.savedScenes) {
|
if (this.savedScenes) {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
editor.scenes.setScenes({ scenes: this.savedScenes });
|
editor.scenes.setScenes({ scenes: this.savedScenes });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,55 +1,55 @@
|
||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import type { Bookmark, TScene } from "@/lib/timeline";
|
import type { Bookmark, TScene } from "@/lib/timeline";
|
||||||
import { updateSceneInArray } from "@/lib/scenes";
|
import { updateSceneInArray } from "@/lib/scenes";
|
||||||
import { getFrameTime, updateBookmarkInArray } from "@/lib/timeline/bookmarks";
|
import { getFrameTime, updateBookmarkInArray } from "@/lib/timeline/bookmarks";
|
||||||
|
|
||||||
export class UpdateBookmarkCommand extends Command {
|
export class UpdateBookmarkCommand extends Command {
|
||||||
private savedScenes: TScene[] | null = null;
|
private savedScenes: TScene[] | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private time: number,
|
private time: number,
|
||||||
private updates: Partial<Omit<Bookmark, "time">>,
|
private updates: Partial<Omit<Bookmark, "time">>,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
const activeScene = editor.scenes.getActiveScene();
|
const activeScene = editor.scenes.getActiveScene();
|
||||||
const activeProject = editor.project.getActive();
|
const activeProject = editor.project.getActive();
|
||||||
|
|
||||||
if (!activeScene || !activeProject) {
|
if (!activeScene || !activeProject) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const scenes = editor.scenes.getScenes();
|
const scenes = editor.scenes.getScenes();
|
||||||
this.savedScenes = [...scenes];
|
this.savedScenes = [...scenes];
|
||||||
|
|
||||||
const frameTime = getFrameTime({
|
const frameTime = getFrameTime({
|
||||||
time: this.time,
|
time: this.time,
|
||||||
fps: activeProject.settings.fps,
|
fps: activeProject.settings.fps,
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedBookmarks = updateBookmarkInArray({
|
const updatedBookmarks = updateBookmarkInArray({
|
||||||
bookmarks: activeScene.bookmarks,
|
bookmarks: activeScene.bookmarks,
|
||||||
frameTime,
|
frameTime,
|
||||||
updates: this.updates,
|
updates: this.updates,
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedScenes = updateSceneInArray({
|
const updatedScenes = updateSceneInArray({
|
||||||
scenes,
|
scenes,
|
||||||
sceneId: activeScene.id,
|
sceneId: activeScene.id,
|
||||||
updates: { bookmarks: updatedBookmarks },
|
updates: { bookmarks: updatedBookmarks },
|
||||||
});
|
});
|
||||||
|
|
||||||
editor.scenes.setScenes({ scenes: updatedScenes });
|
editor.scenes.setScenes({ scenes: updatedScenes });
|
||||||
}
|
}
|
||||||
|
|
||||||
undo(): void {
|
undo(): void {
|
||||||
if (this.savedScenes) {
|
if (this.savedScenes) {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
editor.scenes.setScenes({ scenes: this.savedScenes });
|
editor.scenes.setScenes({ scenes: this.savedScenes });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,16 @@
|
||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import type {
|
import type {
|
||||||
TimelineTrack,
|
SceneTracks,
|
||||||
TimelineElement,
|
TimelineElement,
|
||||||
ClipboardItem,
|
ClipboardItem,
|
||||||
} from "@/lib/timeline";
|
} from "@/lib/timeline";
|
||||||
import { generateUUID } from "@/utils/id";
|
import { generateUUID } from "@/utils/id";
|
||||||
import {
|
import { applyPlacement, resolveTrackPlacement, enforceMainTrackStart } from "@/lib/timeline/placement";
|
||||||
applyPlacement,
|
|
||||||
resolveTrackPlacement,
|
|
||||||
isMainTrack,
|
|
||||||
enforceMainTrackStart,
|
|
||||||
} from "@/lib/timeline/placement";
|
|
||||||
import { cloneAnimations } from "@/lib/animation";
|
import { cloneAnimations } from "@/lib/animation";
|
||||||
|
|
||||||
export class PasteCommand extends Command {
|
export class PasteCommand extends Command {
|
||||||
private savedState: TimelineTrack[] | null = null;
|
private savedState: SceneTracks | null = null;
|
||||||
private pastedElements: { trackId: string; elementId: string }[] = [];
|
private pastedElements: { trackId: string; elementId: string }[] = [];
|
||||||
private readonly time: number;
|
private readonly time: number;
|
||||||
private readonly clipboardItems: ClipboardItem[];
|
private readonly clipboardItems: ClipboardItem[];
|
||||||
|
|
@ -36,14 +31,14 @@ export class PasteCommand extends Command {
|
||||||
if (this.clipboardItems.length === 0) return undefined;
|
if (this.clipboardItems.length === 0) return undefined;
|
||||||
|
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
this.savedState = editor.timeline.getTracks();
|
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||||
this.pastedElements = [];
|
this.pastedElements = [];
|
||||||
|
|
||||||
const minStart = Math.min(
|
const minStart = Math.min(
|
||||||
...this.clipboardItems.map((item) => item.element.startTime),
|
...this.clipboardItems.map((item) => item.element.startTime),
|
||||||
);
|
);
|
||||||
|
|
||||||
let updatedTracks = [...this.savedState];
|
let updatedTracks = this.savedState;
|
||||||
const itemsByTrackId = groupClipboardItemsByTrackId({
|
const itemsByTrackId = groupClipboardItemsByTrackId({
|
||||||
clipboardItems: this.clipboardItems,
|
clipboardItems: this.clipboardItems,
|
||||||
});
|
});
|
||||||
|
|
@ -60,9 +55,11 @@ export class PasteCommand extends Command {
|
||||||
}
|
}
|
||||||
|
|
||||||
const trackType = items[0].trackType;
|
const trackType = items[0].trackType;
|
||||||
const sourceTrackIndex = updatedTracks.findIndex(
|
const sourceTrackIndex = [
|
||||||
(track) => track.id === trackId,
|
...updatedTracks.overlay,
|
||||||
);
|
updatedTracks.main,
|
||||||
|
...updatedTracks.audio,
|
||||||
|
].findIndex((track) => track.id === trackId);
|
||||||
const placementResult = resolveTrackPlacement({
|
const placementResult = resolveTrackPlacement({
|
||||||
tracks: updatedTracks,
|
tracks: updatedTracks,
|
||||||
trackType,
|
trackType,
|
||||||
|
|
@ -78,8 +75,15 @@ export class PasteCommand extends Command {
|
||||||
|
|
||||||
let elementsForPlacement = elementsToAdd;
|
let elementsForPlacement = elementsToAdd;
|
||||||
if (placementResult.kind === "existingTrack") {
|
if (placementResult.kind === "existingTrack") {
|
||||||
const targetTrack = updatedTracks[placementResult.trackIndex];
|
const targetTrack =
|
||||||
if (isMainTrack(targetTrack)) {
|
placementResult.trackIndex < updatedTracks.overlay.length
|
||||||
|
? updatedTracks.overlay[placementResult.trackIndex]
|
||||||
|
: placementResult.trackIndex === updatedTracks.overlay.length
|
||||||
|
? updatedTracks.main
|
||||||
|
: updatedTracks.audio[
|
||||||
|
placementResult.trackIndex - updatedTracks.overlay.length - 1
|
||||||
|
];
|
||||||
|
if (targetTrack?.id === updatedTracks.main.id) {
|
||||||
const earliestElement = elementsToAdd.reduce((earliest, element) =>
|
const earliestElement = elementsToAdd.reduce((earliest, element) =>
|
||||||
element.startTime < earliest.startTime ? element : earliest,
|
element.startTime < earliest.startTime ? element : earliest,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,28 @@
|
||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import type { TimelineTrack } from "@/lib/timeline";
|
import type { SceneTracks } from "@/lib/timeline";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
|
import type { TimelineTrack } from "@/lib/timeline";
|
||||||
|
|
||||||
|
function removeTrackElements<TTrack extends TimelineTrack>({
|
||||||
|
track,
|
||||||
|
elements,
|
||||||
|
}: {
|
||||||
|
track: TTrack;
|
||||||
|
elements: { trackId: string; elementId: string }[];
|
||||||
|
}): TTrack {
|
||||||
|
const nextElements = track.elements.filter(
|
||||||
|
(element) =>
|
||||||
|
!elements.some(
|
||||||
|
(target) =>
|
||||||
|
target.trackId === track.id && target.elementId === element.id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { ...track, elements: nextElements } as TTrack;
|
||||||
|
}
|
||||||
|
|
||||||
export class DeleteElementsCommand extends Command {
|
export class DeleteElementsCommand extends Command {
|
||||||
private savedState: TimelineTrack[] | null = null;
|
private savedState: SceneTracks | null = null;
|
||||||
private readonly elements: { trackId: string; elementId: string }[];
|
private readonly elements: { trackId: string; elementId: string }[];
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
|
|
@ -17,28 +36,20 @@ export class DeleteElementsCommand extends Command {
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
this.savedState = editor.timeline.getTracks();
|
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||||
|
|
||||||
const updatedTracks = this.savedState.map((track) => {
|
const updatedTracks: SceneTracks = {
|
||||||
const elementsToDeleteOnTrack = this.elements.filter(
|
overlay: this.savedState.overlay.map((track) =>
|
||||||
(target) => target.trackId === track.id,
|
removeTrackElements({ track, elements: this.elements }),
|
||||||
);
|
),
|
||||||
|
main: removeTrackElements({
|
||||||
if (elementsToDeleteOnTrack.length === 0) {
|
track: this.savedState.main,
|
||||||
return track;
|
elements: this.elements,
|
||||||
}
|
}),
|
||||||
|
audio: this.savedState.audio.map((track) =>
|
||||||
const elements = track.elements.filter(
|
removeTrackElements({ track, elements: this.elements }),
|
||||||
(element) =>
|
),
|
||||||
!this.elements.some(
|
};
|
||||||
(target) =>
|
|
||||||
target.trackId === track.id &&
|
|
||||||
target.elementId === element.id,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return { ...track, elements } as typeof track;
|
|
||||||
});
|
|
||||||
|
|
||||||
editor.timeline.updateTracks(updatedTracks);
|
editor.timeline.updateTracks(updatedTracks);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
|
import type { SceneTracks, TimelineElement } from "@/lib/timeline";
|
||||||
import { generateUUID } from "@/utils/id";
|
import { generateUUID } from "@/utils/id";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import { applyPlacement, resolveTrackPlacement } from "@/lib/timeline/placement";
|
import { applyPlacement, resolveTrackPlacement } from "@/lib/timeline/placement";
|
||||||
|
|
@ -11,7 +11,7 @@ interface DuplicateElementsParams {
|
||||||
|
|
||||||
export class DuplicateElementsCommand extends Command {
|
export class DuplicateElementsCommand extends Command {
|
||||||
private duplicatedElements: { trackId: string; elementId: string }[] = [];
|
private duplicatedElements: { trackId: string; elementId: string }[] = [];
|
||||||
private savedState: TimelineTrack[] | null = null;
|
private savedState: SceneTracks | null = null;
|
||||||
private elements: DuplicateElementsParams["elements"];
|
private elements: DuplicateElementsParams["elements"];
|
||||||
|
|
||||||
constructor({ elements }: DuplicateElementsParams) {
|
constructor({ elements }: DuplicateElementsParams) {
|
||||||
|
|
@ -21,12 +21,16 @@ export class DuplicateElementsCommand extends Command {
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
this.savedState = editor.timeline.getTracks();
|
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||||
this.duplicatedElements = [];
|
this.duplicatedElements = [];
|
||||||
|
|
||||||
let updatedTracks = [...this.savedState];
|
let updatedTracks = this.savedState;
|
||||||
|
|
||||||
for (const track of this.savedState) {
|
for (const track of [
|
||||||
|
...this.savedState.overlay,
|
||||||
|
this.savedState.main,
|
||||||
|
...this.savedState.audio,
|
||||||
|
]) {
|
||||||
const elementsToDuplicate = this.elements.filter(
|
const elementsToDuplicate = this.elements.filter(
|
||||||
(elementEntry) => elementEntry.trackId === track.id,
|
(elementEntry) => elementEntry.trackId === track.id,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
|
import { isVisualElement, updateElementInSceneTracks } from "@/lib/timeline";
|
||||||
import type { TimelineTrack, VisualElement } from "@/lib/timeline";
|
import type { SceneTracks, VisualElement } from "@/lib/timeline";
|
||||||
import { buildDefaultEffectInstance } from "@/lib/effects";
|
import { buildDefaultEffectInstance } from "@/lib/effects";
|
||||||
|
|
||||||
function addEffectToElement({
|
function addEffectToElement({
|
||||||
|
|
@ -17,7 +17,7 @@ function addEffectToElement({
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AddClipEffectCommand extends Command {
|
export class AddClipEffectCommand extends Command {
|
||||||
private savedState: TimelineTrack[] | null = null;
|
private savedState: SceneTracks | null = null;
|
||||||
private effectId: string | null = null;
|
private effectId: string | null = null;
|
||||||
private readonly trackId: string;
|
private readonly trackId: string;
|
||||||
private readonly elementId: string;
|
private readonly elementId: string;
|
||||||
|
|
@ -40,9 +40,9 @@ export class AddClipEffectCommand extends Command {
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
this.savedState = editor.timeline.getTracks();
|
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||||
|
|
||||||
const updatedTracks = updateElementInTracks({
|
const updatedTracks = updateElementInSceneTracks({
|
||||||
tracks: this.savedState,
|
tracks: this.savedState,
|
||||||
trackId: this.trackId,
|
trackId: this.trackId,
|
||||||
elementId: this.elementId,
|
elementId: this.elementId,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
export { AddClipEffectCommand } from "./add-effect";
|
export { AddClipEffectCommand } from "./add-effect";
|
||||||
export { RemoveClipEffectCommand } from "./remove-effect";
|
export { RemoveClipEffectCommand } from "./remove-effect";
|
||||||
export { ToggleClipEffectCommand } from "./toggle-effect";
|
export { ToggleClipEffectCommand } from "./toggle-effect";
|
||||||
export { UpdateClipEffectParamsCommand } from "./update-effect-params";
|
export { UpdateClipEffectParamsCommand } from "./update-effect-params";
|
||||||
export { ReorderClipEffectsCommand } from "./reorder-effect";
|
export { ReorderClipEffectsCommand } from "./reorder-effect";
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
|
import { isVisualElement, updateElementInSceneTracks } from "@/lib/timeline";
|
||||||
import type { TimelineTrack, VisualElement } from "@/lib/timeline";
|
import type { SceneTracks, VisualElement } from "@/lib/timeline";
|
||||||
|
|
||||||
function removeEffectFromElement({
|
function removeEffectFromElement({
|
||||||
element,
|
element,
|
||||||
|
|
@ -16,7 +16,7 @@ function removeEffectFromElement({
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RemoveClipEffectCommand extends Command {
|
export class RemoveClipEffectCommand extends Command {
|
||||||
private savedState: TimelineTrack[] | null = null;
|
private savedState: SceneTracks | null = null;
|
||||||
private readonly trackId: string;
|
private readonly trackId: string;
|
||||||
private readonly elementId: string;
|
private readonly elementId: string;
|
||||||
private readonly effectId: string;
|
private readonly effectId: string;
|
||||||
|
|
@ -38,9 +38,9 @@ export class RemoveClipEffectCommand extends Command {
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
this.savedState = editor.timeline.getTracks();
|
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||||
|
|
||||||
const updatedTracks = updateElementInTracks({
|
const updatedTracks = updateElementInSceneTracks({
|
||||||
tracks: this.savedState,
|
tracks: this.savedState,
|
||||||
trackId: this.trackId,
|
trackId: this.trackId,
|
||||||
elementId: this.elementId,
|
elementId: this.elementId,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
|
import { isVisualElement, updateElementInSceneTracks } from "@/lib/timeline";
|
||||||
import type { TimelineTrack, VisualElement } from "@/lib/timeline";
|
import type { SceneTracks, VisualElement } from "@/lib/timeline";
|
||||||
|
|
||||||
function reorderEffectsOnElement({
|
function reorderEffectsOnElement({
|
||||||
element,
|
element,
|
||||||
|
|
@ -19,7 +19,7 @@ function reorderEffectsOnElement({
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ReorderClipEffectsCommand extends Command {
|
export class ReorderClipEffectsCommand extends Command {
|
||||||
private savedState: TimelineTrack[] | null = null;
|
private savedState: SceneTracks | null = null;
|
||||||
private readonly trackId: string;
|
private readonly trackId: string;
|
||||||
private readonly elementId: string;
|
private readonly elementId: string;
|
||||||
private readonly fromIndex: number;
|
private readonly fromIndex: number;
|
||||||
|
|
@ -45,9 +45,9 @@ export class ReorderClipEffectsCommand extends Command {
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
this.savedState = editor.timeline.getTracks();
|
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||||
|
|
||||||
const updatedTracks = updateElementInTracks({
|
const updatedTracks = updateElementInSceneTracks({
|
||||||
tracks: this.savedState,
|
tracks: this.savedState,
|
||||||
trackId: this.trackId,
|
trackId: this.trackId,
|
||||||
elementId: this.elementId,
|
elementId: this.elementId,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
|
import { isVisualElement, updateElementInSceneTracks } from "@/lib/timeline";
|
||||||
import type { TimelineTrack, VisualElement } from "@/lib/timeline";
|
import type { SceneTracks, VisualElement } from "@/lib/timeline";
|
||||||
|
|
||||||
export function toggleEffectOnElement({
|
export function toggleEffectOnElement({
|
||||||
element,
|
element,
|
||||||
|
|
@ -18,7 +18,7 @@ export function toggleEffectOnElement({
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ToggleClipEffectCommand extends Command {
|
export class ToggleClipEffectCommand extends Command {
|
||||||
private savedState: TimelineTrack[] | null = null;
|
private savedState: SceneTracks | null = null;
|
||||||
private readonly trackId: string;
|
private readonly trackId: string;
|
||||||
private readonly elementId: string;
|
private readonly elementId: string;
|
||||||
private readonly effectId: string;
|
private readonly effectId: string;
|
||||||
|
|
@ -40,9 +40,9 @@ export class ToggleClipEffectCommand extends Command {
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
this.savedState = editor.timeline.getTracks();
|
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||||
|
|
||||||
const updatedTracks = updateElementInTracks({
|
const updatedTracks = updateElementInSceneTracks({
|
||||||
tracks: this.savedState,
|
tracks: this.savedState,
|
||||||
trackId: this.trackId,
|
trackId: this.trackId,
|
||||||
elementId: this.elementId,
|
elementId: this.elementId,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
|
import { isVisualElement, updateElementInSceneTracks } from "@/lib/timeline";
|
||||||
import type { ParamValues } from "@/lib/params";
|
import type { ParamValues } from "@/lib/params";
|
||||||
import type { TimelineTrack, VisualElement } from "@/lib/timeline";
|
import type { SceneTracks, VisualElement } from "@/lib/timeline";
|
||||||
|
|
||||||
function updateEffectParamsOnElement({
|
function updateEffectParamsOnElement({
|
||||||
element,
|
element,
|
||||||
|
|
@ -32,7 +32,7 @@ function updateEffectParamsOnElement({
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateClipEffectParamsCommand extends Command {
|
export class UpdateClipEffectParamsCommand extends Command {
|
||||||
private savedState: TimelineTrack[] | null = null;
|
private savedState: SceneTracks | null = null;
|
||||||
private readonly trackId: string;
|
private readonly trackId: string;
|
||||||
private readonly elementId: string;
|
private readonly elementId: string;
|
||||||
private readonly effectId: string;
|
private readonly effectId: string;
|
||||||
|
|
@ -58,9 +58,9 @@ export class UpdateClipEffectParamsCommand extends Command {
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
this.savedState = editor.timeline.getTracks();
|
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||||
|
|
||||||
const updatedTracks = updateElementInTracks({
|
const updatedTracks = updateElementInSceneTracks({
|
||||||
tracks: this.savedState,
|
tracks: this.savedState,
|
||||||
trackId: this.trackId,
|
trackId: this.trackId,
|
||||||
elementId: this.elementId,
|
elementId: this.elementId,
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
export { InsertElementCommand } from "./insert-element";
|
export { InsertElementCommand } from "./insert-element";
|
||||||
export { DeleteElementsCommand } from "./delete-elements";
|
export { DeleteElementsCommand } from "./delete-elements";
|
||||||
export { DuplicateElementsCommand } from "./duplicate-elements";
|
export { DuplicateElementsCommand } from "./duplicate-elements";
|
||||||
export { SplitElementsCommand } from "./split-elements";
|
export { SplitElementsCommand } from "./split-elements";
|
||||||
export { UpdateElementsCommand } from "./update-elements";
|
export { UpdateElementsCommand } from "./update-elements";
|
||||||
export { ToggleSourceAudioSeparationCommand } from "./toggle-source-audio-separation";
|
export { ToggleSourceAudioSeparationCommand } from "./toggle-source-audio-separation";
|
||||||
export { MoveElementCommand } from "./move-elements";
|
export { MoveElementCommand } from "./move-elements";
|
||||||
|
|
||||||
export * from "./keyframes";
|
export * from "./keyframes";
|
||||||
export * from "./effects";
|
export * from "./effects";
|
||||||
export * from "./masks";
|
export * from "./masks";
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import type {
|
import type {
|
||||||
CreateTimelineElement,
|
CreateTimelineElement,
|
||||||
|
SceneTracks,
|
||||||
TimelineTrack,
|
TimelineTrack,
|
||||||
TimelineElement,
|
TimelineElement,
|
||||||
TrackType,
|
TrackType,
|
||||||
|
|
@ -30,7 +31,7 @@ export interface InsertElementParams {
|
||||||
|
|
||||||
export class InsertElementCommand extends Command {
|
export class InsertElementCommand extends Command {
|
||||||
private elementId: string;
|
private elementId: string;
|
||||||
private savedState: TimelineTrack[] | null = null;
|
private savedState: SceneTracks | null = null;
|
||||||
private targetTrackId: string | null = null;
|
private targetTrackId: string | null = null;
|
||||||
|
|
||||||
constructor({ element, placement }: InsertElementParams) {
|
constructor({ element, placement }: InsertElementParams) {
|
||||||
|
|
@ -45,21 +46,22 @@ export class InsertElementCommand extends Command {
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
this.savedState = editor.timeline.getTracks();
|
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||||
|
|
||||||
if (!this.savedState) {
|
|
||||||
console.error("Tracks not available");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.validateElementBasics({ element: this.element })) {
|
if (!this.validateElementBasics({ element: this.element })) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalElementsInTimeline = this.savedState.reduce(
|
const totalElementsInTimeline =
|
||||||
(total, t) => total + t.elements.length,
|
this.savedState.main.elements.length +
|
||||||
0,
|
this.savedState.overlay.reduce(
|
||||||
);
|
(total, track) => total + track.elements.length,
|
||||||
|
0,
|
||||||
|
) +
|
||||||
|
this.savedState.audio.reduce(
|
||||||
|
(total, track) => total + track.elements.length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
const isFirstElement = totalElementsInTimeline === 0;
|
const isFirstElement = totalElementsInTimeline === 0;
|
||||||
|
|
||||||
const newElement = this.buildElement({ element: this.element });
|
const newElement = this.buildElement({ element: this.element });
|
||||||
|
|
@ -194,9 +196,9 @@ export class InsertElementCommand extends Command {
|
||||||
tracks,
|
tracks,
|
||||||
element,
|
element,
|
||||||
}: {
|
}: {
|
||||||
tracks: TimelineTrack[];
|
tracks: SceneTracks;
|
||||||
element: TimelineElement;
|
element: TimelineElement;
|
||||||
}): { updatedTracks: TimelineTrack[]; targetTrackId: string } | null {
|
}): { updatedTracks: SceneTracks; targetTrackId: string } | null {
|
||||||
const placement = this.placement;
|
const placement = this.placement;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|
@ -231,7 +233,11 @@ export class InsertElementCommand extends Command {
|
||||||
});
|
});
|
||||||
if (!placementResult) {
|
if (!placementResult) {
|
||||||
if (placement.mode === "explicit") {
|
if (placement.mode === "explicit") {
|
||||||
const targetTrack = tracks.find((track) => track.id === placement.trackId);
|
const targetTrack =
|
||||||
|
tracks.main.id === placement.trackId
|
||||||
|
? tracks.main
|
||||||
|
: tracks.overlay.find((track) => track.id === placement.trackId) ??
|
||||||
|
tracks.audio.find((track) => track.id === placement.trackId);
|
||||||
if (!targetTrack) {
|
if (!targetTrack) {
|
||||||
console.error("Track not found:", placement.trackId);
|
console.error("Track not found:", placement.trackId);
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -256,7 +262,7 @@ export class InsertElementCommand extends Command {
|
||||||
}
|
}
|
||||||
: element;
|
: element;
|
||||||
|
|
||||||
return applyPlacement({
|
const appliedPlacement = applyPlacement({
|
||||||
tracks,
|
tracks,
|
||||||
placementResult,
|
placementResult,
|
||||||
elements: [elementToPlace],
|
elements: [elementToPlace],
|
||||||
|
|
@ -265,5 +271,13 @@ export class InsertElementCommand extends Command {
|
||||||
? placement.insertIndex
|
? placement.insertIndex
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
if (!appliedPlacement) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
updatedTracks: appliedPlacement.updatedTracks,
|
||||||
|
targetTrackId: appliedPlacement.targetTrackId,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import { removeEffectParamKeyframe } from "@/lib/animation/effect-param-channel";
|
import { removeEffectParamKeyframe } from "@/lib/animation/effect-param-channel";
|
||||||
import { updateElementInTracks } from "@/lib/timeline";
|
import { updateElementInSceneTracks } from "@/lib/timeline";
|
||||||
import { isVisualElement } from "@/lib/timeline/element-utils";
|
import { isVisualElement } from "@/lib/timeline/element-utils";
|
||||||
import type { TimelineTrack } from "@/lib/timeline";
|
import type { SceneTracks } from "@/lib/timeline";
|
||||||
|
|
||||||
export class RemoveEffectParamKeyframeCommand extends Command {
|
export class RemoveEffectParamKeyframeCommand extends Command {
|
||||||
private savedState: TimelineTrack[] | null = null;
|
private savedState: SceneTracks | null = null;
|
||||||
private readonly trackId: string;
|
private readonly trackId: string;
|
||||||
private readonly elementId: string;
|
private readonly elementId: string;
|
||||||
private readonly effectId: string;
|
private readonly effectId: string;
|
||||||
|
|
@ -36,9 +36,9 @@ export class RemoveEffectParamKeyframeCommand extends Command {
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
this.savedState = editor.timeline.getTracks();
|
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||||
|
|
||||||
const updatedTracks = updateElementInTracks({
|
const updatedTracks = updateElementInSceneTracks({
|
||||||
tracks: this.savedState,
|
tracks: this.savedState,
|
||||||
trackId: this.trackId,
|
trackId: this.trackId,
|
||||||
elementId: this.elementId,
|
elementId: this.elementId,
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,9 @@ import {
|
||||||
resolveAnimationTarget,
|
resolveAnimationTarget,
|
||||||
} from "@/lib/animation";
|
} from "@/lib/animation";
|
||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import { updateElementInTracks } from "@/lib/timeline";
|
import { updateElementInSceneTracks } from "@/lib/timeline";
|
||||||
import type { AnimationPath, AnimationValue } from "@/lib/animation/types";
|
import type { AnimationPath, AnimationValue } from "@/lib/animation/types";
|
||||||
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
|
import type { SceneTracks, TimelineElement } from "@/lib/timeline";
|
||||||
|
|
||||||
function sampleValueBeforeRemoval({
|
function sampleValueBeforeRemoval({
|
||||||
element,
|
element,
|
||||||
|
|
@ -86,7 +86,7 @@ function removeKeyframeAndPersist({
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RemoveKeyframeCommand extends Command {
|
export class RemoveKeyframeCommand extends Command {
|
||||||
private savedState: TimelineTrack[] | null = null;
|
private savedState: SceneTracks | null = null;
|
||||||
private readonly trackId: string;
|
private readonly trackId: string;
|
||||||
private readonly elementId: string;
|
private readonly elementId: string;
|
||||||
private readonly propertyPath: AnimationPath;
|
private readonly propertyPath: AnimationPath;
|
||||||
|
|
@ -112,9 +112,9 @@ export class RemoveKeyframeCommand extends Command {
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
this.savedState = editor.timeline.getTracks();
|
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||||
|
|
||||||
const updatedTracks = updateElementInTracks({
|
const updatedTracks = updateElementInSceneTracks({
|
||||||
tracks: this.savedState,
|
tracks: this.savedState,
|
||||||
trackId: this.trackId,
|
trackId: this.trackId,
|
||||||
elementId: this.elementId,
|
elementId: this.elementId,
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import { resolveAnimationTarget, retimeElementKeyframe } from "@/lib/animation";
|
import { resolveAnimationTarget, retimeElementKeyframe } from "@/lib/animation";
|
||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import { updateElementInTracks } from "@/lib/timeline";
|
import { updateElementInSceneTracks } from "@/lib/timeline";
|
||||||
import type { AnimationPath } from "@/lib/animation/types";
|
import type { AnimationPath } from "@/lib/animation/types";
|
||||||
import type { TimelineTrack } from "@/lib/timeline";
|
import type { SceneTracks } from "@/lib/timeline";
|
||||||
|
|
||||||
export class RetimeKeyframeCommand extends Command {
|
export class RetimeKeyframeCommand extends Command {
|
||||||
private savedState: TimelineTrack[] | null = null;
|
private savedState: SceneTracks | null = null;
|
||||||
private readonly trackId: string;
|
private readonly trackId: string;
|
||||||
private readonly elementId: string;
|
private readonly elementId: string;
|
||||||
private readonly propertyPath: AnimationPath;
|
private readonly propertyPath: AnimationPath;
|
||||||
|
|
@ -36,9 +36,9 @@ export class RetimeKeyframeCommand extends Command {
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
this.savedState = editor.timeline.getTracks();
|
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||||
|
|
||||||
const updatedTracks = updateElementInTracks({
|
const updatedTracks = updateElementInSceneTracks({
|
||||||
tracks: this.savedState,
|
tracks: this.savedState,
|
||||||
trackId: this.trackId,
|
trackId: this.trackId,
|
||||||
elementId: this.elementId,
|
elementId: this.elementId,
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,15 @@ import {
|
||||||
updateScalarKeyframeCurve,
|
updateScalarKeyframeCurve,
|
||||||
} from "@/lib/animation";
|
} from "@/lib/animation";
|
||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import { updateElementInTracks } from "@/lib/timeline";
|
import { updateElementInSceneTracks } from "@/lib/timeline";
|
||||||
import type {
|
import type {
|
||||||
AnimationPath,
|
AnimationPath,
|
||||||
ScalarCurveKeyframePatch,
|
ScalarCurveKeyframePatch,
|
||||||
} from "@/lib/animation/types";
|
} from "@/lib/animation/types";
|
||||||
import type { TimelineTrack } from "@/lib/timeline";
|
import type { SceneTracks } from "@/lib/timeline";
|
||||||
|
|
||||||
export class UpdateScalarKeyframeCurveCommand extends Command {
|
export class UpdateScalarKeyframeCurveCommand extends Command {
|
||||||
private savedState: TimelineTrack[] | null = null;
|
private savedState: SceneTracks | null = null;
|
||||||
private readonly trackId: string;
|
private readonly trackId: string;
|
||||||
private readonly elementId: string;
|
private readonly elementId: string;
|
||||||
private readonly propertyPath: AnimationPath;
|
private readonly propertyPath: AnimationPath;
|
||||||
|
|
@ -46,9 +46,9 @@ export class UpdateScalarKeyframeCurveCommand extends Command {
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
this.savedState = editor.timeline.getTracks();
|
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||||
|
|
||||||
const updatedTracks = updateElementInTracks({
|
const updatedTracks = updateElementInSceneTracks({
|
||||||
tracks: this.savedState,
|
tracks: this.savedState,
|
||||||
trackId: this.trackId,
|
trackId: this.trackId,
|
||||||
elementId: this.elementId,
|
elementId: this.elementId,
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,13 @@ import {
|
||||||
resolveAnimationTarget,
|
resolveAnimationTarget,
|
||||||
upsertPathKeyframe,
|
upsertPathKeyframe,
|
||||||
} from "@/lib/animation";
|
} from "@/lib/animation";
|
||||||
import { updateElementInTracks } from "@/lib/timeline";
|
import { updateElementInSceneTracks } from "@/lib/timeline";
|
||||||
import { isVisualElement } from "@/lib/timeline/element-utils";
|
import { isVisualElement } from "@/lib/timeline/element-utils";
|
||||||
import type { AnimationInterpolation } from "@/lib/animation/types";
|
import type { AnimationInterpolation } from "@/lib/animation/types";
|
||||||
import type { TimelineTrack } from "@/lib/timeline";
|
import type { SceneTracks } from "@/lib/timeline";
|
||||||
|
|
||||||
export class UpsertEffectParamKeyframeCommand extends Command {
|
export class UpsertEffectParamKeyframeCommand extends Command {
|
||||||
private savedState: TimelineTrack[] | null = null;
|
private savedState: SceneTracks | null = null;
|
||||||
private readonly trackId: string;
|
private readonly trackId: string;
|
||||||
private readonly elementId: string;
|
private readonly elementId: string;
|
||||||
private readonly effectId: string;
|
private readonly effectId: string;
|
||||||
|
|
@ -53,9 +53,9 @@ export class UpsertEffectParamKeyframeCommand extends Command {
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
this.savedState = editor.timeline.getTracks();
|
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||||
|
|
||||||
const updatedTracks = updateElementInTracks({
|
const updatedTracks = updateElementInSceneTracks({
|
||||||
tracks: this.savedState,
|
tracks: this.savedState,
|
||||||
trackId: this.trackId,
|
trackId: this.trackId,
|
||||||
elementId: this.elementId,
|
elementId: this.elementId,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import { resolveAnimationTarget, upsertPathKeyframe } from "@/lib/animation";
|
import { resolveAnimationTarget, upsertPathKeyframe } from "@/lib/animation";
|
||||||
import { updateElementInTracks } from "@/lib/timeline";
|
import { updateElementInSceneTracks } from "@/lib/timeline";
|
||||||
import type { TimelineTrack } from "@/lib/timeline";
|
import type { SceneTracks } from "@/lib/timeline";
|
||||||
import type {
|
import type {
|
||||||
AnimationPath,
|
AnimationPath,
|
||||||
AnimationInterpolation,
|
AnimationInterpolation,
|
||||||
|
|
@ -10,7 +10,7 @@ import type {
|
||||||
} from "@/lib/animation/types";
|
} from "@/lib/animation/types";
|
||||||
|
|
||||||
export class UpsertKeyframeCommand extends Command {
|
export class UpsertKeyframeCommand extends Command {
|
||||||
private savedState: TimelineTrack[] | null = null;
|
private savedState: SceneTracks | null = null;
|
||||||
private readonly trackId: string;
|
private readonly trackId: string;
|
||||||
private readonly elementId: string;
|
private readonly elementId: string;
|
||||||
private readonly propertyPath: AnimationPath;
|
private readonly propertyPath: AnimationPath;
|
||||||
|
|
@ -48,9 +48,9 @@ export class UpsertKeyframeCommand extends Command {
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
this.savedState = editor.timeline.getTracks();
|
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||||
|
|
||||||
const updatedTracks = updateElementInTracks({
|
const updatedTracks = updateElementInSceneTracks({
|
||||||
tracks: this.savedState,
|
tracks: this.savedState,
|
||||||
trackId: this.trackId,
|
trackId: this.trackId,
|
||||||
elementId: this.elementId,
|
elementId: this.elementId,
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
export { RemoveMaskCommand } from "./remove-mask";
|
export { RemoveMaskCommand } from "./remove-mask";
|
||||||
export { ToggleMaskInvertedCommand } from "./toggle-mask-inverted";
|
export { ToggleMaskInvertedCommand } from "./toggle-mask-inverted";
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import { isMaskableElement, updateElementInTracks } from "@/lib/timeline";
|
import { isMaskableElement, updateElementInSceneTracks } from "@/lib/timeline";
|
||||||
import type { TimelineTrack, MaskableElement } from "@/lib/timeline";
|
import type { SceneTracks, MaskableElement } from "@/lib/timeline";
|
||||||
|
|
||||||
function removeMaskFromElement({
|
function removeMaskFromElement({
|
||||||
element,
|
element,
|
||||||
|
|
@ -16,7 +16,7 @@ function removeMaskFromElement({
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RemoveMaskCommand extends Command {
|
export class RemoveMaskCommand extends Command {
|
||||||
private savedState: TimelineTrack[] | null = null;
|
private savedState: SceneTracks | null = null;
|
||||||
private readonly trackId: string;
|
private readonly trackId: string;
|
||||||
private readonly elementId: string;
|
private readonly elementId: string;
|
||||||
private readonly maskId: string;
|
private readonly maskId: string;
|
||||||
|
|
@ -38,9 +38,9 @@ export class RemoveMaskCommand extends Command {
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
this.savedState = editor.timeline.getTracks();
|
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||||
|
|
||||||
const updatedTracks = updateElementInTracks({
|
const updatedTracks = updateElementInSceneTracks({
|
||||||
tracks: this.savedState,
|
tracks: this.savedState,
|
||||||
trackId: this.trackId,
|
trackId: this.trackId,
|
||||||
elementId: this.elementId,
|
elementId: this.elementId,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import { isMaskableElement, updateElementInTracks } from "@/lib/timeline";
|
import { isMaskableElement, updateElementInSceneTracks } from "@/lib/timeline";
|
||||||
import type { Mask } from "@/lib/masks/types";
|
import type { Mask } from "@/lib/masks/types";
|
||||||
import type { TimelineTrack, MaskableElement } from "@/lib/timeline";
|
import type { SceneTracks, MaskableElement } from "@/lib/timeline";
|
||||||
|
|
||||||
export function toggleMaskInvertedOnElement({
|
export function toggleMaskInvertedOnElement({
|
||||||
element,
|
element,
|
||||||
|
|
@ -27,7 +27,7 @@ export function toggleMaskInvertedOnElement({
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ToggleMaskInvertedCommand extends Command {
|
export class ToggleMaskInvertedCommand extends Command {
|
||||||
private savedState: TimelineTrack[] | null = null;
|
private savedState: SceneTracks | null = null;
|
||||||
private readonly trackId: string;
|
private readonly trackId: string;
|
||||||
private readonly elementId: string;
|
private readonly elementId: string;
|
||||||
private readonly maskId: string;
|
private readonly maskId: string;
|
||||||
|
|
@ -49,9 +49,9 @@ export class ToggleMaskInvertedCommand extends Command {
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
this.savedState = editor.timeline.getTracks();
|
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||||
|
|
||||||
const updatedTracks = updateElementInTracks({
|
const updatedTracks = updateElementInSceneTracks({
|
||||||
tracks: this.savedState,
|
tracks: this.savedState,
|
||||||
trackId: this.trackId,
|
trackId: this.trackId,
|
||||||
elementId: this.elementId,
|
elementId: this.elementId,
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,23 @@
|
||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import type {
|
import type {
|
||||||
TimelineTrack,
|
SceneTracks,
|
||||||
TimelineElement,
|
TimelineElement,
|
||||||
TrackType,
|
TrackType,
|
||||||
|
TimelineTrack,
|
||||||
} from "@/lib/timeline";
|
} from "@/lib/timeline";
|
||||||
import {
|
import {
|
||||||
buildEmptyTrack,
|
buildEmptyTrack,
|
||||||
validateElementTrackCompatibility,
|
validateElementTrackCompatibility,
|
||||||
enforceMainTrackStart,
|
enforceMainTrackStart,
|
||||||
} from "@/lib/timeline/placement";
|
} from "@/lib/timeline/placement";
|
||||||
|
import {
|
||||||
|
findTrackInSceneTracks,
|
||||||
|
updateTrackInSceneTracks,
|
||||||
|
} from "@/lib/timeline/track-element-update";
|
||||||
|
|
||||||
export class MoveElementCommand extends Command {
|
export class MoveElementCommand extends Command {
|
||||||
private savedState: TimelineTrack[] | null = null;
|
private savedState: SceneTracks | null = null;
|
||||||
private readonly sourceTrackId: string;
|
private readonly sourceTrackId: string;
|
||||||
private readonly targetTrackId: string;
|
private readonly targetTrackId: string;
|
||||||
private readonly elementId: string;
|
private readonly elementId: string;
|
||||||
|
|
@ -42,11 +47,12 @@ export class MoveElementCommand extends Command {
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
this.savedState = editor.timeline.getTracks();
|
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||||
|
|
||||||
const sourceTrack = this.savedState.find(
|
const sourceTrack = findTrackInSceneTracks({
|
||||||
(track) => track.id === this.sourceTrackId,
|
tracks: this.savedState,
|
||||||
);
|
trackId: this.sourceTrackId,
|
||||||
|
});
|
||||||
const element = sourceTrack?.elements.find(
|
const element = sourceTrack?.elements.find(
|
||||||
(trackElement) => trackElement.id === this.elementId,
|
(trackElement) => trackElement.id === this.elementId,
|
||||||
);
|
);
|
||||||
|
|
@ -55,15 +61,21 @@ export class MoveElementCommand extends Command {
|
||||||
throw new Error("Source track or element not found");
|
throw new Error("Source track or element not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
let targetTrack = this.savedState.find((track) => track.id === this.targetTrackId);
|
let targetTrack = findTrackInSceneTracks({
|
||||||
|
tracks: this.savedState,
|
||||||
|
trackId: this.targetTrackId,
|
||||||
|
});
|
||||||
let tracksToUpdate = this.savedState;
|
let tracksToUpdate = this.savedState;
|
||||||
if (!targetTrack && this.createTrack) {
|
if (!targetTrack && this.createTrack) {
|
||||||
const newTrack = buildEmptyTrack({
|
const newTrack = buildEmptyTrack({
|
||||||
id: this.targetTrackId,
|
id: this.targetTrackId,
|
||||||
type: this.createTrack.type,
|
type: this.createTrack.type,
|
||||||
});
|
});
|
||||||
tracksToUpdate = [...this.savedState];
|
tracksToUpdate = insertTrackAtDisplayIndex({
|
||||||
tracksToUpdate.splice(this.createTrack.index, 0, newTrack);
|
tracks: this.savedState,
|
||||||
|
track: newTrack,
|
||||||
|
insertIndex: this.createTrack.index,
|
||||||
|
});
|
||||||
targetTrack = newTrack;
|
targetTrack = newTrack;
|
||||||
}
|
}
|
||||||
if (!targetTrack) {
|
if (!targetTrack) {
|
||||||
|
|
@ -94,32 +106,34 @@ export class MoveElementCommand extends Command {
|
||||||
|
|
||||||
const isSameTrack = this.sourceTrackId === this.targetTrackId;
|
const isSameTrack = this.sourceTrackId === this.targetTrackId;
|
||||||
|
|
||||||
const updatedTracks = tracksToUpdate.map((track): TimelineTrack => {
|
const updatedTracks = isSameTrack
|
||||||
if (isSameTrack && track.id === this.sourceTrackId) {
|
? updateTrackInSceneTracks({
|
||||||
return {
|
tracks: tracksToUpdate,
|
||||||
...track,
|
trackId: this.sourceTrackId,
|
||||||
elements: track.elements.map((trackElement) =>
|
update: (track) => ({
|
||||||
trackElement.id === this.elementId ? movedElement : trackElement,
|
...track,
|
||||||
),
|
elements: track.elements.map((trackElement) =>
|
||||||
} as typeof track;
|
trackElement.id === this.elementId ? movedElement : trackElement,
|
||||||
}
|
),
|
||||||
|
}),
|
||||||
if (track.id === this.sourceTrackId) {
|
})
|
||||||
const remainingElements = track.elements.filter(
|
: updateTrackInSceneTracks({
|
||||||
(trackElement) => trackElement.id !== this.elementId,
|
tracks: updateTrackInSceneTracks({
|
||||||
);
|
tracks: tracksToUpdate,
|
||||||
return { ...track, elements: remainingElements } as typeof track;
|
trackId: this.sourceTrackId,
|
||||||
}
|
update: (track) => ({
|
||||||
|
...track,
|
||||||
if (track.id === this.targetTrackId) {
|
elements: track.elements.filter(
|
||||||
return {
|
(trackElement) => trackElement.id !== this.elementId,
|
||||||
...track,
|
),
|
||||||
elements: [...track.elements, movedElement],
|
}),
|
||||||
} as typeof track;
|
}),
|
||||||
}
|
trackId: this.targetTrackId,
|
||||||
|
update: (track) => ({
|
||||||
return track;
|
...track,
|
||||||
});
|
elements: [...track.elements, movedElement],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
editor.timeline.updateTracks(updatedTracks);
|
editor.timeline.updateTracks(updatedTracks);
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
@ -132,3 +146,41 @@ export class MoveElementCommand extends Command {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function insertTrackAtDisplayIndex({
|
||||||
|
tracks,
|
||||||
|
track,
|
||||||
|
insertIndex,
|
||||||
|
}: {
|
||||||
|
tracks: SceneTracks;
|
||||||
|
track: TimelineTrack;
|
||||||
|
insertIndex: number;
|
||||||
|
}): SceneTracks {
|
||||||
|
if (track.type === "audio") {
|
||||||
|
const audioInsertIndex = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(insertIndex - tracks.overlay.length - 1, tracks.audio.length),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...tracks,
|
||||||
|
audio: [
|
||||||
|
...tracks.audio.slice(0, audioInsertIndex),
|
||||||
|
track,
|
||||||
|
...tracks.audio.slice(audioInsertIndex),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlayInsertIndex = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(insertIndex, tracks.overlay.length),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...tracks,
|
||||||
|
overlay: [
|
||||||
|
...tracks.overlay.slice(0, overlayInsertIndex),
|
||||||
|
track,
|
||||||
|
...tracks.overlay.slice(overlayInsertIndex),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import type { TimelineTrack } from "@/lib/timeline";
|
import type { SceneTracks, TimelineElement } from "@/lib/timeline";
|
||||||
import { generateUUID } from "@/utils/id";
|
import { generateUUID } from "@/utils/id";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import { isRetimableElement } from "@/lib/timeline";
|
import { isRetimableElement } from "@/lib/timeline";
|
||||||
|
|
@ -7,7 +7,7 @@ import { splitAnimationsAtTime } from "@/lib/animation";
|
||||||
import { getSourceSpanAtClipTime } from "@/lib/retime";
|
import { getSourceSpanAtClipTime } from "@/lib/retime";
|
||||||
|
|
||||||
export class SplitElementsCommand extends Command {
|
export class SplitElementsCommand extends Command {
|
||||||
private savedState: TimelineTrack[] | null = null;
|
private savedState: SceneTracks | null = null;
|
||||||
private rightSideElements: { trackId: string; elementId: string }[] = [];
|
private rightSideElements: { trackId: string; elementId: string }[] = [];
|
||||||
private readonly elements: { trackId: string; elementId: string }[];
|
private readonly elements: { trackId: string; elementId: string }[];
|
||||||
private readonly splitTime: number;
|
private readonly splitTime: number;
|
||||||
|
|
@ -34,10 +34,12 @@ export class SplitElementsCommand extends Command {
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
this.savedState = editor.timeline.getTracks();
|
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||||
this.rightSideElements = [];
|
this.rightSideElements = [];
|
||||||
|
|
||||||
const updatedTracks = this.savedState.map((track) => {
|
const splitTrack = <TTrack extends { id: string; elements: TimelineElement[] }>(
|
||||||
|
track: TTrack,
|
||||||
|
): TTrack => {
|
||||||
const elementsToSplit = this.elements.filter(
|
const elementsToSplit = this.elements.filter(
|
||||||
(target) => target.trackId === track.id,
|
(target) => target.trackId === track.id,
|
||||||
);
|
);
|
||||||
|
|
@ -148,8 +150,14 @@ export class SplitElementsCommand extends Command {
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
return { ...track, elements } as typeof track;
|
return { ...track, elements } as TTrack;
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const updatedTracks: SceneTracks = {
|
||||||
|
overlay: this.savedState.overlay.map((track) => splitTrack(track)),
|
||||||
|
main: splitTrack(this.savedState.main),
|
||||||
|
audio: this.savedState.audio.map((track) => splitTrack(track)),
|
||||||
|
};
|
||||||
|
|
||||||
editor.timeline.updateTracks(updatedTracks);
|
editor.timeline.updateTracks(updatedTracks);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,20 @@ import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import {
|
import {
|
||||||
buildSeparatedAudioElement,
|
buildSeparatedAudioElement,
|
||||||
canExtractSourceAudio,
|
canExtractSourceAudio,
|
||||||
canRecoverSourceAudio,
|
isSourceAudioSeparated,
|
||||||
} from "@/lib/timeline/audio-separation";
|
} from "@/lib/timeline/audio-separation";
|
||||||
import {
|
import { buildEmptyTrack } from "@/lib/timeline/placement";
|
||||||
applyPlacement,
|
import { updateElementInSceneTracks } from "@/lib/timeline/track-element-update";
|
||||||
resolveTrackPlacement,
|
import type {
|
||||||
} from "@/lib/timeline/placement";
|
AudioTrack,
|
||||||
import { updateElementInTracks } from "@/lib/timeline/track-element-update";
|
SceneTracks,
|
||||||
import type { TimelineTrack, VideoElement } from "@/lib/timeline/types";
|
TimelineElement,
|
||||||
|
VideoElement,
|
||||||
|
} from "@/lib/timeline/types";
|
||||||
import { generateUUID } from "@/utils/id";
|
import { generateUUID } from "@/utils/id";
|
||||||
|
|
||||||
export class ToggleSourceAudioSeparationCommand extends Command {
|
export class ToggleSourceAudioSeparationCommand extends Command {
|
||||||
private savedState: TimelineTrack[] | null = null;
|
private savedState: SceneTracks | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly params: {
|
private readonly params: {
|
||||||
|
|
@ -27,27 +29,27 @@ export class ToggleSourceAudioSeparationCommand extends Command {
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
this.savedState = editor.timeline.getTracks();
|
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||||
|
|
||||||
const trackIndex = this.savedState.findIndex(
|
const sourceTrack = [
|
||||||
|
...this.savedState.overlay,
|
||||||
|
this.savedState.main,
|
||||||
|
...this.savedState.audio,
|
||||||
|
].find(
|
||||||
(track) => track.id === this.params.trackId,
|
(track) => track.id === this.params.trackId,
|
||||||
);
|
);
|
||||||
if (trackIndex < 0) {
|
if (!sourceTrack) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceTrack = this.savedState[trackIndex];
|
|
||||||
const sourceElement = sourceTrack.elements.find(
|
const sourceElement = sourceTrack.elements.find(
|
||||||
(element) => element.id === this.params.elementId,
|
(element) => element.id === this.params.elementId,
|
||||||
);
|
) as TimelineElement | undefined;
|
||||||
if (!sourceElement) {
|
if (!sourceElement || sourceElement.type !== "video") {
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (sourceElement.type !== "video") {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const videoElement: VideoElement = sourceElement;
|
||||||
|
|
||||||
if (canRecoverSourceAudio(sourceElement)) {
|
if (isSourceAudioSeparated({ element: videoElement })) {
|
||||||
editor.timeline.updateTracks(
|
editor.timeline.updateTracks(
|
||||||
updateSourceAudioEnabled({
|
updateSourceAudioEnabled({
|
||||||
tracks: this.savedState,
|
tracks: this.savedState,
|
||||||
|
|
@ -62,47 +64,34 @@ export class ToggleSourceAudioSeparationCommand extends Command {
|
||||||
const mediaAsset = editor
|
const mediaAsset = editor
|
||||||
.media
|
.media
|
||||||
.getAssets()
|
.getAssets()
|
||||||
.find((asset) => asset.id === sourceElement.mediaId);
|
.find((asset) => asset.id === videoElement.mediaId);
|
||||||
if (!canExtractSourceAudio(sourceElement, mediaAsset)) {
|
if (!canExtractSourceAudio(videoElement, mediaAsset)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (sourceElement.duration <= 0) {
|
if (videoElement.duration <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const separatedAudioElement = {
|
const separatedAudioElement = {
|
||||||
...buildSeparatedAudioElement({
|
...buildSeparatedAudioElement({
|
||||||
sourceElement,
|
sourceElement: videoElement,
|
||||||
}),
|
}),
|
||||||
id: generateUUID(),
|
id: generateUUID(),
|
||||||
};
|
};
|
||||||
const placementResult = resolveTrackPlacement({
|
const newAudioTrack = {
|
||||||
tracks: this.savedState,
|
...buildEmptyTrack({
|
||||||
trackType: "audio",
|
id: generateUUID(),
|
||||||
timeSpans: [
|
type: "audio",
|
||||||
{
|
}),
|
||||||
startTime: separatedAudioElement.startTime,
|
|
||||||
duration: separatedAudioElement.duration,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
strategy: { type: "aboveSource", sourceTrackIndex: trackIndex },
|
|
||||||
});
|
|
||||||
if (!placementResult) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const appliedPlacement = applyPlacement({
|
|
||||||
tracks: this.savedState,
|
|
||||||
placementResult,
|
|
||||||
elements: [separatedAudioElement],
|
elements: [separatedAudioElement],
|
||||||
});
|
} as AudioTrack;
|
||||||
if (!appliedPlacement) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
editor.timeline.updateTracks(
|
editor.timeline.updateTracks(
|
||||||
updateSourceAudioEnabled({
|
updateSourceAudioEnabled({
|
||||||
tracks: appliedPlacement.updatedTracks,
|
tracks: {
|
||||||
|
...this.savedState,
|
||||||
|
audio: [...this.savedState.audio, newAudioTrack],
|
||||||
|
},
|
||||||
trackId: this.params.trackId,
|
trackId: this.params.trackId,
|
||||||
elementId: this.params.elementId,
|
elementId: this.params.elementId,
|
||||||
isSourceAudioEnabled: false,
|
isSourceAudioEnabled: false,
|
||||||
|
|
@ -126,12 +115,12 @@ function updateSourceAudioEnabled({
|
||||||
elementId,
|
elementId,
|
||||||
isSourceAudioEnabled,
|
isSourceAudioEnabled,
|
||||||
}: {
|
}: {
|
||||||
tracks: TimelineTrack[];
|
tracks: SceneTracks;
|
||||||
trackId: string;
|
trackId: string;
|
||||||
elementId: string;
|
elementId: string;
|
||||||
isSourceAudioEnabled: boolean;
|
isSourceAudioEnabled: boolean;
|
||||||
}): TimelineTrack[] {
|
}): SceneTracks {
|
||||||
return updateElementInTracks({
|
return updateElementInSceneTracks({
|
||||||
tracks,
|
tracks,
|
||||||
trackId,
|
trackId,
|
||||||
elementId,
|
elementId,
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
|
import type { SceneTracks, TimelineElement } from "@/lib/timeline";
|
||||||
import { updateElementInTracks } from "@/lib/timeline";
|
import {
|
||||||
|
findTrackInSceneTracks,
|
||||||
|
updateElementInSceneTracks,
|
||||||
|
} from "@/lib/timeline";
|
||||||
import { applyElementUpdate } from "@/lib/timeline/update-pipeline";
|
import { applyElementUpdate } from "@/lib/timeline/update-pipeline";
|
||||||
|
|
||||||
export class UpdateElementsCommand extends Command {
|
export class UpdateElementsCommand extends Command {
|
||||||
private savedState: TimelineTrack[] | null = null;
|
private savedState: SceneTracks | null = null;
|
||||||
private readonly updates: Array<{
|
private readonly updates: Array<{
|
||||||
trackId: string;
|
trackId: string;
|
||||||
elementId: string;
|
elementId: string;
|
||||||
|
|
@ -27,13 +30,14 @@ export class UpdateElementsCommand extends Command {
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
this.savedState = editor.timeline.getTracks();
|
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||||
let updatedTracks = this.savedState;
|
let updatedTracks = this.savedState;
|
||||||
|
|
||||||
for (const updateEntry of this.updates) {
|
for (const updateEntry of this.updates) {
|
||||||
const currentTrack = updatedTracks.find(
|
const currentTrack = findTrackInSceneTracks({
|
||||||
(track) => track.id === updateEntry.trackId,
|
tracks: updatedTracks,
|
||||||
);
|
trackId: updateEntry.trackId,
|
||||||
|
});
|
||||||
const currentElement = currentTrack?.elements.find(
|
const currentElement = currentTrack?.elements.find(
|
||||||
(element) => element.id === updateEntry.elementId,
|
(element) => element.id === updateEntry.elementId,
|
||||||
);
|
);
|
||||||
|
|
@ -50,7 +54,7 @@ export class UpdateElementsCommand extends Command {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
updatedTracks = updateElementInTracks({
|
updatedTracks = updateElementInSceneTracks({
|
||||||
tracks: updatedTracks,
|
tracks: updatedTracks,
|
||||||
trackId: updateEntry.trackId,
|
trackId: updateEntry.trackId,
|
||||||
elementId: updateEntry.elementId,
|
elementId: updateEntry.elementId,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
export * from "./track";
|
export * from "./track";
|
||||||
export * from "./element";
|
export * from "./element";
|
||||||
export * from "./clipboard";
|
export * from "./clipboard";
|
||||||
|
|
||||||
export { TracksSnapshotCommand } from "./tracks-snapshot";
|
export { TracksSnapshotCommand } from "./tracks-snapshot";
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import type { TrackType, TimelineTrack } from "@/lib/timeline";
|
import type { SceneTracks, TrackType } from "@/lib/timeline";
|
||||||
import { generateUUID } from "@/utils/id";
|
import { generateUUID } from "@/utils/id";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import {
|
import {
|
||||||
|
|
@ -9,7 +9,7 @@ import {
|
||||||
|
|
||||||
export class AddTrackCommand extends Command {
|
export class AddTrackCommand extends Command {
|
||||||
private trackId: string;
|
private trackId: string;
|
||||||
private savedState: TimelineTrack[] | null = null;
|
private savedState: SceneTracks | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private type: TrackType,
|
private type: TrackType,
|
||||||
|
|
@ -21,21 +21,28 @@ export class AddTrackCommand extends Command {
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
this.savedState = editor.timeline.getTracks();
|
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||||
|
|
||||||
const newTrack: TimelineTrack = buildEmptyTrack({
|
|
||||||
id: this.trackId,
|
|
||||||
type: this.type,
|
|
||||||
});
|
|
||||||
|
|
||||||
const updatedTracks = [...(this.savedState || [])];
|
|
||||||
const insertIndex =
|
const insertIndex =
|
||||||
this.index ??
|
this.index ??
|
||||||
getDefaultInsertIndexForTrack({
|
getDefaultInsertIndexForTrack({
|
||||||
tracks: updatedTracks,
|
tracks: this.savedState,
|
||||||
trackType: this.type,
|
trackType: this.type,
|
||||||
});
|
});
|
||||||
updatedTracks.splice(insertIndex, 0, newTrack);
|
|
||||||
|
const updatedTracks =
|
||||||
|
this.type === "audio"
|
||||||
|
? buildAudioTrackState({
|
||||||
|
tracks: this.savedState,
|
||||||
|
insertIndex,
|
||||||
|
trackId: this.trackId,
|
||||||
|
})
|
||||||
|
: buildOverlayTrackState({
|
||||||
|
tracks: this.savedState,
|
||||||
|
insertIndex,
|
||||||
|
trackId: this.trackId,
|
||||||
|
trackType: this.type,
|
||||||
|
});
|
||||||
|
|
||||||
editor.timeline.updateTracks(updatedTracks);
|
editor.timeline.updateTracks(updatedTracks);
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
@ -52,3 +59,60 @@ export class AddTrackCommand extends Command {
|
||||||
return this.trackId;
|
return this.trackId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildAudioTrackState({
|
||||||
|
tracks,
|
||||||
|
insertIndex,
|
||||||
|
trackId,
|
||||||
|
}: {
|
||||||
|
tracks: SceneTracks;
|
||||||
|
insertIndex: number;
|
||||||
|
trackId: string;
|
||||||
|
}): SceneTracks {
|
||||||
|
const audioInsertIndex = Math.max(
|
||||||
|
0,
|
||||||
|
insertIndex - tracks.overlay.length - 1,
|
||||||
|
);
|
||||||
|
const newTrack = buildEmptyTrack({
|
||||||
|
id: trackId,
|
||||||
|
type: "audio",
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...tracks,
|
||||||
|
audio: [
|
||||||
|
...tracks.audio.slice(0, audioInsertIndex),
|
||||||
|
newTrack,
|
||||||
|
...tracks.audio.slice(audioInsertIndex),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOverlayTrackState({
|
||||||
|
tracks,
|
||||||
|
insertIndex,
|
||||||
|
trackId,
|
||||||
|
trackType,
|
||||||
|
}: {
|
||||||
|
tracks: SceneTracks;
|
||||||
|
insertIndex: number;
|
||||||
|
trackId: string;
|
||||||
|
trackType: Exclude<TrackType, "audio">;
|
||||||
|
}): SceneTracks {
|
||||||
|
const overlayInsertIndex = Math.min(insertIndex, tracks.overlay.length);
|
||||||
|
const newTrack =
|
||||||
|
trackType === "video"
|
||||||
|
? buildEmptyTrack({ id: trackId, type: "video" })
|
||||||
|
: trackType === "text"
|
||||||
|
? buildEmptyTrack({ id: trackId, type: "text" })
|
||||||
|
: trackType === "graphic"
|
||||||
|
? buildEmptyTrack({ id: trackId, type: "graphic" })
|
||||||
|
: buildEmptyTrack({ id: trackId, type: "effect" });
|
||||||
|
return {
|
||||||
|
...tracks,
|
||||||
|
overlay: [
|
||||||
|
...tracks.overlay.slice(0, overlayInsertIndex),
|
||||||
|
newTrack,
|
||||||
|
...tracks.overlay.slice(overlayInsertIndex),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,30 @@
|
||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import type { TimelineTrack } from "@/lib/timeline";
|
import type { SceneTracks } from "@/lib/timeline";
|
||||||
import { getMainTrack } from "@/lib/timeline/placement";
|
|
||||||
|
export class RemoveTrackCommand extends Command {
|
||||||
export class RemoveTrackCommand extends Command {
|
private savedState: SceneTracks | null = null;
|
||||||
private savedState: TimelineTrack[] | null = null;
|
|
||||||
|
constructor(private trackId: string) {
|
||||||
constructor(private trackId: string) {
|
super();
|
||||||
super();
|
}
|
||||||
}
|
|
||||||
|
execute(): CommandResult | undefined {
|
||||||
execute(): CommandResult | undefined {
|
const editor = EditorCore.getInstance();
|
||||||
const editor = EditorCore.getInstance();
|
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||||
this.savedState = editor.timeline.getTracks();
|
const updatedTracks: SceneTracks = {
|
||||||
const targetTrack = this.savedState.find(
|
...this.savedState,
|
||||||
(track) => track.id === this.trackId,
|
overlay: this.savedState.overlay.filter((track) => track.id !== this.trackId),
|
||||||
);
|
audio: this.savedState.audio.filter((track) => track.id !== this.trackId),
|
||||||
const mainTrack = getMainTrack({ tracks: this.savedState });
|
};
|
||||||
if (mainTrack?.id === targetTrack?.id) {
|
editor.timeline.updateTracks(updatedTracks);
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
const updatedTracks = this.savedState.filter(
|
|
||||||
(track) => track.id !== this.trackId,
|
undo(): void {
|
||||||
);
|
if (this.savedState) {
|
||||||
editor.timeline.updateTracks(updatedTracks);
|
const editor = EditorCore.getInstance();
|
||||||
}
|
editor.timeline.updateTracks(this.savedState);
|
||||||
|
}
|
||||||
undo(): void {
|
}
|
||||||
if (this.savedState) {
|
}
|
||||||
const editor = EditorCore.getInstance();
|
|
||||||
editor.timeline.updateTracks(this.savedState);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,41 @@
|
||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import type { TimelineTrack } from "@/lib/timeline";
|
import type { SceneTracks } from "@/lib/timeline";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import { canTrackHaveAudio } from "@/lib/timeline";
|
import { canTrackHaveAudio, findTrackInSceneTracks, updateTrackInSceneTracks } from "@/lib/timeline";
|
||||||
|
|
||||||
export class ToggleTrackMuteCommand extends Command {
|
export class ToggleTrackMuteCommand extends Command {
|
||||||
private savedState: TimelineTrack[] | null = null;
|
private savedState: SceneTracks | null = null;
|
||||||
|
|
||||||
constructor(private trackId: string) {
|
constructor(private trackId: string) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
this.savedState = editor.timeline.getTracks();
|
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||||
|
|
||||||
const targetTrack = this.savedState.find(
|
const targetTrack = findTrackInSceneTracks({
|
||||||
(track) => track.id === this.trackId,
|
tracks: this.savedState,
|
||||||
);
|
trackId: this.trackId,
|
||||||
if (!targetTrack) {
|
});
|
||||||
return;
|
if (!targetTrack) {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
const updatedTracks = this.savedState.map((track) =>
|
|
||||||
track.id === this.trackId && canTrackHaveAudio(track)
|
const updatedTracks = updateTrackInSceneTracks({
|
||||||
? { ...track, muted: !track.muted }
|
tracks: this.savedState,
|
||||||
: track,
|
trackId: this.trackId,
|
||||||
);
|
update: (track) =>
|
||||||
|
canTrackHaveAudio(track) ? { ...track, muted: !track.muted } : track,
|
||||||
editor.timeline.updateTracks(updatedTracks);
|
});
|
||||||
}
|
|
||||||
|
editor.timeline.updateTracks(updatedTracks);
|
||||||
undo(): void {
|
}
|
||||||
if (this.savedState) {
|
|
||||||
const editor = EditorCore.getInstance();
|
undo(): void {
|
||||||
editor.timeline.updateTracks(this.savedState);
|
if (this.savedState) {
|
||||||
}
|
const editor = EditorCore.getInstance();
|
||||||
}
|
editor.timeline.updateTracks(this.savedState);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,45 @@
|
||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import type { TimelineTrack } from "@/lib/timeline";
|
import type { SceneTracks } from "@/lib/timeline";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import { canTrackBeHidden } from "@/lib/timeline";
|
import { canTrackBeHidden, findTrackInSceneTracks, updateTrackInSceneTracks } from "@/lib/timeline";
|
||||||
|
|
||||||
export class ToggleTrackVisibilityCommand extends Command {
|
export class ToggleTrackVisibilityCommand extends Command {
|
||||||
private savedState: TimelineTrack[] | null = null;
|
private savedState: SceneTracks | null = null;
|
||||||
|
|
||||||
constructor(private trackId: string) {
|
constructor(private trackId: string) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
this.savedState = editor.timeline.getTracks();
|
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||||
|
|
||||||
const targetTrack = this.savedState.find(
|
const targetTrack = findTrackInSceneTracks({
|
||||||
(track) => track.id === this.trackId,
|
tracks: this.savedState,
|
||||||
);
|
trackId: this.trackId,
|
||||||
if (!targetTrack) {
|
});
|
||||||
return;
|
if (!targetTrack) {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
const updatedTracks = this.savedState.map((track) => {
|
|
||||||
if (track.id === this.trackId && canTrackBeHidden(track)) {
|
const updatedTracks = updateTrackInSceneTracks({
|
||||||
return { ...track, hidden: !track.hidden };
|
tracks: this.savedState,
|
||||||
}
|
trackId: this.trackId,
|
||||||
return track;
|
update: (track) => {
|
||||||
});
|
if (canTrackBeHidden(track)) {
|
||||||
|
return { ...track, hidden: !track.hidden };
|
||||||
editor.timeline.updateTracks(updatedTracks);
|
}
|
||||||
}
|
return track;
|
||||||
|
},
|
||||||
undo(): void {
|
});
|
||||||
if (this.savedState) {
|
|
||||||
const editor = EditorCore.getInstance();
|
editor.timeline.updateTracks(updatedTracks);
|
||||||
editor.timeline.updateTracks(this.savedState);
|
}
|
||||||
}
|
|
||||||
}
|
undo(): void {
|
||||||
}
|
if (this.savedState) {
|
||||||
|
const editor = EditorCore.getInstance();
|
||||||
|
editor.timeline.updateTracks(this.savedState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import type { TimelineTrack } from "@/lib/timeline";
|
import type { SceneTracks } from "@/lib/timeline";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
|
|
||||||
export class TracksSnapshotCommand extends Command {
|
export class TracksSnapshotCommand extends Command {
|
||||||
constructor(
|
constructor(
|
||||||
private before: TimelineTrack[],
|
private before: SceneTracks,
|
||||||
private after: TimelineTrack[],
|
private after: SceneTracks,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import type {
|
||||||
AudioElement,
|
AudioElement,
|
||||||
LibraryAudioElement,
|
LibraryAudioElement,
|
||||||
RetimeConfig,
|
RetimeConfig,
|
||||||
TimelineTrack,
|
SceneTracks,
|
||||||
} from "@/lib/timeline";
|
} from "@/lib/timeline";
|
||||||
import { shouldMaintainPitch } from "@/lib/retime/rate";
|
import { shouldMaintainPitch } from "@/lib/retime/rate";
|
||||||
import type { MediaAsset } from "@/lib/media/types";
|
import type { MediaAsset } from "@/lib/media/types";
|
||||||
|
|
@ -89,16 +89,17 @@ export async function collectAudioElements({
|
||||||
mediaAssets,
|
mediaAssets,
|
||||||
audioContext,
|
audioContext,
|
||||||
}: {
|
}: {
|
||||||
tracks: TimelineTrack[];
|
tracks: SceneTracks;
|
||||||
mediaAssets: MediaAsset[];
|
mediaAssets: MediaAsset[];
|
||||||
audioContext: AudioContext;
|
audioContext: AudioContext;
|
||||||
}): Promise<CollectedAudioElement[]> {
|
}): Promise<CollectedAudioElement[]> {
|
||||||
|
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
|
||||||
const mediaMap = new Map<string, MediaAsset>(
|
const mediaMap = new Map<string, MediaAsset>(
|
||||||
mediaAssets.map((media) => [media.id, media]),
|
mediaAssets.map((media) => [media.id, media]),
|
||||||
);
|
);
|
||||||
const pendingElements: Array<Promise<CollectedAudioElement | null>> = [];
|
const pendingElements: Array<Promise<CollectedAudioElement | null>> = [];
|
||||||
|
|
||||||
for (const track of tracks) {
|
for (const track of orderedTracks) {
|
||||||
if (canTrackHaveAudio(track) && track.muted) continue;
|
if (canTrackHaveAudio(track) && track.muted) continue;
|
||||||
|
|
||||||
for (const element of track.elements) {
|
for (const element of track.elements) {
|
||||||
|
|
@ -445,16 +446,17 @@ export async function collectAudioMixSources({
|
||||||
tracks,
|
tracks,
|
||||||
mediaAssets,
|
mediaAssets,
|
||||||
}: {
|
}: {
|
||||||
tracks: TimelineTrack[];
|
tracks: SceneTracks;
|
||||||
mediaAssets: MediaAsset[];
|
mediaAssets: MediaAsset[];
|
||||||
}): Promise<AudioMixSource[]> {
|
}): Promise<AudioMixSource[]> {
|
||||||
|
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
|
||||||
const audioMixSources: AudioMixSource[] = [];
|
const audioMixSources: AudioMixSource[] = [];
|
||||||
const mediaMap = new Map<string, MediaAsset>(
|
const mediaMap = new Map<string, MediaAsset>(
|
||||||
mediaAssets.map((asset) => [asset.id, asset]),
|
mediaAssets.map((asset) => [asset.id, asset]),
|
||||||
);
|
);
|
||||||
const pendingLibrarySources: Array<Promise<AudioMixSource | null>> = [];
|
const pendingLibrarySources: Array<Promise<AudioMixSource | null>> = [];
|
||||||
|
|
||||||
for (const track of tracks) {
|
for (const track of orderedTracks) {
|
||||||
if (canTrackHaveAudio(track) && track.muted) continue;
|
if (canTrackHaveAudio(track) && track.muted) continue;
|
||||||
|
|
||||||
for (const element of track.elements) {
|
for (const element of track.elements) {
|
||||||
|
|
@ -507,16 +509,17 @@ export async function collectAudioClips({
|
||||||
tracks,
|
tracks,
|
||||||
mediaAssets,
|
mediaAssets,
|
||||||
}: {
|
}: {
|
||||||
tracks: TimelineTrack[];
|
tracks: SceneTracks;
|
||||||
mediaAssets: MediaAsset[];
|
mediaAssets: MediaAsset[];
|
||||||
}): Promise<AudioClipSource[]> {
|
}): Promise<AudioClipSource[]> {
|
||||||
|
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
|
||||||
const clips: AudioClipSource[] = [];
|
const clips: AudioClipSource[] = [];
|
||||||
const mediaMap = new Map<string, MediaAsset>(
|
const mediaMap = new Map<string, MediaAsset>(
|
||||||
mediaAssets.map((asset) => [asset.id, asset]),
|
mediaAssets.map((asset) => [asset.id, asset]),
|
||||||
);
|
);
|
||||||
const pendingLibraryClips: Array<Promise<AudioClipSource | null>> = [];
|
const pendingLibraryClips: Array<Promise<AudioClipSource | null>> = [];
|
||||||
|
|
||||||
for (const track of tracks) {
|
for (const track of orderedTracks) {
|
||||||
const isTrackMuted = canTrackHaveAudio(track) && track.muted;
|
const isTrackMuted = canTrackHaveAudio(track) && track.muted;
|
||||||
|
|
||||||
for (const element of track.elements) {
|
for (const element of track.elements) {
|
||||||
|
|
@ -587,7 +590,7 @@ export async function createTimelineAudioBuffer({
|
||||||
sampleRate = EXPORT_SAMPLE_RATE,
|
sampleRate = EXPORT_SAMPLE_RATE,
|
||||||
audioContext,
|
audioContext,
|
||||||
}: {
|
}: {
|
||||||
tracks: TimelineTrack[];
|
tracks: SceneTracks;
|
||||||
mediaAssets: MediaAsset[];
|
mediaAssets: MediaAsset[];
|
||||||
duration: number;
|
duration: number;
|
||||||
sampleRate?: number;
|
sampleRate?: number;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Input, ALL_FORMATS, BlobSource } from "mediabunny";
|
import { Input, ALL_FORMATS, BlobSource } from "mediabunny";
|
||||||
import { createTimelineAudioBuffer } from "@/lib/media/audio";
|
import { createTimelineAudioBuffer } from "@/lib/media/audio";
|
||||||
import type { TimelineTrack } from "@/lib/timeline";
|
import type { SceneTracks } from "@/lib/timeline";
|
||||||
import type { MediaAsset } from "@/lib/media/types";
|
import type { MediaAsset } from "@/lib/media/types";
|
||||||
|
|
||||||
export async function getVideoInfo({
|
export async function getVideoInfo({
|
||||||
|
|
@ -48,7 +48,7 @@ export const extractTimelineAudio = async ({
|
||||||
totalDuration,
|
totalDuration,
|
||||||
onProgress,
|
onProgress,
|
||||||
}: {
|
}: {
|
||||||
tracks: TimelineTrack[];
|
tracks: SceneTracks;
|
||||||
mediaAssets: MediaAsset[];
|
mediaAssets: MediaAsset[];
|
||||||
totalDuration: number;
|
totalDuration: number;
|
||||||
onProgress?: (progress: number) => void;
|
onProgress?: (progress: number) => void;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import type { TimelineTrack, TimelineElement } from "@/lib/timeline";
|
import type { SceneTracks, TimelineElement } from "@/lib/timeline";
|
||||||
import type { MediaAsset } from "@/lib/media/types";
|
import type { MediaAsset } from "@/lib/media/types";
|
||||||
import { isMainTrack } from "@/lib/timeline/placement";
|
|
||||||
import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/constants/sticker-constants";
|
import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/constants/sticker-constants";
|
||||||
import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/lib/graphics";
|
import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/lib/graphics";
|
||||||
import { measureTextElement } from "@/lib/text/measure-element";
|
import { measureTextElement } from "@/lib/text/measure-element";
|
||||||
|
|
@ -266,18 +265,15 @@ export function getVisibleElementsWithBounds({
|
||||||
canvasSize,
|
canvasSize,
|
||||||
mediaAssets,
|
mediaAssets,
|
||||||
}: {
|
}: {
|
||||||
tracks: TimelineTrack[];
|
tracks: SceneTracks;
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
canvasSize: { width: number; height: number };
|
canvasSize: { width: number; height: number };
|
||||||
mediaAssets: MediaAsset[];
|
mediaAssets: MediaAsset[];
|
||||||
}): ElementWithBounds[] {
|
}): ElementWithBounds[] {
|
||||||
const mediaMap = new Map(mediaAssets.map((m) => [m.id, m]));
|
const mediaMap = new Map(mediaAssets.map((m) => [m.id, m]));
|
||||||
const visibleTracks = tracks.filter(
|
|
||||||
(track) => !("hidden" in track && track.hidden),
|
|
||||||
);
|
|
||||||
const orderedTracks = [
|
const orderedTracks = [
|
||||||
...visibleTracks.filter((track) => !isMainTrack(track)),
|
...tracks.overlay.filter((track) => !("hidden" in track && track.hidden)),
|
||||||
...visibleTracks.filter((track) => isMainTrack(track)),
|
...(!tracks.main.hidden ? [tracks.main] : []),
|
||||||
].reverse();
|
].reverse();
|
||||||
|
|
||||||
const result: ElementWithBounds[] = [];
|
const result: ElementWithBounds[] = [];
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { TimelineTrack } from "@/lib/timeline/types";
|
import type { SceneTracks, TimelineTrack } from "@/lib/timeline/types";
|
||||||
import { rippleShiftElements } from "./shift";
|
import { rippleShiftElements } from "./shift";
|
||||||
|
|
||||||
export interface RippleAdjustment {
|
export interface RippleAdjustment {
|
||||||
|
|
@ -11,9 +11,9 @@ export function applyRippleAdjustments({
|
||||||
tracks,
|
tracks,
|
||||||
adjustments,
|
adjustments,
|
||||||
}: {
|
}: {
|
||||||
tracks: TimelineTrack[];
|
tracks: SceneTracks;
|
||||||
adjustments: RippleAdjustment[];
|
adjustments: RippleAdjustment[];
|
||||||
}): TimelineTrack[] {
|
}): SceneTracks {
|
||||||
if (adjustments.length === 0) {
|
if (adjustments.length === 0) {
|
||||||
return tracks;
|
return tracks;
|
||||||
}
|
}
|
||||||
|
|
@ -25,12 +25,24 @@ export function applyRippleAdjustments({
|
||||||
adjustmentsByTrack.set(adjustment.trackId, trackAdjustments);
|
adjustmentsByTrack.set(adjustment.trackId, trackAdjustments);
|
||||||
}
|
}
|
||||||
|
|
||||||
return tracks.map((track) =>
|
return {
|
||||||
applyTrackRippleAdjustments({
|
overlay: tracks.overlay.map((track) =>
|
||||||
track,
|
applyTrackRippleAdjustments({
|
||||||
adjustments: adjustmentsByTrack.get(track.id) ?? [],
|
track,
|
||||||
|
adjustments: adjustmentsByTrack.get(track.id) ?? [],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
main: applyTrackRippleAdjustments({
|
||||||
|
track: tracks.main,
|
||||||
|
adjustments: adjustmentsByTrack.get(tracks.main.id) ?? [],
|
||||||
}),
|
}),
|
||||||
);
|
audio: tracks.audio.map((track) =>
|
||||||
|
applyTrackRippleAdjustments({
|
||||||
|
track,
|
||||||
|
adjustments: adjustmentsByTrack.get(track.id) ?? [],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyTrackRippleAdjustments<
|
function applyTrackRippleAdjustments<
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
|
import type { SceneTracks, TimelineElement, TimelineTrack } from "@/lib/timeline/types";
|
||||||
import type { TimelineElement, TimelineTrack } from "@/lib/timeline/types";
|
|
||||||
import type { RippleAdjustment } from "./apply";
|
import type { RippleAdjustment } from "./apply";
|
||||||
|
|
||||||
interface Interval {
|
interface Interval {
|
||||||
|
|
@ -15,15 +14,25 @@ export function computeRippleAdjustments({
|
||||||
beforeTracks,
|
beforeTracks,
|
||||||
afterTracks,
|
afterTracks,
|
||||||
}: {
|
}: {
|
||||||
beforeTracks: TimelineTrack[];
|
beforeTracks: SceneTracks;
|
||||||
afterTracks: TimelineTrack[];
|
afterTracks: SceneTracks;
|
||||||
}): RippleAdjustment[] {
|
}): RippleAdjustment[] {
|
||||||
const afterTracksById = new Map(afterTracks.map((track) => [track.id, track]));
|
const beforeTrackList = [
|
||||||
|
...beforeTracks.overlay,
|
||||||
|
beforeTracks.main,
|
||||||
|
...beforeTracks.audio,
|
||||||
|
];
|
||||||
|
const afterTrackList = [
|
||||||
|
...afterTracks.overlay,
|
||||||
|
afterTracks.main,
|
||||||
|
...afterTracks.audio,
|
||||||
|
];
|
||||||
|
const afterTracksById = new Map(afterTrackList.map((track) => [track.id, track]));
|
||||||
const allAfterElementIds = new Set(
|
const allAfterElementIds = new Set(
|
||||||
afterTracks.flatMap((track) => track.elements.map((element) => element.id)),
|
afterTrackList.flatMap((track) => track.elements.map((element) => element.id)),
|
||||||
);
|
);
|
||||||
|
|
||||||
return beforeTracks.flatMap((beforeTrack): RippleAdjustment[] =>
|
return beforeTrackList.flatMap((beforeTrack): RippleAdjustment[] =>
|
||||||
computeTrackRippleAdjustments({
|
computeTrackRippleAdjustments({
|
||||||
trackId: beforeTrack.id,
|
trackId: beforeTrack.id,
|
||||||
beforeElements: beforeTrack.elements,
|
beforeElements: beforeTrack.elements,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { TScene } from "@/lib/timeline";
|
import type { TScene } from "@/lib/timeline";
|
||||||
import { generateUUID } from "@/utils/id";
|
import { generateUUID } from "@/utils/id";
|
||||||
import { calculateTotalDuration } from "@/lib/timeline";
|
import { calculateTotalDuration } from "@/lib/timeline";
|
||||||
import { ensureMainTrack } from "@/lib/timeline/placement";
|
import { MAIN_TRACK_NAME } from "@/lib/timeline/placement/main-track";
|
||||||
|
|
||||||
export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null {
|
export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null {
|
||||||
return scenes.find((scene) => scene.isMain) || null;
|
return scenes.find((scene) => scene.isMain) || null;
|
||||||
|
|
@ -23,12 +23,22 @@ export function buildDefaultScene({
|
||||||
name: string;
|
name: string;
|
||||||
isMain: boolean;
|
isMain: boolean;
|
||||||
}): TScene {
|
}): TScene {
|
||||||
const tracks = ensureMainTrack({ tracks: [] });
|
|
||||||
return {
|
return {
|
||||||
id: generateUUID(),
|
id: generateUUID(),
|
||||||
name,
|
name,
|
||||||
isMain,
|
isMain,
|
||||||
tracks,
|
tracks: {
|
||||||
|
overlay: [],
|
||||||
|
main: {
|
||||||
|
id: generateUUID(),
|
||||||
|
name: MAIN_TRACK_NAME,
|
||||||
|
type: "video",
|
||||||
|
elements: [],
|
||||||
|
muted: false,
|
||||||
|
hidden: false,
|
||||||
|
},
|
||||||
|
audio: [],
|
||||||
|
},
|
||||||
bookmarks: [],
|
bookmarks: [],
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
|
|
@ -81,7 +91,7 @@ export function getProjectDurationFromScenes({
|
||||||
scenes: TScene[];
|
scenes: TScene[];
|
||||||
}): number {
|
}): number {
|
||||||
const mainScene = getMainScene({ scenes }) ?? scenes[0] ?? null;
|
const mainScene = getMainScene({ scenes }) ?? scenes[0] ?? null;
|
||||||
if (!mainScene?.tracks || !Array.isArray(mainScene.tracks)) {
|
if (!mainScene?.tracks) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
type CreateLibraryAudioElement,
|
type CreateLibraryAudioElement,
|
||||||
type TextBackground,
|
type TextBackground,
|
||||||
type TextElement,
|
type TextElement,
|
||||||
|
type SceneTracks,
|
||||||
type TimelineElement,
|
type TimelineElement,
|
||||||
type TimelineTrack,
|
type TimelineTrack,
|
||||||
type AudioElement,
|
type AudioElement,
|
||||||
|
|
@ -382,12 +383,13 @@ export function getElementsAtTime({
|
||||||
tracks,
|
tracks,
|
||||||
time,
|
time,
|
||||||
}: {
|
}: {
|
||||||
tracks: TimelineTrack[];
|
tracks: SceneTracks;
|
||||||
time: number;
|
time: number;
|
||||||
}): { trackId: string; elementId: string }[] {
|
}): { trackId: string; elementId: string }[] {
|
||||||
const result: { trackId: string; elementId: string }[] = [];
|
const result: { trackId: string; elementId: string }[] = [];
|
||||||
|
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
|
||||||
|
|
||||||
for (const track of tracks) {
|
for (const track of orderedTracks) {
|
||||||
for (const element of track.elements) {
|
for (const element of track.elements) {
|
||||||
const elementStart = element.startTime;
|
const elementStart = element.startTime;
|
||||||
const elementEnd = element.startTime + element.duration;
|
const elementEnd = element.startTime + element.duration;
|
||||||
|
|
@ -404,10 +406,10 @@ export function getElementsAtTime({
|
||||||
export function getElementFontFamilies({
|
export function getElementFontFamilies({
|
||||||
tracks,
|
tracks,
|
||||||
}: {
|
}: {
|
||||||
tracks: TimelineTrack[];
|
tracks: SceneTracks;
|
||||||
}): string[] {
|
}): string[] {
|
||||||
const families = new Set<string>();
|
const families = new Set<string>();
|
||||||
for (const track of tracks) {
|
for (const track of [...tracks.overlay, tracks.main, ...tracks.audio]) {
|
||||||
for (const element of track.elements) {
|
for (const element of track.elements) {
|
||||||
if (element.type === "text" && element.fontFamily) {
|
if (element.type === "text" && element.fontFamily) {
|
||||||
families.add(element.fontFamily);
|
families.add(element.fontFamily);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { TimelineTrack } from "./types";
|
import type { SceneTracks } from "./types";
|
||||||
|
|
||||||
export * from "./types";
|
export * from "./types";
|
||||||
export * from "./drag";
|
export * from "./drag";
|
||||||
|
|
@ -13,11 +13,12 @@ export * from "./pixel-utils";
|
||||||
export function calculateTotalDuration({
|
export function calculateTotalDuration({
|
||||||
tracks,
|
tracks,
|
||||||
}: {
|
}: {
|
||||||
tracks: TimelineTrack[];
|
tracks: SceneTracks;
|
||||||
}): number {
|
}): number {
|
||||||
if (tracks.length === 0) return 0;
|
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
|
||||||
|
if (orderedTracks.length === 0) return 0;
|
||||||
|
|
||||||
const trackEndTimes = tracks.map((track) =>
|
const trackEndTimes = orderedTracks.map((track) =>
|
||||||
track.elements.reduce((maxEnd, element) => {
|
track.elements.reduce((maxEnd, element) => {
|
||||||
const elementEnd = element.startTime + element.duration;
|
const elementEnd = element.startTime + element.duration;
|
||||||
return Math.max(maxEnd, elementEnd);
|
return Math.max(maxEnd, elementEnd);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import type {
|
||||||
AudioTrack,
|
AudioTrack,
|
||||||
GraphicElement,
|
GraphicElement,
|
||||||
GraphicTrack,
|
GraphicTrack,
|
||||||
|
OverlayTrack,
|
||||||
|
SceneTracks,
|
||||||
TextElement,
|
TextElement,
|
||||||
TextTrack,
|
TextTrack,
|
||||||
TimelineTrack,
|
TimelineTrack,
|
||||||
|
|
@ -134,53 +136,45 @@ type BuildTrackParams =
|
||||||
id: string;
|
id: string;
|
||||||
type: "audio";
|
type: "audio";
|
||||||
elements?: AudioTrack["elements"];
|
elements?: AudioTrack["elements"];
|
||||||
isMain?: boolean;
|
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
id: string;
|
id: string;
|
||||||
type: "graphic";
|
type: "graphic";
|
||||||
elements?: GraphicTrack["elements"];
|
elements?: GraphicTrack["elements"];
|
||||||
isMain?: boolean;
|
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
id: string;
|
id: string;
|
||||||
type: "text";
|
type: "text";
|
||||||
elements?: TextTrack["elements"];
|
elements?: TextTrack["elements"];
|
||||||
isMain?: boolean;
|
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
id: string;
|
id: string;
|
||||||
type: "video";
|
type: "video";
|
||||||
elements?: VideoTrack["elements"];
|
elements?: VideoTrack["elements"];
|
||||||
isMain?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildTrack(params: {
|
function buildTrack(params: {
|
||||||
id: string;
|
id: string;
|
||||||
type: "audio";
|
type: "audio";
|
||||||
elements?: AudioTrack["elements"];
|
elements?: AudioTrack["elements"];
|
||||||
isMain?: boolean;
|
|
||||||
}): AudioTrack;
|
}): AudioTrack;
|
||||||
function buildTrack(params: {
|
function buildTrack(params: {
|
||||||
id: string;
|
id: string;
|
||||||
type: "graphic";
|
type: "graphic";
|
||||||
elements?: GraphicTrack["elements"];
|
elements?: GraphicTrack["elements"];
|
||||||
isMain?: boolean;
|
|
||||||
}): GraphicTrack;
|
}): GraphicTrack;
|
||||||
function buildTrack(params: {
|
function buildTrack(params: {
|
||||||
id: string;
|
id: string;
|
||||||
type: "text";
|
type: "text";
|
||||||
elements?: TextTrack["elements"];
|
elements?: TextTrack["elements"];
|
||||||
isMain?: boolean;
|
|
||||||
}): TextTrack;
|
}): TextTrack;
|
||||||
function buildTrack(params: {
|
function buildTrack(params: {
|
||||||
id: string;
|
id: string;
|
||||||
type: "video";
|
type: "video";
|
||||||
elements?: VideoTrack["elements"];
|
elements?: VideoTrack["elements"];
|
||||||
isMain?: boolean;
|
|
||||||
}): VideoTrack;
|
}): VideoTrack;
|
||||||
function buildTrack(params: BuildTrackParams): TimelineTrack {
|
function buildTrack(params: BuildTrackParams): TimelineTrack {
|
||||||
const { id, type, isMain = false } = params;
|
const { id, type } = params;
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "audio":
|
case "audio":
|
||||||
|
|
@ -213,7 +207,6 @@ function buildTrack(params: BuildTrackParams): TimelineTrack {
|
||||||
type: "video",
|
type: "video",
|
||||||
name: id,
|
name: id,
|
||||||
elements: params.elements ?? [],
|
elements: params.elements ?? [],
|
||||||
isMain,
|
|
||||||
muted: false,
|
muted: false,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
};
|
};
|
||||||
|
|
@ -234,9 +227,32 @@ function buildTimeSpan({
|
||||||
return { startTime, duration, excludeElementId };
|
return { startTime, duration, excludeElementId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildSceneTracks({
|
||||||
|
overlay = [],
|
||||||
|
main,
|
||||||
|
audio = [],
|
||||||
|
}: {
|
||||||
|
overlay?: Array<OverlayTrack>;
|
||||||
|
main?: VideoTrack;
|
||||||
|
audio?: Array<AudioTrack>;
|
||||||
|
}): SceneTracks {
|
||||||
|
return {
|
||||||
|
overlay,
|
||||||
|
main:
|
||||||
|
main ??
|
||||||
|
buildTrack({
|
||||||
|
id: "video-main",
|
||||||
|
type: "video",
|
||||||
|
}),
|
||||||
|
audio,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("resolveTrackPlacement", () => {
|
describe("resolveTrackPlacement", () => {
|
||||||
test("explicit returns the requested compatible track", () => {
|
test("explicit returns the requested compatible track", () => {
|
||||||
const tracks = [buildTrack({ id: "text-1", type: "text" })];
|
const tracks = buildSceneTracks({
|
||||||
|
overlay: [buildTrack({ id: "text-1", type: "text" })],
|
||||||
|
});
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
resolveTrackPlacement({
|
resolveTrackPlacement({
|
||||||
|
|
@ -254,7 +270,9 @@ describe("resolveTrackPlacement", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("explicit rejects missing and incompatible tracks", () => {
|
test("explicit rejects missing and incompatible tracks", () => {
|
||||||
const tracks = [buildTrack({ id: "video-1", type: "video", isMain: true })];
|
const tracks = buildSceneTracks({
|
||||||
|
main: buildTrack({ id: "video-1", type: "video" }),
|
||||||
|
});
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
resolveTrackPlacement({
|
resolveTrackPlacement({
|
||||||
|
|
@ -276,16 +294,18 @@ describe("resolveTrackPlacement", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("firstAvailable picks the first compatible track without overlap", () => {
|
test("firstAvailable picks the first compatible track without overlap", () => {
|
||||||
const tracks = [
|
const tracks = buildSceneTracks({
|
||||||
buildTrack({
|
overlay: [
|
||||||
id: "text-1",
|
buildTrack({
|
||||||
type: "text",
|
id: "text-1",
|
||||||
elements: [
|
type: "text",
|
||||||
buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }),
|
elements: [
|
||||||
],
|
buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }),
|
||||||
}),
|
],
|
||||||
buildTrack({ id: "text-2", type: "text" }),
|
}),
|
||||||
];
|
buildTrack({ id: "text-2", type: "text" }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
resolveTrackPlacement({
|
resolveTrackPlacement({
|
||||||
|
|
@ -303,21 +323,22 @@ describe("resolveTrackPlacement", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("firstAvailable creates a new track when all compatible tracks are full", () => {
|
test("firstAvailable creates a new track when all compatible tracks are full", () => {
|
||||||
const tracks = [
|
const tracks = buildSceneTracks({
|
||||||
buildTrack({
|
overlay: [
|
||||||
id: "graphic-1",
|
buildTrack({
|
||||||
type: "graphic",
|
id: "graphic-1",
|
||||||
elements: [
|
type: "graphic",
|
||||||
buildElement({ id: "a", type: "graphic", startTime: 0, duration: 5 }),
|
elements: [
|
||||||
],
|
buildElement({ id: "a", type: "graphic", startTime: 0, duration: 5 }),
|
||||||
}),
|
],
|
||||||
buildTrack({
|
}),
|
||||||
|
],
|
||||||
|
main: buildTrack({
|
||||||
id: "video-main",
|
id: "video-main",
|
||||||
type: "video",
|
type: "video",
|
||||||
isMain: true,
|
|
||||||
}),
|
}),
|
||||||
buildTrack({ id: "audio-1", type: "audio" }),
|
audio: [buildTrack({ id: "audio-1", type: "audio" })],
|
||||||
];
|
});
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
resolveTrackPlacement({
|
resolveTrackPlacement({
|
||||||
|
|
@ -335,7 +356,9 @@ describe("resolveTrackPlacement", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("preferIndex uses the preferred track when it fits", () => {
|
test("preferIndex uses the preferred track when it fits", () => {
|
||||||
const tracks = [buildTrack({ id: "audio-1", type: "audio" })];
|
const tracks = buildSceneTracks({
|
||||||
|
audio: [buildTrack({ id: "audio-1", type: "audio" })],
|
||||||
|
});
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
resolveTrackPlacement({
|
resolveTrackPlacement({
|
||||||
|
|
@ -349,18 +372,18 @@ describe("resolveTrackPlacement", () => {
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
kind: "existingTrack",
|
kind: "newTrack",
|
||||||
trackId: "audio-1",
|
|
||||||
trackIndex: 0,
|
|
||||||
trackType: "audio",
|
trackType: "audio",
|
||||||
|
insertIndex: 1,
|
||||||
|
insertPosition: "below",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("preferIndex creates a new overlay track above the main track", () => {
|
test("preferIndex creates a new overlay track above the main track", () => {
|
||||||
const tracks = [
|
const tracks = buildSceneTracks({
|
||||||
buildTrack({ id: "video-main", type: "video", isMain: true }),
|
main: buildTrack({ id: "video-main", type: "video" }),
|
||||||
buildTrack({ id: "audio-1", type: "audio" }),
|
audio: [buildTrack({ id: "audio-1", type: "audio" })],
|
||||||
];
|
});
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
resolveTrackPlacement({
|
resolveTrackPlacement({
|
||||||
|
|
@ -382,11 +405,11 @@ describe("resolveTrackPlacement", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("preferIndex keeps audio tracks below the main track", () => {
|
test("preferIndex keeps audio tracks below the main track", () => {
|
||||||
const tracks = [
|
const tracks = buildSceneTracks({
|
||||||
buildTrack({ id: "text-1", type: "text" }),
|
overlay: [buildTrack({ id: "text-1", type: "text" })],
|
||||||
buildTrack({ id: "video-main", type: "video", isMain: true }),
|
main: buildTrack({ id: "video-main", type: "video" }),
|
||||||
buildTrack({ id: "audio-1", type: "audio" }),
|
audio: [buildTrack({ id: "audio-1", type: "audio" })],
|
||||||
];
|
});
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
resolveTrackPlacement({
|
resolveTrackPlacement({
|
||||||
|
|
@ -409,17 +432,19 @@ describe("resolveTrackPlacement", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("aboveSource tries the track above source, then any compatible track", () => {
|
test("aboveSource tries the track above source, then any compatible track", () => {
|
||||||
const tracks = [
|
const tracks = buildSceneTracks({
|
||||||
buildTrack({ id: "text-top", type: "text" }),
|
overlay: [
|
||||||
buildTrack({
|
buildTrack({ id: "text-top", type: "text" }),
|
||||||
id: "text-middle",
|
buildTrack({
|
||||||
type: "text",
|
id: "text-middle",
|
||||||
elements: [
|
type: "text",
|
||||||
buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }),
|
elements: [
|
||||||
],
|
buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }),
|
||||||
}),
|
],
|
||||||
buildTrack({ id: "text-source", type: "text" }),
|
}),
|
||||||
];
|
buildTrack({ id: "text-source", type: "text" }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
resolveTrackPlacement({
|
resolveTrackPlacement({
|
||||||
|
|
@ -436,23 +461,25 @@ describe("resolveTrackPlacement", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("aboveSource creates a new track near the source when none fit", () => {
|
test("aboveSource creates a new overlay track in the overlay zone when none fit", () => {
|
||||||
const tracks = [
|
const tracks = buildSceneTracks({
|
||||||
buildTrack({
|
overlay: [
|
||||||
id: "text-top",
|
buildTrack({
|
||||||
type: "text",
|
id: "text-top",
|
||||||
elements: [
|
type: "text",
|
||||||
buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }),
|
elements: [
|
||||||
],
|
buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }),
|
||||||
}),
|
],
|
||||||
buildTrack({
|
}),
|
||||||
id: "text-source",
|
buildTrack({
|
||||||
type: "text",
|
id: "text-source",
|
||||||
elements: [
|
type: "text",
|
||||||
buildElement({ id: "b", type: "text", startTime: 0, duration: 5 }),
|
elements: [
|
||||||
],
|
buildElement({ id: "b", type: "text", startTime: 0, duration: 5 }),
|
||||||
}),
|
],
|
||||||
];
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
resolveTrackPlacement({
|
resolveTrackPlacement({
|
||||||
|
|
@ -464,16 +491,16 @@ describe("resolveTrackPlacement", () => {
|
||||||
).toEqual({
|
).toEqual({
|
||||||
kind: "newTrack",
|
kind: "newTrack",
|
||||||
trackType: "text",
|
trackType: "text",
|
||||||
insertIndex: 1,
|
insertIndex: 0,
|
||||||
insertPosition: null,
|
insertPosition: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("alwaysNew honors highest and default insertion rules", () => {
|
test("alwaysNew honors highest and default insertion rules", () => {
|
||||||
const tracks = [
|
const tracks = buildSceneTracks({
|
||||||
buildTrack({ id: "video-main", type: "video", isMain: true }),
|
main: buildTrack({ id: "video-main", type: "video" }),
|
||||||
buildTrack({ id: "audio-1", type: "audio" }),
|
audio: [buildTrack({ id: "audio-1", type: "audio" })],
|
||||||
];
|
});
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
resolveTrackPlacement({
|
resolveTrackPlacement({
|
||||||
|
|
@ -505,16 +532,18 @@ describe("resolveTrackPlacement", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("batch time spans reject tracks when any span overlaps", () => {
|
test("batch time spans reject tracks when any span overlaps", () => {
|
||||||
const tracks = [
|
const tracks = buildSceneTracks({
|
||||||
buildTrack({
|
audio: [
|
||||||
id: "audio-1",
|
buildTrack({
|
||||||
type: "audio",
|
id: "audio-1",
|
||||||
elements: [
|
type: "audio",
|
||||||
buildElement({ id: "a", type: "audio", startTime: 0, duration: 2 }),
|
elements: [
|
||||||
buildElement({ id: "b", type: "audio", startTime: 5, duration: 2 }),
|
buildElement({ id: "a", type: "audio", startTime: 0, duration: 2 }),
|
||||||
],
|
buildElement({ id: "b", type: "audio", startTime: 5, duration: 2 }),
|
||||||
}),
|
],
|
||||||
];
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
resolveTrackPlacement({
|
resolveTrackPlacement({
|
||||||
|
|
@ -534,10 +563,10 @@ describe("resolveTrackPlacement", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("handles empty timelines, single tracks, and track-type derivation", () => {
|
test("handles main-only timelines, single tracks, and track-type derivation", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveTrackPlacement({
|
resolveTrackPlacement({
|
||||||
tracks: [],
|
tracks: buildSceneTracks({}),
|
||||||
elementType: "video",
|
elementType: "video",
|
||||||
timeSpans: [buildTimeSpan({ startTime: 0, duration: 3 })],
|
timeSpans: [buildTimeSpan({ startTime: 0, duration: 3 })],
|
||||||
strategy: {
|
strategy: {
|
||||||
|
|
@ -551,12 +580,14 @@ describe("resolveTrackPlacement", () => {
|
||||||
kind: "newTrack",
|
kind: "newTrack",
|
||||||
trackType: "video",
|
trackType: "video",
|
||||||
insertIndex: 0,
|
insertIndex: 0,
|
||||||
insertPosition: null,
|
insertPosition: "above",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
resolveTrackPlacement({
|
resolveTrackPlacement({
|
||||||
tracks: [buildTrack({ id: "audio-1", type: "audio" })],
|
tracks: buildSceneTracks({
|
||||||
|
audio: [buildTrack({ id: "audio-1", type: "audio" })],
|
||||||
|
}),
|
||||||
elementType: "audio",
|
elementType: "audio",
|
||||||
timeSpans: [],
|
timeSpans: [],
|
||||||
strategy: { type: "alwaysNew", position: "default" },
|
strategy: { type: "alwaysNew", position: "default" },
|
||||||
|
|
@ -564,22 +595,21 @@ describe("resolveTrackPlacement", () => {
|
||||||
).toEqual({
|
).toEqual({
|
||||||
kind: "newTrack",
|
kind: "newTrack",
|
||||||
trackType: "audio",
|
trackType: "audio",
|
||||||
insertIndex: 1,
|
insertIndex: 2,
|
||||||
insertPosition: null,
|
insertPosition: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("existingTrack on main video includes adjustedStartTime when start snaps", () => {
|
test("existingTrack on main video includes adjustedStartTime when start snaps", () => {
|
||||||
const tracks = [
|
const tracks = buildSceneTracks({
|
||||||
buildTrack({
|
main: buildTrack({
|
||||||
id: "video-main",
|
id: "video-main",
|
||||||
type: "video",
|
type: "video",
|
||||||
isMain: true,
|
|
||||||
elements: [
|
elements: [
|
||||||
buildElement({ id: "a", type: "video", startTime: 5, duration: 5 }),
|
buildElement({ id: "a", type: "video", startTime: 5, duration: 5 }),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
];
|
});
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
resolveTrackPlacement({
|
resolveTrackPlacement({
|
||||||
|
|
@ -598,11 +628,11 @@ describe("resolveTrackPlacement", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("preferIndex uses vertical drag direction when hovered track is incompatible", () => {
|
test("preferIndex uses vertical drag direction when hovered track is incompatible", () => {
|
||||||
const tracks = [
|
const tracks = buildSceneTracks({
|
||||||
buildTrack({ id: "text-1", type: "text" }),
|
overlay: [buildTrack({ id: "text-1", type: "text" })],
|
||||||
buildTrack({ id: "video-main", type: "video", isMain: true }),
|
main: buildTrack({ id: "video-main", type: "video" }),
|
||||||
buildTrack({ id: "audio-1", type: "audio" }),
|
audio: [buildTrack({ id: "audio-1", type: "audio" })],
|
||||||
];
|
});
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
resolveTrackPlacement({
|
resolveTrackPlacement({
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,18 @@
|
||||||
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
|
import type {
|
||||||
|
AudioTrack,
|
||||||
|
EffectTrack,
|
||||||
|
GraphicTrack,
|
||||||
|
OverlayTrack,
|
||||||
|
SceneTracks,
|
||||||
|
TextTrack,
|
||||||
|
TimelineElement,
|
||||||
|
TimelineTrack,
|
||||||
|
VideoTrack,
|
||||||
|
} from "@/lib/timeline";
|
||||||
import { generateUUID } from "@/utils/id";
|
import { generateUUID } from "@/utils/id";
|
||||||
import { buildEmptyTrack } from "./track-factory";
|
import { buildEmptyTrack } from "./track-factory";
|
||||||
import type { PlacementResult } from "./types";
|
import type { PlacementResult } from "./types";
|
||||||
|
import { updateTrackInSceneTracks } from "@/lib/timeline/track-element-update";
|
||||||
|
|
||||||
export function applyPlacement({
|
export function applyPlacement({
|
||||||
tracks,
|
tracks,
|
||||||
|
|
@ -9,37 +20,139 @@ export function applyPlacement({
|
||||||
elements,
|
elements,
|
||||||
newTrackInsertIndexOverride,
|
newTrackInsertIndexOverride,
|
||||||
}: {
|
}: {
|
||||||
tracks: TimelineTrack[];
|
tracks: SceneTracks;
|
||||||
placementResult: PlacementResult;
|
placementResult: PlacementResult;
|
||||||
elements: TimelineElement[];
|
elements: TimelineElement[];
|
||||||
newTrackInsertIndexOverride?: number;
|
newTrackInsertIndexOverride?: number;
|
||||||
}): { updatedTracks: TimelineTrack[]; targetTrackId: string } | null {
|
}): { updatedTracks: SceneTracks; targetTrackId: string } | null {
|
||||||
|
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
|
||||||
if (placementResult.kind === "existingTrack") {
|
if (placementResult.kind === "existingTrack") {
|
||||||
const targetTrack = tracks[placementResult.trackIndex];
|
const targetTrack = orderedTracks[placementResult.trackIndex];
|
||||||
if (!targetTrack) {
|
if (!targetTrack) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedTracks = tracks.map((track, trackIndex) =>
|
const updatedTracks = updateTrackInSceneTracks({
|
||||||
trackIndex === placementResult.trackIndex
|
tracks,
|
||||||
? {
|
trackId: targetTrack.id,
|
||||||
...track,
|
update: (track) => ({
|
||||||
elements: [...track.elements, ...elements],
|
...track,
|
||||||
}
|
elements: [...track.elements, ...elements],
|
||||||
: track,
|
}),
|
||||||
) as TimelineTrack[];
|
});
|
||||||
|
|
||||||
return { updatedTracks, targetTrackId: targetTrack.id };
|
return { updatedTracks, targetTrackId: targetTrack.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
const newTrackId = generateUUID();
|
const newTrackId = generateUUID();
|
||||||
const newTrack = {
|
|
||||||
...buildEmptyTrack({ id: newTrackId, type: placementResult.trackType }),
|
|
||||||
elements,
|
|
||||||
} as TimelineTrack;
|
|
||||||
const insertIndex =
|
const insertIndex =
|
||||||
newTrackInsertIndexOverride ?? placementResult.insertIndex;
|
newTrackInsertIndexOverride ?? placementResult.insertIndex;
|
||||||
const updatedTracks = [...tracks];
|
const updatedTracks =
|
||||||
updatedTracks.splice(insertIndex, 0, newTrack);
|
placementResult.trackType === "audio"
|
||||||
|
? {
|
||||||
|
...tracks,
|
||||||
|
audio: insertIntoAudioTracks({
|
||||||
|
tracks,
|
||||||
|
insertIndex,
|
||||||
|
track: buildPlacedAudioTrack({
|
||||||
|
id: newTrackId,
|
||||||
|
elements,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
...tracks,
|
||||||
|
overlay: insertIntoOverlayTracks({
|
||||||
|
tracks,
|
||||||
|
insertIndex,
|
||||||
|
track: buildPlacedOverlayTrack({
|
||||||
|
id: newTrackId,
|
||||||
|
type: placementResult.trackType,
|
||||||
|
elements,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
};
|
||||||
return { updatedTracks, targetTrackId: newTrackId };
|
return { updatedTracks, targetTrackId: newTrackId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function insertIntoOverlayTracks({
|
||||||
|
tracks,
|
||||||
|
insertIndex,
|
||||||
|
track,
|
||||||
|
}: {
|
||||||
|
tracks: SceneTracks;
|
||||||
|
insertIndex: number;
|
||||||
|
track: OverlayTrack;
|
||||||
|
}): OverlayTrack[] {
|
||||||
|
const normalizedInsertIndex = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(insertIndex, tracks.overlay.length),
|
||||||
|
);
|
||||||
|
const nextTracks = [...tracks.overlay];
|
||||||
|
nextTracks.splice(normalizedInsertIndex, 0, track);
|
||||||
|
return nextTracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertIntoAudioTracks({
|
||||||
|
tracks,
|
||||||
|
insertIndex,
|
||||||
|
track,
|
||||||
|
}: {
|
||||||
|
tracks: SceneTracks;
|
||||||
|
insertIndex: number;
|
||||||
|
track: AudioTrack;
|
||||||
|
}): AudioTrack[] {
|
||||||
|
const audioInsertIndex = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(insertIndex - tracks.overlay.length - 1, tracks.audio.length),
|
||||||
|
);
|
||||||
|
const nextTracks = [...tracks.audio];
|
||||||
|
nextTracks.splice(audioInsertIndex, 0, track);
|
||||||
|
return nextTracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPlacedAudioTrack({
|
||||||
|
id,
|
||||||
|
elements,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
elements: TimelineElement[];
|
||||||
|
}): AudioTrack {
|
||||||
|
return {
|
||||||
|
...buildEmptyTrack({ id, type: "audio" }),
|
||||||
|
elements: elements as AudioTrack["elements"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPlacedOverlayTrack({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
elements,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
type: Exclude<OverlayTrack["type"], "audio">;
|
||||||
|
elements: TimelineElement[];
|
||||||
|
}): OverlayTrack {
|
||||||
|
switch (type) {
|
||||||
|
case "video":
|
||||||
|
return {
|
||||||
|
...buildEmptyTrack({ id, type: "video" }),
|
||||||
|
elements: elements as VideoTrack["elements"],
|
||||||
|
};
|
||||||
|
case "text":
|
||||||
|
return {
|
||||||
|
...buildEmptyTrack({ id, type: "text" }),
|
||||||
|
elements: elements as TextTrack["elements"],
|
||||||
|
};
|
||||||
|
case "graphic":
|
||||||
|
return {
|
||||||
|
...buildEmptyTrack({ id, type: "graphic" }),
|
||||||
|
elements: elements as GraphicTrack["elements"],
|
||||||
|
};
|
||||||
|
case "effect":
|
||||||
|
return {
|
||||||
|
...buildEmptyTrack({ id, type: "effect" }),
|
||||||
|
elements: elements as EffectTrack["elements"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,7 @@
|
||||||
export { applyPlacement } from "./apply";
|
export { applyPlacement } from "./apply";
|
||||||
export { canElementGoOnTrack, validateElementTrackCompatibility } from "./compatibility";
|
export { canElementGoOnTrack, validateElementTrackCompatibility } from "./compatibility";
|
||||||
export { getDefaultInsertIndexForTrack, getHighestInsertIndexForTrack } from "./insert-index";
|
export { getDefaultInsertIndexForTrack, getHighestInsertIndexForTrack } from "./insert-index";
|
||||||
export {
|
export { MAIN_TRACK_NAME, enforceMainTrackStart, getEarliestMainTrackElement } from "./main-track";
|
||||||
enforceMainTrackStart,
|
|
||||||
ensureMainTrack,
|
|
||||||
getEarliestMainTrackElement,
|
|
||||||
getMainTrack,
|
|
||||||
isMainTrack,
|
|
||||||
} from "./main-track";
|
|
||||||
export { resolveTrackPlacement } from "./resolve";
|
export { resolveTrackPlacement } from "./resolve";
|
||||||
export { buildEmptyTrack } from "./track-factory";
|
export { buildEmptyTrack } from "./track-factory";
|
||||||
export type {
|
export type {
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,32 @@
|
||||||
import type { TrackType, TimelineTrack } from "@/lib/timeline";
|
import type { SceneTracks, TrackType } from "@/lib/timeline";
|
||||||
import { isMainTrack } from "./main-track";
|
|
||||||
|
|
||||||
export function getDefaultInsertIndexForTrack({
|
export function getDefaultInsertIndexForTrack({
|
||||||
tracks,
|
tracks,
|
||||||
trackType,
|
trackType,
|
||||||
}: {
|
}: {
|
||||||
tracks: TimelineTrack[];
|
tracks: SceneTracks;
|
||||||
trackType: TrackType;
|
trackType: TrackType;
|
||||||
}): number {
|
}): number {
|
||||||
if (trackType === "audio") {
|
if (trackType === "audio") {
|
||||||
return tracks.length;
|
return tracks.overlay.length + 1 + tracks.audio.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trackType === "effect") {
|
if (trackType === "effect") {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track));
|
return tracks.overlay.length;
|
||||||
if (mainTrackIndex >= 0) {
|
|
||||||
return mainTrackIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstAudioTrackIndex = tracks.findIndex((track) => track.type === "audio");
|
|
||||||
if (firstAudioTrackIndex >= 0) {
|
|
||||||
return firstAudioTrackIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
return tracks.length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getHighestInsertIndexForTrack({
|
export function getHighestInsertIndexForTrack({
|
||||||
tracks,
|
tracks,
|
||||||
trackType,
|
trackType,
|
||||||
}: {
|
}: {
|
||||||
tracks: TimelineTrack[];
|
tracks: SceneTracks;
|
||||||
trackType: TrackType;
|
trackType: TrackType;
|
||||||
}): number {
|
}): number {
|
||||||
const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track));
|
|
||||||
if (trackType === "audio") {
|
if (trackType === "audio") {
|
||||||
return mainTrackIndex >= 0 ? mainTrackIndex + 1 : tracks.length;
|
return tracks.overlay.length + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
|
|
@ -50,12 +38,13 @@ export function resolvePreferredNewTrackPlacement({
|
||||||
preferredIndex,
|
preferredIndex,
|
||||||
direction,
|
direction,
|
||||||
}: {
|
}: {
|
||||||
tracks: TimelineTrack[];
|
tracks: SceneTracks;
|
||||||
trackType: TrackType;
|
trackType: TrackType;
|
||||||
preferredIndex: number;
|
preferredIndex: number;
|
||||||
direction: "above" | "below";
|
direction: "above" | "below";
|
||||||
}): { insertIndex: number; insertPosition: "above" | "below" | null } {
|
}): { insertIndex: number; insertPosition: "above" | "below" | null } {
|
||||||
if (tracks.length === 0) {
|
const trackCount = tracks.overlay.length + 1 + tracks.audio.length;
|
||||||
|
if (trackCount === 0) {
|
||||||
return {
|
return {
|
||||||
insertIndex: 0,
|
insertIndex: 0,
|
||||||
insertPosition: trackType === "audio" ? "below" : null,
|
insertPosition: trackType === "audio" ? "below" : null,
|
||||||
|
|
@ -64,9 +53,9 @@ export function resolvePreferredNewTrackPlacement({
|
||||||
|
|
||||||
const safePreferredIndex = Math.min(
|
const safePreferredIndex = Math.min(
|
||||||
Math.max(preferredIndex, 0),
|
Math.max(preferredIndex, 0),
|
||||||
tracks.length - 1,
|
trackCount - 1,
|
||||||
);
|
);
|
||||||
const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track));
|
const mainTrackIndex = tracks.overlay.length;
|
||||||
|
|
||||||
if (trackType === "audio") {
|
if (trackType === "audio") {
|
||||||
if (safePreferredIndex <= mainTrackIndex) {
|
if (safePreferredIndex <= mainTrackIndex) {
|
||||||
|
|
|
||||||
|
|
@ -1,55 +1,14 @@
|
||||||
import type { TimelineElement, TimelineTrack, VideoTrack } from "@/lib/timeline";
|
import type { SceneTracks, TimelineElement, VideoTrack } from "@/lib/timeline";
|
||||||
import { generateUUID } from "@/utils/id";
|
|
||||||
|
|
||||||
const MAIN_TRACK_NAME = "Main Track";
|
export const MAIN_TRACK_NAME = "Main Track";
|
||||||
|
|
||||||
export function isMainTrack(track: TimelineTrack): track is VideoTrack {
|
|
||||||
return track.type === "video" && track.isMain === true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMainTrack({
|
|
||||||
tracks,
|
|
||||||
}: {
|
|
||||||
tracks: TimelineTrack[];
|
|
||||||
}): VideoTrack | null {
|
|
||||||
return tracks.find((track) => isMainTrack(track)) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ensureMainTrack({
|
|
||||||
tracks,
|
|
||||||
}: {
|
|
||||||
tracks: TimelineTrack[];
|
|
||||||
}): TimelineTrack[] {
|
|
||||||
if (tracks.some((track) => isMainTrack(track))) {
|
|
||||||
return tracks;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: generateUUID(),
|
|
||||||
name: MAIN_TRACK_NAME,
|
|
||||||
type: "video",
|
|
||||||
elements: [],
|
|
||||||
muted: false,
|
|
||||||
isMain: true,
|
|
||||||
hidden: false,
|
|
||||||
},
|
|
||||||
...tracks,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getEarliestMainTrackElement({
|
export function getEarliestMainTrackElement({
|
||||||
tracks,
|
mainTrack,
|
||||||
excludeElementId,
|
excludeElementId,
|
||||||
}: {
|
}: {
|
||||||
tracks: TimelineTrack[];
|
mainTrack: VideoTrack;
|
||||||
excludeElementId?: string;
|
excludeElementId?: string;
|
||||||
}): TimelineElement | null {
|
}): TimelineElement | null {
|
||||||
const mainTrack = getMainTrack({ tracks });
|
|
||||||
if (!mainTrack) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const elements = mainTrack.elements.filter((element) => {
|
const elements = mainTrack.elements.filter((element) => {
|
||||||
return !excludeElementId || element.id !== excludeElementId;
|
return !excludeElementId || element.id !== excludeElementId;
|
||||||
});
|
});
|
||||||
|
|
@ -70,18 +29,17 @@ export function enforceMainTrackStart({
|
||||||
requestedStartTime,
|
requestedStartTime,
|
||||||
excludeElementId,
|
excludeElementId,
|
||||||
}: {
|
}: {
|
||||||
tracks: TimelineTrack[];
|
tracks: SceneTracks;
|
||||||
targetTrackId: string;
|
targetTrackId: string;
|
||||||
requestedStartTime: number;
|
requestedStartTime: number;
|
||||||
excludeElementId?: string;
|
excludeElementId?: string;
|
||||||
}): number {
|
}): number {
|
||||||
const mainTrack = getMainTrack({ tracks });
|
if (tracks.main.id !== targetTrackId) {
|
||||||
if (!mainTrack || mainTrack.id !== targetTrackId) {
|
|
||||||
return requestedStartTime;
|
return requestedStartTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
const earliestElement = getEarliestMainTrackElement({
|
const earliestElement = getEarliestMainTrackElement({
|
||||||
tracks,
|
mainTrack: tracks.main,
|
||||||
excludeElementId,
|
excludeElementId,
|
||||||
});
|
});
|
||||||
if (!earliestElement) {
|
if (!earliestElement) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { TrackType, TimelineTrack } from "@/lib/timeline";
|
import type { SceneTracks, TrackType, TimelineTrack } from "@/lib/timeline";
|
||||||
import {
|
import {
|
||||||
getDefaultInsertIndexForTrack,
|
getDefaultInsertIndexForTrack,
|
||||||
getHighestInsertIndexForTrack,
|
getHighestInsertIndexForTrack,
|
||||||
|
|
@ -15,7 +15,7 @@ import type {
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
type ResolveTrackPlacementParams = PlacementSubject & {
|
type ResolveTrackPlacementParams = PlacementSubject & {
|
||||||
tracks: TimelineTrack[];
|
tracks: SceneTracks;
|
||||||
timeSpans: PlacementTimeSpan[];
|
timeSpans: PlacementTimeSpan[];
|
||||||
strategy: PlacementStrategy;
|
strategy: PlacementStrategy;
|
||||||
};
|
};
|
||||||
|
|
@ -28,7 +28,7 @@ function buildExistingTrackResult({
|
||||||
}: {
|
}: {
|
||||||
track: TimelineTrack;
|
track: TimelineTrack;
|
||||||
trackIndex: number;
|
trackIndex: number;
|
||||||
tracks: TimelineTrack[];
|
tracks: SceneTracks;
|
||||||
timeSpans: PlacementTimeSpan[];
|
timeSpans: PlacementTimeSpan[];
|
||||||
}): PlacementResult {
|
}): PlacementResult {
|
||||||
const firstSpan = timeSpans[0];
|
const firstSpan = timeSpans[0];
|
||||||
|
|
@ -90,7 +90,7 @@ function resolveAlwaysNewTrack({
|
||||||
trackType,
|
trackType,
|
||||||
position,
|
position,
|
||||||
}: {
|
}: {
|
||||||
tracks: TimelineTrack[];
|
tracks: SceneTracks;
|
||||||
trackType: TrackType;
|
trackType: TrackType;
|
||||||
position: "highest" | "default";
|
position: "highest" | "default";
|
||||||
}): PlacementResult {
|
}): PlacementResult {
|
||||||
|
|
@ -134,6 +134,7 @@ export function resolveTrackPlacement({
|
||||||
tracks,
|
tracks,
|
||||||
...placement
|
...placement
|
||||||
}: ResolveTrackPlacementParams): PlacementResult | null {
|
}: ResolveTrackPlacementParams): PlacementResult | null {
|
||||||
|
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
|
||||||
const trackType =
|
const trackType =
|
||||||
"trackType" in placement
|
"trackType" in placement
|
||||||
? placement.trackType
|
? placement.trackType
|
||||||
|
|
@ -143,30 +144,35 @@ export function resolveTrackPlacement({
|
||||||
const { timeSpans, strategy } = placement;
|
const { timeSpans, strategy } = placement;
|
||||||
|
|
||||||
if (strategy.type === "explicit") {
|
if (strategy.type === "explicit") {
|
||||||
const trackIndex = tracks.findIndex(
|
const trackIndex = orderedTracks.findIndex(
|
||||||
(track) => track.id === strategy.trackId,
|
(track) => track.id === strategy.trackId,
|
||||||
);
|
);
|
||||||
if (trackIndex < 0) {
|
if (trackIndex < 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const track = tracks[trackIndex];
|
const track = orderedTracks[trackIndex];
|
||||||
if (track.type !== trackType) {
|
if (track.type !== trackType) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildExistingTrackResult({ track, trackIndex, tracks, timeSpans });
|
return buildExistingTrackResult({
|
||||||
|
track,
|
||||||
|
trackIndex,
|
||||||
|
tracks,
|
||||||
|
timeSpans,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (strategy.type === "firstAvailable") {
|
if (strategy.type === "firstAvailable") {
|
||||||
const existingTrackIndex = findFirstAvailableTrackIndex({
|
const existingTrackIndex = findFirstAvailableTrackIndex({
|
||||||
tracks,
|
tracks: orderedTracks,
|
||||||
trackType,
|
trackType,
|
||||||
timeSpans,
|
timeSpans,
|
||||||
});
|
});
|
||||||
if (existingTrackIndex >= 0) {
|
if (existingTrackIndex >= 0) {
|
||||||
return buildExistingTrackResult({
|
return buildExistingTrackResult({
|
||||||
track: tracks[existingTrackIndex],
|
track: orderedTracks[existingTrackIndex],
|
||||||
trackIndex: existingTrackIndex,
|
trackIndex: existingTrackIndex,
|
||||||
tracks,
|
tracks,
|
||||||
timeSpans,
|
timeSpans,
|
||||||
|
|
@ -181,7 +187,7 @@ export function resolveTrackPlacement({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (strategy.type === "preferIndex") {
|
if (strategy.type === "preferIndex") {
|
||||||
const preferredTrack = tracks[strategy.trackIndex];
|
const preferredTrack = orderedTracks[strategy.trackIndex];
|
||||||
const isPreferredTrackCompatible =
|
const isPreferredTrackCompatible =
|
||||||
!!preferredTrack && preferredTrack.type === trackType;
|
!!preferredTrack && preferredTrack.type === trackType;
|
||||||
const canUseExistingTrack =
|
const canUseExistingTrack =
|
||||||
|
|
@ -220,7 +226,7 @@ export function resolveTrackPlacement({
|
||||||
|
|
||||||
if (strategy.type === "aboveSource") {
|
if (strategy.type === "aboveSource") {
|
||||||
const aboveTrackIndex = strategy.sourceTrackIndex - 1;
|
const aboveTrackIndex = strategy.sourceTrackIndex - 1;
|
||||||
const aboveTrack = tracks[aboveTrackIndex];
|
const aboveTrack = orderedTracks[aboveTrackIndex];
|
||||||
if (
|
if (
|
||||||
aboveTrack &&
|
aboveTrack &&
|
||||||
aboveTrack.type === trackType &&
|
aboveTrack.type === trackType &&
|
||||||
|
|
@ -238,26 +244,23 @@ export function resolveTrackPlacement({
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstAvailableTrackIndex = findFirstAvailableTrackIndex({
|
const firstAvailableTrackIndex = findFirstAvailableTrackIndex({
|
||||||
tracks,
|
tracks: orderedTracks,
|
||||||
trackType,
|
trackType,
|
||||||
timeSpans,
|
timeSpans,
|
||||||
});
|
});
|
||||||
if (firstAvailableTrackIndex >= 0) {
|
if (firstAvailableTrackIndex >= 0) {
|
||||||
return buildExistingTrackResult({
|
return buildExistingTrackResult({
|
||||||
track: tracks[firstAvailableTrackIndex],
|
track: orderedTracks[firstAvailableTrackIndex],
|
||||||
trackIndex: firstAvailableTrackIndex,
|
trackIndex: firstAvailableTrackIndex,
|
||||||
tracks,
|
tracks,
|
||||||
timeSpans,
|
timeSpans,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const insertIndex =
|
const insertIndex = getHighestInsertIndexForTrack({
|
||||||
strategy.sourceTrackIndex >= 0
|
tracks,
|
||||||
? strategy.sourceTrackIndex
|
trackType,
|
||||||
: getHighestInsertIndexForTrack({
|
});
|
||||||
tracks,
|
|
||||||
trackType,
|
|
||||||
});
|
|
||||||
|
|
||||||
return buildNewTrackResult({
|
return buildNewTrackResult({
|
||||||
trackType,
|
trackType,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,69 @@
|
||||||
import { DEFAULT_TRACK_NAMES } from "@/lib/timeline/tracks";
|
import { DEFAULT_TRACK_NAMES } from "@/lib/timeline/tracks";
|
||||||
import type { TrackType, TimelineTrack } from "@/lib/timeline";
|
import type {
|
||||||
|
AudioTrack,
|
||||||
|
EffectTrack,
|
||||||
|
GraphicTrack,
|
||||||
|
TextTrack,
|
||||||
|
TrackType,
|
||||||
|
TimelineTrack,
|
||||||
|
VideoTrack,
|
||||||
|
} from "@/lib/timeline";
|
||||||
|
|
||||||
|
export function buildEmptyTrack({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
type: "video";
|
||||||
|
name?: string;
|
||||||
|
}): VideoTrack;
|
||||||
|
export function buildEmptyTrack({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
type: "text";
|
||||||
|
name?: string;
|
||||||
|
}): TextTrack;
|
||||||
|
export function buildEmptyTrack({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
type: "audio";
|
||||||
|
name?: string;
|
||||||
|
}): AudioTrack;
|
||||||
|
export function buildEmptyTrack({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
type: "graphic";
|
||||||
|
name?: string;
|
||||||
|
}): GraphicTrack;
|
||||||
|
export function buildEmptyTrack({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
type: "effect";
|
||||||
|
name?: string;
|
||||||
|
}): EffectTrack;
|
||||||
|
|
||||||
|
export function buildEmptyTrack({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
type: TrackType;
|
||||||
|
name?: string;
|
||||||
|
}): TimelineTrack;
|
||||||
export function buildEmptyTrack({
|
export function buildEmptyTrack({
|
||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
|
|
@ -21,7 +84,6 @@ export function buildEmptyTrack({
|
||||||
elements: [],
|
elements: [],
|
||||||
hidden: false,
|
hidden: false,
|
||||||
muted: false,
|
muted: false,
|
||||||
isMain: false,
|
|
||||||
};
|
};
|
||||||
case "text":
|
case "text":
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { Bookmark, TimelineTrack } from "@/lib/timeline";
|
import type { Bookmark, SceneTracks } from "@/lib/timeline";
|
||||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
||||||
import { getElementKeyframes } from "@/lib/animation";
|
import { getElementKeyframes } from "@/lib/animation";
|
||||||
import { TICKS_PER_SECOND } from "@/lib/wasm";
|
import { TICKS_PER_SECOND } from "@/lib/wasm";
|
||||||
|
|
@ -29,7 +29,7 @@ export function findSnapPoints({
|
||||||
enableBookmarkSnapping = true,
|
enableBookmarkSnapping = true,
|
||||||
enableKeyframeSnapping = true,
|
enableKeyframeSnapping = true,
|
||||||
}: {
|
}: {
|
||||||
tracks: Array<TimelineTrack>;
|
tracks: SceneTracks;
|
||||||
playheadTime: number;
|
playheadTime: number;
|
||||||
excludeElementId?: string;
|
excludeElementId?: string;
|
||||||
bookmarks?: Array<Bookmark>;
|
bookmarks?: Array<Bookmark>;
|
||||||
|
|
@ -40,8 +40,9 @@ export function findSnapPoints({
|
||||||
enableKeyframeSnapping?: boolean;
|
enableKeyframeSnapping?: boolean;
|
||||||
}): SnapPoint[] {
|
}): SnapPoint[] {
|
||||||
const snapPoints: SnapPoint[] = [];
|
const snapPoints: SnapPoint[] = [];
|
||||||
|
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
|
||||||
|
|
||||||
for (const track of tracks) {
|
for (const track of orderedTracks) {
|
||||||
for (const element of track.elements) {
|
for (const element of track.elements) {
|
||||||
if (element.id === excludeElementId) continue;
|
if (element.id === excludeElementId) continue;
|
||||||
|
|
||||||
|
|
@ -140,7 +141,7 @@ export function snapElementEdge({
|
||||||
}: {
|
}: {
|
||||||
targetTime: number;
|
targetTime: number;
|
||||||
elementDuration: number;
|
elementDuration: number;
|
||||||
tracks: Array<TimelineTrack>;
|
tracks: SceneTracks;
|
||||||
playheadTime: number;
|
playheadTime: number;
|
||||||
zoomLevel: number;
|
zoomLevel: number;
|
||||||
excludeElementId?: string;
|
excludeElementId?: string;
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,147 @@
|
||||||
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
|
import type { SceneTracks, TimelineElement, TimelineTrack } from "@/lib/timeline";
|
||||||
|
|
||||||
export function updateElementInTracks({
|
export function findTrackInSceneTracks({
|
||||||
|
tracks,
|
||||||
|
trackId,
|
||||||
|
}: {
|
||||||
|
tracks: SceneTracks;
|
||||||
|
trackId: string;
|
||||||
|
}): TimelineTrack | null {
|
||||||
|
if (tracks.main.id === trackId) {
|
||||||
|
return tracks.main;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
tracks.overlay.find((track) => track.id === trackId) ??
|
||||||
|
tracks.audio.find((track) => track.id === trackId) ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTrackInSceneTracks({
|
||||||
|
tracks,
|
||||||
|
trackId,
|
||||||
|
update,
|
||||||
|
}: {
|
||||||
|
tracks: SceneTracks;
|
||||||
|
trackId: string;
|
||||||
|
update: <TTrack extends TimelineTrack>(track: TTrack) => TTrack;
|
||||||
|
}): SceneTracks {
|
||||||
|
if (tracks.main.id === trackId) {
|
||||||
|
return {
|
||||||
|
...tracks,
|
||||||
|
main: update(tracks.main),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlayTrackIndex = tracks.overlay.findIndex((track) => track.id === trackId);
|
||||||
|
if (overlayTrackIndex >= 0) {
|
||||||
|
return {
|
||||||
|
...tracks,
|
||||||
|
overlay: tracks.overlay.map((track, index) =>
|
||||||
|
index === overlayTrackIndex ? update(track) : track,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioTrackIndex = tracks.audio.findIndex((track) => track.id === trackId);
|
||||||
|
if (audioTrackIndex >= 0) {
|
||||||
|
return {
|
||||||
|
...tracks,
|
||||||
|
audio: tracks.audio.map((track, index) =>
|
||||||
|
index === audioTrackIndex ? update(track) : track,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return tracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateElementInTrack<TTrack extends TimelineTrack>({
|
||||||
|
track,
|
||||||
|
elementId,
|
||||||
|
update,
|
||||||
|
elementPredicate,
|
||||||
|
}: {
|
||||||
|
track: TTrack;
|
||||||
|
elementId: string;
|
||||||
|
update: (element: TimelineElement) => TimelineElement;
|
||||||
|
elementPredicate?: (element: TimelineElement) => boolean;
|
||||||
|
}): TTrack {
|
||||||
|
const nextElements = track.elements.map((element) => {
|
||||||
|
if (element.id !== elementId) {
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
if (elementPredicate && !elementPredicate(element)) {
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
return update(element);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...track,
|
||||||
|
elements: nextElements,
|
||||||
|
} as TTrack;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateElementInSceneTracks({
|
||||||
tracks,
|
tracks,
|
||||||
trackId,
|
trackId,
|
||||||
elementId,
|
elementId,
|
||||||
update,
|
update,
|
||||||
elementPredicate,
|
elementPredicate,
|
||||||
}: {
|
}: {
|
||||||
tracks: TimelineTrack[];
|
tracks: SceneTracks;
|
||||||
trackId: string;
|
trackId: string;
|
||||||
elementId: string;
|
elementId: string;
|
||||||
update: (element: TimelineElement) => TimelineElement;
|
update: (element: TimelineElement) => TimelineElement;
|
||||||
elementPredicate?: (element: TimelineElement) => boolean;
|
elementPredicate?: (element: TimelineElement) => boolean;
|
||||||
}): TimelineTrack[] {
|
}): SceneTracks {
|
||||||
return tracks.map((track) => {
|
if (tracks.main.id === trackId) {
|
||||||
if (track.id !== trackId) {
|
return {
|
||||||
return track;
|
...tracks,
|
||||||
}
|
main: updateElementInTrack({
|
||||||
|
track: tracks.main,
|
||||||
|
elementId,
|
||||||
|
update,
|
||||||
|
elementPredicate,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const nextElements = track.elements.map((element) => {
|
const overlayTrackIndex = tracks.overlay.findIndex((track) => track.id === trackId);
|
||||||
if (element.id !== elementId) {
|
if (overlayTrackIndex >= 0) {
|
||||||
return element;
|
return {
|
||||||
}
|
...tracks,
|
||||||
if (elementPredicate && !elementPredicate(element)) {
|
overlay: tracks.overlay.map((track, index) =>
|
||||||
return element;
|
index === overlayTrackIndex
|
||||||
}
|
? updateElementInTrack({
|
||||||
return update(element);
|
track,
|
||||||
});
|
elementId,
|
||||||
|
update,
|
||||||
|
elementPredicate,
|
||||||
|
})
|
||||||
|
: track,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return { ...track, elements: nextElements } as TimelineTrack;
|
const audioTrackIndex = tracks.audio.findIndex((track) => track.id === trackId);
|
||||||
});
|
if (audioTrackIndex >= 0) {
|
||||||
|
return {
|
||||||
|
...tracks,
|
||||||
|
audio: tracks.audio.map((track, index) =>
|
||||||
|
index === audioTrackIndex
|
||||||
|
? updateElementInTrack({
|
||||||
|
track,
|
||||||
|
elementId,
|
||||||
|
update,
|
||||||
|
elementPredicate,
|
||||||
|
})
|
||||||
|
: track,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return tracks;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ export interface TScene {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
isMain: boolean;
|
isMain: boolean;
|
||||||
tracks: TimelineTrack[];
|
tracks: SceneTracks;
|
||||||
bookmarks: Bookmark[];
|
bookmarks: Bookmark[];
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
|
@ -36,7 +36,6 @@ interface BaseTrack {
|
||||||
export interface VideoTrack extends BaseTrack {
|
export interface VideoTrack extends BaseTrack {
|
||||||
type: "video";
|
type: "video";
|
||||||
elements: (VideoElement | ImageElement)[];
|
elements: (VideoElement | ImageElement)[];
|
||||||
isMain: boolean;
|
|
||||||
muted: boolean;
|
muted: boolean;
|
||||||
hidden: boolean;
|
hidden: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -72,6 +71,14 @@ export type TimelineTrack =
|
||||||
| GraphicTrack
|
| GraphicTrack
|
||||||
| EffectTrack;
|
| EffectTrack;
|
||||||
|
|
||||||
|
export type OverlayTrack = VideoTrack | TextTrack | GraphicTrack | EffectTrack;
|
||||||
|
|
||||||
|
export interface SceneTracks {
|
||||||
|
overlay: OverlayTrack[];
|
||||||
|
main: VideoTrack;
|
||||||
|
audio: AudioTrack[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface RetimeConfig {
|
export interface RetimeConfig {
|
||||||
rate: number;
|
rate: number;
|
||||||
maintainPitch?: boolean;
|
maintainPitch?: boolean;
|
||||||
|
|
@ -286,7 +293,7 @@ export interface ComputeDropTargetParams {
|
||||||
elementType: ElementType;
|
elementType: ElementType;
|
||||||
mouseX: number;
|
mouseX: number;
|
||||||
mouseY: number;
|
mouseY: number;
|
||||||
tracks: TimelineTrack[];
|
tracks: SceneTracks;
|
||||||
playheadTime: number;
|
playheadTime: number;
|
||||||
isExternalDrop: boolean;
|
isExternalDrop: boolean;
|
||||||
elementDuration: number;
|
elementDuration: number;
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,13 @@ import {
|
||||||
getSourceSpanAtClipTime,
|
getSourceSpanAtClipTime,
|
||||||
getTimelineDurationForSourceSpan,
|
getTimelineDurationForSourceSpan,
|
||||||
} from "@/lib/retime";
|
} from "@/lib/retime";
|
||||||
import { enforceMainTrackStart } from "@/lib/timeline/placement";
|
import type { RetimeConfig, SceneTracks, TimelineElement } from "@/lib/timeline";
|
||||||
import type { RetimeConfig, TimelineElement, TimelineTrack } from "@/lib/timeline";
|
|
||||||
import { isRetimableElement } from "@/lib/timeline";
|
import { isRetimableElement } from "@/lib/timeline";
|
||||||
|
|
||||||
type ElementUpdateField = keyof TimelineElement | string;
|
type ElementUpdateField = keyof TimelineElement | string;
|
||||||
|
|
||||||
export interface ElementUpdateContext {
|
export interface ElementUpdateContext {
|
||||||
tracks: TimelineTrack[];
|
tracks: SceneTracks;
|
||||||
trackId: string;
|
trackId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -94,17 +93,36 @@ const enforceRules: ElementUpdateRule[] = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
triggers: ["startTime"],
|
triggers: ["startTime"],
|
||||||
apply: ({ element, context }) => ({
|
apply: ({ element, context }) => {
|
||||||
element: {
|
const requestedStartTime = Math.max(0, element.startTime);
|
||||||
...element,
|
if (context.trackId !== context.tracks.main.id) {
|
||||||
startTime: enforceMainTrackStart({
|
return {
|
||||||
tracks: context.tracks,
|
element: {
|
||||||
targetTrackId: context.trackId,
|
...element,
|
||||||
requestedStartTime: Math.max(0, element.startTime),
|
startTime: requestedStartTime,
|
||||||
excludeElementId: element.id,
|
},
|
||||||
}),
|
};
|
||||||
},
|
}
|
||||||
}),
|
|
||||||
|
const earliestElement = context.tracks.main.elements
|
||||||
|
.filter((candidate) => candidate.id !== element.id)
|
||||||
|
.reduce<TimelineElement | null>((earliest, candidate) => {
|
||||||
|
if (!earliest || candidate.startTime < earliest.startTime) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
return earliest;
|
||||||
|
}, null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
element: {
|
||||||
|
...element,
|
||||||
|
startTime:
|
||||||
|
!earliestElement || requestedStartTime <= earliestElement.startTime
|
||||||
|
? 0
|
||||||
|
: requestedStartTime,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { TimelineTrack } from "@/lib/timeline";
|
import type { SceneTracks, TimelineTrack } from "@/lib/timeline";
|
||||||
import type { MediaAsset } from "@/lib/media/types";
|
import type { MediaAsset } from "@/lib/media/types";
|
||||||
import { RootNode } from "./nodes/root-node";
|
import { RootNode } from "./nodes/root-node";
|
||||||
import { VideoNode } from "./nodes/video-node";
|
import { VideoNode } from "./nodes/video-node";
|
||||||
|
|
@ -12,7 +12,6 @@ import { EffectLayerNode } from "./nodes/effect-layer-node";
|
||||||
import type { BaseNode } from "./nodes/base-node";
|
import type { BaseNode } from "./nodes/base-node";
|
||||||
import type { TBackground, TCanvasSize } from "@/lib/project/types";
|
import type { TBackground, TCanvasSize } from "@/lib/project/types";
|
||||||
import { DEFAULT_BACKGROUND_BLUR_INTENSITY } from "@/lib/background/constants";
|
import { DEFAULT_BACKGROUND_BLUR_INTENSITY } from "@/lib/background/constants";
|
||||||
import { isMainTrack } from "@/lib/timeline/placement";
|
|
||||||
|
|
||||||
const PREVIEW_MAX_IMAGE_SIZE = 2048;
|
const PREVIEW_MAX_IMAGE_SIZE = 2048;
|
||||||
|
|
||||||
|
|
@ -209,7 +208,7 @@ function buildBlurBackgroundNodes({
|
||||||
|
|
||||||
export type BuildSceneParams = {
|
export type BuildSceneParams = {
|
||||||
canvasSize: TCanvasSize;
|
canvasSize: TCanvasSize;
|
||||||
tracks: TimelineTrack[];
|
tracks: SceneTracks;
|
||||||
mediaAssets: MediaAsset[];
|
mediaAssets: MediaAsset[];
|
||||||
duration: number;
|
duration: number;
|
||||||
background: TBackground;
|
background: TBackground;
|
||||||
|
|
@ -227,19 +226,12 @@ export function buildScene({
|
||||||
const rootNode = new RootNode({ duration });
|
const rootNode = new RootNode({ duration });
|
||||||
const mediaMap = new Map(mediaAssets.map((m) => [m.id, m]));
|
const mediaMap = new Map(mediaAssets.map((m) => [m.id, m]));
|
||||||
|
|
||||||
const visibleTracks = tracks.filter(
|
const visibleTracks = [
|
||||||
(track) => !("hidden" in track && track.hidden),
|
...tracks.overlay.filter((track) => !("hidden" in track && track.hidden)),
|
||||||
);
|
...(!tracks.main.hidden ? [tracks.main] : []),
|
||||||
|
|
||||||
const orderedTracksTopToBottom = [
|
|
||||||
...visibleTracks.filter((track) => !isMainTrack(track)),
|
|
||||||
...visibleTracks.filter((track) => isMainTrack(track)),
|
|
||||||
];
|
];
|
||||||
|
const orderedTracksBottomToTop = visibleTracks.slice().reverse();
|
||||||
const orderedTracksBottomToTop = orderedTracksTopToBottom.slice().reverse();
|
const mainTrack = tracks.main.hidden ? undefined : tracks.main;
|
||||||
const mainTrack = orderedTracksBottomToTop.find((track) =>
|
|
||||||
isMainTrack(track),
|
|
||||||
);
|
|
||||||
|
|
||||||
const allNodes = buildTrackNodes({
|
const allNodes = buildTrackNodes({
|
||||||
tracks: orderedTracksBottomToTop,
|
tracks: orderedTracksBottomToTop,
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,11 @@ import { V19toV20Migration } from "./v19-to-v20";
|
||||||
import { V20toV21Migration } from "./v20-to-v21";
|
import { V20toV21Migration } from "./v20-to-v21";
|
||||||
import { V21toV22Migration } from "./v21-to-v22";
|
import { V21toV22Migration } from "./v21-to-v22";
|
||||||
import { V22toV23Migration } from "./v22-to-v23";
|
import { V22toV23Migration } from "./v22-to-v23";
|
||||||
|
import { V23toV24Migration } from "./v23-to-v24";
|
||||||
export { runStorageMigrations } from "./runner";
|
export { runStorageMigrations } from "./runner";
|
||||||
export type { MigrationProgress } from "./runner";
|
export type { MigrationProgress } from "./runner";
|
||||||
|
|
||||||
export const CURRENT_PROJECT_VERSION = 23;
|
export const CURRENT_PROJECT_VERSION = 24;
|
||||||
|
|
||||||
export const migrations = [
|
export const migrations = [
|
||||||
new V0toV1Migration(),
|
new V0toV1Migration(),
|
||||||
|
|
@ -51,4 +52,5 @@ export const migrations = [
|
||||||
new V20toV21Migration(),
|
new V20toV21Migration(),
|
||||||
new V21toV22Migration(),
|
new V21toV22Migration(),
|
||||||
new V22toV23Migration(),
|
new V22toV23Migration(),
|
||||||
|
new V23toV24Migration(),
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
import type { MigrationResult, ProjectRecord } from "./types";
|
||||||
|
import { getProjectId, isRecord } from "./utils";
|
||||||
|
|
||||||
|
export function transformProjectV23ToV24({
|
||||||
|
project,
|
||||||
|
}: {
|
||||||
|
project: ProjectRecord;
|
||||||
|
}): MigrationResult<ProjectRecord> {
|
||||||
|
if (!getProjectId({ project })) {
|
||||||
|
return { project, skipped: true, reason: "no project id" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = project.version;
|
||||||
|
if (typeof version !== "number") {
|
||||||
|
return { project, skipped: true, reason: "invalid version" };
|
||||||
|
}
|
||||||
|
if (version >= 24) {
|
||||||
|
return { project, skipped: true, reason: "already v24" };
|
||||||
|
}
|
||||||
|
if (version !== 23) {
|
||||||
|
return { project, skipped: true, reason: "not v23" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
project: {
|
||||||
|
...migrateProject({ project }),
|
||||||
|
version: 24,
|
||||||
|
},
|
||||||
|
skipped: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateProject({
|
||||||
|
project,
|
||||||
|
}: {
|
||||||
|
project: ProjectRecord;
|
||||||
|
}): ProjectRecord {
|
||||||
|
if (!Array.isArray(project.scenes)) {
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...project,
|
||||||
|
scenes: project.scenes.map((scene) => migrateScene({ scene })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateScene({ scene }: { scene: unknown }): unknown {
|
||||||
|
if (!isRecord(scene) || !Array.isArray(scene.tracks)) {
|
||||||
|
return scene;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainTrack = findMainTrack({ tracks: scene.tracks });
|
||||||
|
if (!mainTrack) {
|
||||||
|
return scene;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...scene,
|
||||||
|
tracks: {
|
||||||
|
overlay: scene.tracks
|
||||||
|
.filter((track) => track !== mainTrack)
|
||||||
|
.map((track) => migrateTrack({ track }))
|
||||||
|
.filter(
|
||||||
|
(track): track is ProjectRecord =>
|
||||||
|
isRecord(track) && track.type !== "audio",
|
||||||
|
),
|
||||||
|
main: migrateTrack({ track: mainTrack }),
|
||||||
|
audio: scene.tracks
|
||||||
|
.map((track) => migrateTrack({ track }))
|
||||||
|
.filter(
|
||||||
|
(track): track is ProjectRecord =>
|
||||||
|
isRecord(track) && track.type === "audio",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMainTrack({
|
||||||
|
tracks,
|
||||||
|
}: {
|
||||||
|
tracks: unknown[];
|
||||||
|
}): ProjectRecord | null {
|
||||||
|
for (const track of tracks) {
|
||||||
|
if (!isRecord(track)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (track.type === "video" && track.isMain === true) {
|
||||||
|
return track;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const track of tracks) {
|
||||||
|
if (!isRecord(track)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (track.type === "video") {
|
||||||
|
return track;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateTrack({ track }: { track: unknown }): unknown {
|
||||||
|
if (!isRecord(track)) {
|
||||||
|
return track;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextTrack = { ...track };
|
||||||
|
delete nextTrack.isMain;
|
||||||
|
return nextTrack;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { StorageMigration } from "./base";
|
||||||
|
import type { ProjectRecord } from "./transformers/types";
|
||||||
|
import { transformProjectV23ToV24 } from "./transformers/v23-to-v24";
|
||||||
|
|
||||||
|
export class V23toV24Migration extends StorageMigration {
|
||||||
|
from = 23;
|
||||||
|
to = 24;
|
||||||
|
|
||||||
|
async transform(project: ProjectRecord): Promise<{
|
||||||
|
project: ProjectRecord;
|
||||||
|
skipped: boolean;
|
||||||
|
reason?: string;
|
||||||
|
}> {
|
||||||
|
return transformProjectV23ToV24({ project });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,7 +21,7 @@ import {
|
||||||
migrations,
|
migrations,
|
||||||
runStorageMigrations,
|
runStorageMigrations,
|
||||||
} from "@/services/storage/migrations";
|
} from "@/services/storage/migrations";
|
||||||
import type { Bookmark, TimelineTrack, TScene } from "@/lib/timeline";
|
import type { Bookmark, SceneTracks, TScene } from "@/lib/timeline";
|
||||||
|
|
||||||
function normalizeBookmarks({ raw }: { raw: unknown }): Bookmark[] {
|
function normalizeBookmarks({ raw }: { raw: unknown }): Bookmark[] {
|
||||||
if (!Array.isArray(raw)) return [];
|
if (!Array.isArray(raw)) return [];
|
||||||
|
|
@ -116,18 +116,18 @@ class StorageService {
|
||||||
private stripAudioBuffers({
|
private stripAudioBuffers({
|
||||||
tracks,
|
tracks,
|
||||||
}: {
|
}: {
|
||||||
tracks: TimelineTrack[];
|
tracks: SceneTracks;
|
||||||
}): TimelineTrack[] {
|
}): SceneTracks {
|
||||||
return tracks.map((track) => {
|
return {
|
||||||
if (track.type !== "audio") return track;
|
...tracks,
|
||||||
return {
|
audio: tracks.audio.map((track) => ({
|
||||||
...track,
|
...track,
|
||||||
elements: track.elements.map((element) => {
|
elements: track.elements.map((element) => {
|
||||||
const { buffer: _buffer, ...rest } = element;
|
const { buffer: _buffer, ...rest } = element;
|
||||||
return rest;
|
return rest;
|
||||||
}),
|
}),
|
||||||
};
|
})),
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveProject({ project }: { project: TProject }): Promise<void> {
|
async saveProject({ project }: { project: TProject }): Promise<void> {
|
||||||
|
|
@ -178,11 +178,7 @@ class StorageService {
|
||||||
id: scene.id,
|
id: scene.id,
|
||||||
name: scene.name,
|
name: scene.name,
|
||||||
isMain: scene.isMain,
|
isMain: scene.isMain,
|
||||||
tracks: (scene.tracks ?? []).map((track) =>
|
tracks: scene.tracks,
|
||||||
track.type === "video"
|
|
||||||
? { ...track, isMain: track.isMain ?? false } // legacy: isMain was optional
|
|
||||||
: track,
|
|
||||||
),
|
|
||||||
bookmarks: normalizeBookmarks({ raw: scene.bookmarks }),
|
bookmarks: normalizeBookmarks({ raw: scene.bookmarks }),
|
||||||
createdAt: new Date(scene.createdAt),
|
createdAt: new Date(scene.createdAt),
|
||||||
updatedAt: new Date(scene.updatedAt),
|
updatedAt: new Date(scene.updatedAt),
|
||||||
|
|
|
||||||
|
|
@ -215,7 +215,7 @@ export const useSoundsStore = create<SoundsStore>((set, get) => ({
|
||||||
try {
|
try {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
const currentTime = editor.playback.getCurrentTime();
|
const currentTime = editor.playback.getCurrentTime();
|
||||||
const tracks = editor.timeline.getTracks();
|
const tracks = editor.scenes.getActiveScene().tracks;
|
||||||
|
|
||||||
const response = await fetch(audioUrl);
|
const response = await fetch(audioUrl);
|
||||||
if (!response.ok)
|
if (!response.ok)
|
||||||
|
|
@ -225,7 +225,7 @@ export const useSoundsStore = create<SoundsStore>((set, get) => ({
|
||||||
const audioContext = new AudioContext();
|
const audioContext = new AudioContext();
|
||||||
const buffer = await audioContext.decodeAudioData(arrayBuffer);
|
const buffer = await audioContext.decodeAudioData(arrayBuffer);
|
||||||
|
|
||||||
const audioTrack = tracks.find((t) => t.type === "audio");
|
const audioTrack = tracks.audio[0];
|
||||||
let trackId: string;
|
let trackId: string;
|
||||||
|
|
||||||
if (audioTrack) {
|
if (audioTrack) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue