mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
refactor: unify element updates into pipeline and centralize ripple
This commit is contained in:
+1
-1
@@ -144,7 +144,7 @@ export function useKeyframedNumberProperty({
|
|||||||
{
|
{
|
||||||
trackId,
|
trackId,
|
||||||
elementId,
|
elementId,
|
||||||
updates: buildBaseUpdates({ value: nextValue }),
|
patch: buildBaseUpdates({ value: nextValue }),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
+2
-2
@@ -152,7 +152,7 @@ export function useKeyframedVectorProperty({
|
|||||||
}
|
}
|
||||||
editor.timeline.updateElements({
|
editor.timeline.updateElements({
|
||||||
updates: [
|
updates: [
|
||||||
{ trackId, elementId, updates: buildBaseUpdates({ value: vector }) },
|
{ trackId, elementId, patch: buildBaseUpdates({ value: vector }) },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -172,7 +172,7 @@ export function useKeyframedVectorProperty({
|
|||||||
}
|
}
|
||||||
editor.timeline.updateElements({
|
editor.timeline.updateElements({
|
||||||
updates: [
|
updates: [
|
||||||
{ trackId, elementId, updates: buildBaseUpdates({ value: vector }) },
|
{ trackId, elementId, patch: buildBaseUpdates({ value: vector }) },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ type BlendingElement = {
|
|||||||
animations?: ElementAnimations;
|
animations?: ElementAnimations;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BLEND_MODE_GROUPS = [
|
const BLEND_MODE_GROUPS: { value: BlendMode; label: string }[][] = [
|
||||||
[{ value: "normal", label: "Normal" }],
|
[{ value: "normal", label: "Normal" }],
|
||||||
[
|
[
|
||||||
{ value: "darken", label: "Darken" },
|
{ value: "darken", label: "Darken" },
|
||||||
@@ -99,7 +99,7 @@ export function BlendingTab({
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const commitBlendMode = (value: string) => {
|
const commitBlendMode = (value: BlendMode) => {
|
||||||
if (editor.timeline.isPreviewActive()) {
|
if (editor.timeline.isPreviewActive()) {
|
||||||
editor.timeline.commitPreview();
|
editor.timeline.commitPreview();
|
||||||
} else {
|
} else {
|
||||||
@@ -108,7 +108,7 @@ export function BlendingTab({
|
|||||||
{
|
{
|
||||||
trackId,
|
trackId,
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
updates: { blendMode: value as BlendMode },
|
patch: { blendMode: value },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -209,7 +209,7 @@ export function BlendingTab({
|
|||||||
key={option.value}
|
key={option.value}
|
||||||
value={option.value}
|
value={option.value}
|
||||||
onPointerEnter={() =>
|
onPointerEnter={() =>
|
||||||
previewBlendMode({ value: option.value as BlendMode })
|
previewBlendMode({ value: option.value })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ function StrokeSection({
|
|||||||
{
|
{
|
||||||
trackId,
|
trackId,
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
updates: { params: { ...element.params, strokeWidth: 0 } },
|
patch: { params: { ...element.params, strokeWidth: 0 } },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -133,7 +133,7 @@ function StrokeSection({
|
|||||||
{
|
{
|
||||||
trackId,
|
trackId,
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
updates: {
|
patch: {
|
||||||
params: {
|
params: {
|
||||||
...element.params,
|
...element.params,
|
||||||
strokeWidth: lastStrokeWidth.current,
|
strokeWidth: lastStrokeWidth.current,
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ export function MasksTab({ element, trackId }: MasksTabProps) {
|
|||||||
{
|
{
|
||||||
trackId,
|
trackId,
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
updates: {
|
patch: {
|
||||||
masks: [
|
masks: [
|
||||||
buildDefaultMaskInstance({
|
buildDefaultMaskInstance({
|
||||||
maskType,
|
maskType,
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ function TypographySection({
|
|||||||
{
|
{
|
||||||
trackId,
|
trackId,
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
updates: { fontFamily: value },
|
patch: { fontFamily: value },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@@ -188,7 +188,7 @@ function TypographySection({
|
|||||||
{
|
{
|
||||||
trackId,
|
trackId,
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
updates: {
|
patch: {
|
||||||
fontSize: DEFAULTS.text.element.fontSize,
|
fontSize: DEFAULTS.text.element.fontSize,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -291,7 +291,7 @@ function SpacingSection({
|
|||||||
{
|
{
|
||||||
trackId,
|
trackId,
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
updates: { letterSpacing: DEFAULTS.text.letterSpacing },
|
patch: { letterSpacing: DEFAULTS.text.letterSpacing },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@@ -317,7 +317,7 @@ function SpacingSection({
|
|||||||
{
|
{
|
||||||
trackId,
|
trackId,
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
updates: { lineHeight: DEFAULTS.text.lineHeight },
|
patch: { lineHeight: DEFAULTS.text.lineHeight },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@@ -512,7 +512,7 @@ function BackgroundSection({
|
|||||||
{
|
{
|
||||||
trackId,
|
trackId,
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
updates: {
|
patch: {
|
||||||
background: {
|
background: {
|
||||||
...element.background,
|
...element.background,
|
||||||
enabled,
|
enabled,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { EditorCore } from "@/core";
|
|||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import { useKeybindingsListener } from "@/hooks/use-keybindings";
|
import { useKeybindingsListener } from "@/hooks/use-keybindings";
|
||||||
import { useKeybindingsStore } from "@/stores/keybindings-store";
|
import { useKeybindingsStore } from "@/stores/keybindings-store";
|
||||||
|
import { useTimelineStore } from "@/stores/timeline-store";
|
||||||
import { useEditorActions } from "@/hooks/actions/use-editor-actions";
|
import { useEditorActions } from "@/hooks/actions/use-editor-actions";
|
||||||
import { loadFontAtlas } from "@/lib/fonts/google-fonts";
|
import { loadFontAtlas } from "@/lib/fonts/google-fonts";
|
||||||
import { initializeGpuRenderer } from "@/services/renderer/gpu-renderer";
|
import { initializeGpuRenderer } from "@/services/renderer/gpu-renderer";
|
||||||
@@ -117,6 +118,13 @@ export function EditorProvider({ projectId, children }: EditorProviderProps) {
|
|||||||
|
|
||||||
function EditorRuntimeBindings() {
|
function EditorRuntimeBindings() {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
|
const rippleEditingEnabled = useTimelineStore(
|
||||||
|
(state) => state.rippleEditingEnabled,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
editor.command.isRippleEnabled = rippleEditingEnabled;
|
||||||
|
}, [editor, rippleEditingEnabled]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||||
|
|||||||
@@ -1,6 +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 type { ElementRef } from "@/lib/timeline/types";
|
import { applyRippleAdjustments, computeRippleAdjustments } from "@/lib/ripple";
|
||||||
|
import type { TimelineTrack, ElementRef } from "@/lib/timeline/types";
|
||||||
|
|
||||||
interface CommandHistoryEntry {
|
interface CommandHistoryEntry {
|
||||||
command: Command;
|
command: Command;
|
||||||
@@ -9,6 +10,7 @@ interface CommandHistoryEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class CommandManager {
|
export class CommandManager {
|
||||||
|
public isRippleEnabled = false;
|
||||||
private history: CommandHistoryEntry[] = [];
|
private history: CommandHistoryEntry[] = [];
|
||||||
private redoStack: CommandHistoryEntry[] = [];
|
private redoStack: CommandHistoryEntry[] = [];
|
||||||
private reactors: Array<() => void> = [];
|
private reactors: Array<() => void> = [];
|
||||||
@@ -16,8 +18,10 @@ 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 previousSelection = this.getSelectionSnapshot();
|
const previousSelection = this.getSelectionSnapshot();
|
||||||
const result = command.execute();
|
const result = command.execute();
|
||||||
|
this.applyRippleIfEnabled({ beforeTracks });
|
||||||
const selectionOverride = this.applySelectionOverride(result);
|
const selectionOverride = this.applySelectionOverride(result);
|
||||||
this.runReactors();
|
this.runReactors();
|
||||||
this.history.push({
|
this.history.push({
|
||||||
@@ -67,8 +71,10 @@ export class CommandManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const beforeTracks = this.isRippleEnabled ? this.editor.timeline.getTracks() : null;
|
||||||
const previousSelection = this.getSelectionSnapshot();
|
const previousSelection = this.getSelectionSnapshot();
|
||||||
const result = entry.command.redo();
|
const result = entry.command.redo();
|
||||||
|
this.applyRippleIfEnabled({ beforeTracks });
|
||||||
const selectionOverride = this.applySelectionOverride(result);
|
const selectionOverride = this.applySelectionOverride(result);
|
||||||
this.runReactors();
|
this.runReactors();
|
||||||
|
|
||||||
@@ -113,4 +119,29 @@ export class CommandManager {
|
|||||||
reactor();
|
reactor();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private applyRippleIfEnabled({
|
||||||
|
beforeTracks,
|
||||||
|
}: {
|
||||||
|
beforeTracks: TimelineTrack[] | null;
|
||||||
|
}): void {
|
||||||
|
if (!this.isRippleEnabled || !beforeTracks) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const afterTracks = this.editor.timeline.getTracks();
|
||||||
|
const adjustments = computeRippleAdjustments({
|
||||||
|
beforeTracks,
|
||||||
|
afterTracks,
|
||||||
|
});
|
||||||
|
if (adjustments.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tracksWithRipple = applyRippleAdjustments({
|
||||||
|
tracks: afterTracks,
|
||||||
|
adjustments,
|
||||||
|
});
|
||||||
|
this.editor.timeline.updateTracks(tracksWithRipple);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,32 +7,31 @@ import type {
|
|||||||
ClipboardItem,
|
ClipboardItem,
|
||||||
RetimeConfig,
|
RetimeConfig,
|
||||||
} from "@/lib/timeline";
|
} from "@/lib/timeline";
|
||||||
|
import { calculateTotalDuration } from "@/lib/timeline";
|
||||||
|
import {
|
||||||
|
canElementBeHidden,
|
||||||
|
canElementHaveAudio,
|
||||||
|
} from "@/lib/timeline/element-utils";
|
||||||
import type {
|
import type {
|
||||||
AnimationPath,
|
AnimationPath,
|
||||||
AnimationInterpolation,
|
AnimationInterpolation,
|
||||||
AnimationValue,
|
AnimationValue,
|
||||||
} from "@/lib/animation/types";
|
} from "@/lib/animation/types";
|
||||||
import { calculateTotalDuration } from "@/lib/timeline";
|
|
||||||
import { getLastFrameTime } from "opencut-wasm";
|
import { getLastFrameTime } from "opencut-wasm";
|
||||||
|
import { BatchCommand } from "@/lib/commands";
|
||||||
import {
|
import {
|
||||||
AddTrackCommand,
|
AddTrackCommand,
|
||||||
RemoveTrackCommand,
|
RemoveTrackCommand,
|
||||||
ToggleTrackMuteCommand,
|
ToggleTrackMuteCommand,
|
||||||
ToggleTrackVisibilityCommand,
|
ToggleTrackVisibilityCommand,
|
||||||
InsertElementCommand,
|
InsertElementCommand,
|
||||||
UpdateElementTrimCommand,
|
|
||||||
UpdateElementDurationCommand,
|
|
||||||
DeleteElementsCommand,
|
DeleteElementsCommand,
|
||||||
DuplicateElementsCommand,
|
DuplicateElementsCommand,
|
||||||
ToggleElementsVisibilityCommand,
|
UpdateElementsCommand,
|
||||||
ToggleElementsMutedCommand,
|
|
||||||
UpdateElementCommand,
|
|
||||||
SplitElementsCommand,
|
SplitElementsCommand,
|
||||||
PasteCommand,
|
PasteCommand,
|
||||||
UpdateElementStartTimeCommand,
|
|
||||||
MoveElementCommand,
|
MoveElementCommand,
|
||||||
TracksSnapshotCommand,
|
TracksSnapshotCommand,
|
||||||
UpdateElementRetimeCommand,
|
|
||||||
UpsertKeyframeCommand,
|
UpsertKeyframeCommand,
|
||||||
RemoveKeyframeCommand,
|
RemoveKeyframeCommand,
|
||||||
RetimeKeyframeCommand,
|
RetimeKeyframeCommand,
|
||||||
@@ -47,7 +46,6 @@ import {
|
|||||||
RemoveEffectParamKeyframeCommand,
|
RemoveEffectParamKeyframeCommand,
|
||||||
ToggleSourceAudioSeparationCommand,
|
ToggleSourceAudioSeparationCommand,
|
||||||
} from "@/lib/commands/timeline";
|
} from "@/lib/commands/timeline";
|
||||||
import { BatchCommand } from "@/lib/commands";
|
|
||||||
import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element";
|
import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element";
|
||||||
|
|
||||||
export class TimelineManager {
|
export class TimelineManager {
|
||||||
@@ -80,7 +78,6 @@ export class TimelineManager {
|
|||||||
startTime,
|
startTime,
|
||||||
duration,
|
duration,
|
||||||
pushHistory = true,
|
pushHistory = true,
|
||||||
rippleEnabled = false,
|
|
||||||
}: {
|
}: {
|
||||||
elementId: string;
|
elementId: string;
|
||||||
trimStart: number;
|
trimStart: number;
|
||||||
@@ -88,44 +85,33 @@ export class TimelineManager {
|
|||||||
startTime?: number;
|
startTime?: number;
|
||||||
duration?: number;
|
duration?: number;
|
||||||
pushHistory?: boolean;
|
pushHistory?: boolean;
|
||||||
rippleEnabled?: boolean;
|
|
||||||
}): void {
|
}): void {
|
||||||
const command = new UpdateElementTrimCommand({
|
const trackId = this.findTrackIdForElement({ elementId });
|
||||||
elementId,
|
if (!trackId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextUpdates: Partial<TimelineElement> = {
|
||||||
trimStart,
|
trimStart,
|
||||||
trimEnd,
|
trimEnd,
|
||||||
startTime,
|
};
|
||||||
duration,
|
if (startTime !== undefined) {
|
||||||
rippleEnabled,
|
nextUpdates.startTime = startTime;
|
||||||
});
|
}
|
||||||
if (pushHistory) {
|
if (duration !== undefined) {
|
||||||
this.editor.command.execute({ command });
|
nextUpdates.duration = duration;
|
||||||
} else {
|
|
||||||
command.execute();
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
updateElementDuration({
|
this.updateElements({
|
||||||
trackId,
|
updates: [
|
||||||
elementId,
|
{
|
||||||
duration,
|
trackId,
|
||||||
pushHistory = true,
|
elementId,
|
||||||
}: {
|
patch: nextUpdates,
|
||||||
trackId: string;
|
},
|
||||||
elementId: string;
|
],
|
||||||
duration: number;
|
pushHistory,
|
||||||
pushHistory?: boolean;
|
|
||||||
}): void {
|
|
||||||
const command = new UpdateElementDurationCommand({
|
|
||||||
trackId,
|
|
||||||
elementId,
|
|
||||||
duration,
|
|
||||||
});
|
});
|
||||||
if (pushHistory) {
|
|
||||||
this.editor.command.execute({ command });
|
|
||||||
} else {
|
|
||||||
command.execute();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateElementRetime({
|
updateElementRetime({
|
||||||
@@ -139,30 +125,18 @@ export class TimelineManager {
|
|||||||
retime?: RetimeConfig;
|
retime?: RetimeConfig;
|
||||||
pushHistory?: boolean;
|
pushHistory?: boolean;
|
||||||
}): void {
|
}): void {
|
||||||
const command = new UpdateElementRetimeCommand({
|
this.updateElements({
|
||||||
trackId,
|
updates: [
|
||||||
elementId,
|
{
|
||||||
retime,
|
trackId,
|
||||||
|
elementId,
|
||||||
|
patch: {
|
||||||
|
retime,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pushHistory,
|
||||||
});
|
});
|
||||||
if (pushHistory) {
|
|
||||||
this.editor.command.execute({ command });
|
|
||||||
} else {
|
|
||||||
command.execute();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateElementStartTime({
|
|
||||||
elements,
|
|
||||||
startTime,
|
|
||||||
}: {
|
|
||||||
elements: { trackId: string; elementId: string }[];
|
|
||||||
startTime: number;
|
|
||||||
}): void {
|
|
||||||
const command = new UpdateElementStartTimeCommand({
|
|
||||||
elements,
|
|
||||||
startTime,
|
|
||||||
});
|
|
||||||
this.editor.command.execute({ command });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
moveElement({
|
moveElement({
|
||||||
@@ -171,14 +145,12 @@ export class TimelineManager {
|
|||||||
elementId,
|
elementId,
|
||||||
newStartTime,
|
newStartTime,
|
||||||
createTrack,
|
createTrack,
|
||||||
rippleEnabled = false,
|
|
||||||
}: {
|
}: {
|
||||||
sourceTrackId: string;
|
sourceTrackId: string;
|
||||||
targetTrackId: string;
|
targetTrackId: string;
|
||||||
elementId: string;
|
elementId: string;
|
||||||
newStartTime: number;
|
newStartTime: number;
|
||||||
createTrack?: { type: TrackType; index: number };
|
createTrack?: { type: TrackType; index: number };
|
||||||
rippleEnabled?: boolean;
|
|
||||||
}): void {
|
}): void {
|
||||||
const command = new MoveElementCommand({
|
const command = new MoveElementCommand({
|
||||||
sourceTrackId,
|
sourceTrackId,
|
||||||
@@ -186,7 +158,6 @@ export class TimelineManager {
|
|||||||
elementId,
|
elementId,
|
||||||
newStartTime,
|
newStartTime,
|
||||||
createTrack,
|
createTrack,
|
||||||
rippleEnabled,
|
|
||||||
});
|
});
|
||||||
this.editor.command.execute({ command });
|
this.editor.command.execute({ command });
|
||||||
}
|
}
|
||||||
@@ -205,18 +176,15 @@ export class TimelineManager {
|
|||||||
elements,
|
elements,
|
||||||
splitTime,
|
splitTime,
|
||||||
retainSide = "both",
|
retainSide = "both",
|
||||||
rippleEnabled = false,
|
|
||||||
}: {
|
}: {
|
||||||
elements: { trackId: string; elementId: string }[];
|
elements: { trackId: string; elementId: string }[];
|
||||||
splitTime: number;
|
splitTime: number;
|
||||||
retainSide?: "both" | "left" | "right";
|
retainSide?: "both" | "left" | "right";
|
||||||
rippleEnabled?: boolean;
|
|
||||||
}): { trackId: string; elementId: string }[] {
|
}): { trackId: string; elementId: string }[] {
|
||||||
const command = new SplitElementsCommand({
|
const command = new SplitElementsCommand({
|
||||||
elements,
|
elements,
|
||||||
splitTime,
|
splitTime,
|
||||||
retainSide,
|
retainSide,
|
||||||
rippleEnabled,
|
|
||||||
});
|
});
|
||||||
this.editor.command.execute({ command });
|
this.editor.command.execute({ command });
|
||||||
return command.getRightSideElements();
|
return command.getRightSideElements();
|
||||||
@@ -273,12 +241,10 @@ export class TimelineManager {
|
|||||||
|
|
||||||
deleteElements({
|
deleteElements({
|
||||||
elements,
|
elements,
|
||||||
rippleEnabled = false,
|
|
||||||
}: {
|
}: {
|
||||||
elements: { trackId: string; elementId: string }[];
|
elements: { trackId: string; elementId: string }[];
|
||||||
rippleEnabled?: boolean;
|
|
||||||
}): void {
|
}): void {
|
||||||
const command = new DeleteElementsCommand({ elements, rippleEnabled });
|
const command = new DeleteElementsCommand({ elements });
|
||||||
this.editor.command.execute({ command });
|
this.editor.command.execute({ command });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,20 +269,17 @@ export class TimelineManager {
|
|||||||
updates: Array<{
|
updates: Array<{
|
||||||
trackId: string;
|
trackId: string;
|
||||||
elementId: string;
|
elementId: string;
|
||||||
updates: Partial<TimelineElement>;
|
patch: Partial<TimelineElement>;
|
||||||
}>;
|
}>;
|
||||||
pushHistory?: boolean;
|
pushHistory?: boolean;
|
||||||
}): void {
|
}): void {
|
||||||
const commands = updates.map(
|
if (updates.length === 0) {
|
||||||
({ trackId, elementId, updates: elementUpdates }) =>
|
return;
|
||||||
new UpdateElementCommand({
|
}
|
||||||
trackId,
|
|
||||||
elementId,
|
const command = new UpdateElementsCommand({
|
||||||
updates: elementUpdates,
|
updates,
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
const command =
|
|
||||||
commands.length === 1 ? commands[0] : new BatchCommand(commands);
|
|
||||||
if (pushHistory) {
|
if (pushHistory) {
|
||||||
this.editor.command.execute({ command });
|
this.editor.command.execute({ command });
|
||||||
} else {
|
} else {
|
||||||
@@ -679,8 +642,27 @@ export class TimelineManager {
|
|||||||
}: {
|
}: {
|
||||||
elements: { trackId: string; elementId: string }[];
|
elements: { trackId: string; elementId: string }[];
|
||||||
}): void {
|
}): void {
|
||||||
const command = new ToggleElementsVisibilityCommand(elements);
|
const shouldHide = elements.some(({ trackId, elementId }) => {
|
||||||
this.editor.command.execute({ command });
|
const element = this.getElementByRef({ trackId, elementId });
|
||||||
|
return element && canElementBeHidden(element) && !element.hidden;
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextUpdates = elements.flatMap(({ trackId, elementId }) => {
|
||||||
|
const element = this.getElementByRef({ trackId, elementId });
|
||||||
|
if (!element || !canElementBeHidden(element)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
trackId,
|
||||||
|
elementId,
|
||||||
|
patch: { hidden: shouldHide },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
this.updateElements({ updates: nextUpdates });
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleElementsMuted({
|
toggleElementsMuted({
|
||||||
@@ -688,8 +670,27 @@ export class TimelineManager {
|
|||||||
}: {
|
}: {
|
||||||
elements: { trackId: string; elementId: string }[];
|
elements: { trackId: string; elementId: string }[];
|
||||||
}): void {
|
}): void {
|
||||||
const command = new ToggleElementsMutedCommand(elements);
|
const shouldMute = elements.some(({ trackId, elementId }) => {
|
||||||
this.editor.command.execute({ command });
|
const element = this.getElementByRef({ trackId, elementId });
|
||||||
|
return element && canElementHaveAudio(element) && !element.muted;
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextUpdates = elements.flatMap(({ trackId, elementId }) => {
|
||||||
|
const element = this.getElementByRef({ trackId, elementId });
|
||||||
|
if (!element || !canElementHaveAudio(element)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
trackId,
|
||||||
|
elementId,
|
||||||
|
patch: { muted: shouldMute },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
this.updateElements({ updates: nextUpdates });
|
||||||
}
|
}
|
||||||
|
|
||||||
getTracks(): TimelineTrack[] {
|
getTracks(): TimelineTrack[] {
|
||||||
@@ -712,6 +713,30 @@ export class TimelineManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getElementByRef({
|
||||||
|
trackId,
|
||||||
|
elementId,
|
||||||
|
}: {
|
||||||
|
trackId: string;
|
||||||
|
elementId: string;
|
||||||
|
}): TimelineElement | undefined {
|
||||||
|
return this.getTrackById({ trackId })?.elements.find(
|
||||||
|
(element) => element.id === elementId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private findTrackIdForElement({
|
||||||
|
elementId,
|
||||||
|
}: {
|
||||||
|
elementId: string;
|
||||||
|
}): string | null {
|
||||||
|
return (
|
||||||
|
this.getTracks().find((track) =>
|
||||||
|
track.elements.some((element) => element.id === elementId),
|
||||||
|
)?.id ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
updateTracks(newTracks: TimelineTrack[]): void {
|
updateTracks(newTracks: TimelineTrack[]): void {
|
||||||
this.previewOverlay.clear();
|
this.previewOverlay.clear();
|
||||||
this.previewTracks = null;
|
this.previewTracks = null;
|
||||||
|
|||||||
@@ -212,7 +212,6 @@ export function useEditorActions() {
|
|||||||
elements: elementsToSplit,
|
elements: elementsToSplit,
|
||||||
splitTime: currentTime,
|
splitTime: currentTime,
|
||||||
retainSide: "right",
|
retainSide: "right",
|
||||||
rippleEnabled: rippleEditingEnabled,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (rippleEditingEnabled && rightSideElements.length > 0) {
|
if (rippleEditingEnabled && rightSideElements.length > 0) {
|
||||||
@@ -263,7 +262,6 @@ export function useEditorActions() {
|
|||||||
}
|
}
|
||||||
editor.timeline.deleteElements({
|
editor.timeline.deleteElements({
|
||||||
elements: selectedElements,
|
elements: selectedElements,
|
||||||
rippleEnabled: rippleEditingEnabled,
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||||
import { useTimelineStore } from "@/stores/timeline-store";
|
|
||||||
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
||||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
||||||
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
|
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
|
||||||
@@ -161,7 +160,6 @@ export function useElementInteraction({
|
|||||||
onSnapPointChange,
|
onSnapPointChange,
|
||||||
}: UseElementInteractionProps) {
|
}: UseElementInteractionProps) {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
|
|
||||||
const isShiftHeldRef = useShiftKey();
|
const isShiftHeldRef = useShiftKey();
|
||||||
const tracks = editor.timeline.getTracks();
|
const tracks = editor.timeline.getTracks();
|
||||||
const {
|
const {
|
||||||
@@ -467,7 +465,6 @@ export function useElementInteraction({
|
|||||||
elementId: dragState.elementId,
|
elementId: dragState.elementId,
|
||||||
newStartTime: snappedTime,
|
newStartTime: snappedTime,
|
||||||
createTrack: { type: sourceTrack.type, index: dropTarget.trackIndex },
|
createTrack: { type: sourceTrack.type, index: dropTarget.trackIndex },
|
||||||
rippleEnabled: rippleEditingEnabled,
|
|
||||||
});
|
});
|
||||||
selectElement({ trackId: newTrackId, elementId: dragState.elementId });
|
selectElement({ trackId: newTrackId, elementId: dragState.elementId });
|
||||||
} else {
|
} else {
|
||||||
@@ -478,7 +475,6 @@ export function useElementInteraction({
|
|||||||
targetTrackId: targetTrack.id,
|
targetTrackId: targetTrack.id,
|
||||||
elementId: dragState.elementId,
|
elementId: dragState.elementId,
|
||||||
newStartTime: snappedTime,
|
newStartTime: snappedTime,
|
||||||
rippleEnabled: rippleEditingEnabled,
|
|
||||||
});
|
});
|
||||||
if (targetTrack.id !== dragState.trackId) {
|
if (targetTrack.id !== dragState.trackId) {
|
||||||
selectElement({
|
selectElement({
|
||||||
@@ -509,7 +505,6 @@ export function useElementInteraction({
|
|||||||
tracksContainerRef,
|
tracksContainerRef,
|
||||||
tracksScrollRef,
|
tracksScrollRef,
|
||||||
headerRef,
|
headerRef,
|
||||||
rippleEditingEnabled,
|
|
||||||
selectElement,
|
selectElement,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -45,9 +45,6 @@ export function useTimelineElementResize({
|
|||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const isShiftHeldRef = useShiftKey();
|
const isShiftHeldRef = useShiftKey();
|
||||||
const snappingEnabled = useTimelineStore((state) => state.snappingEnabled);
|
const snappingEnabled = useTimelineStore((state) => state.snappingEnabled);
|
||||||
const rippleEditingEnabled = useTimelineStore(
|
|
||||||
(state) => state.rippleEditingEnabled,
|
|
||||||
);
|
|
||||||
|
|
||||||
const [resizing, setResizing] = useState<ResizeState | null>(null);
|
const [resizing, setResizing] = useState<ResizeState | null>(null);
|
||||||
const [currentTrimStart, setCurrentTrimStart] = useState(element.trimStart);
|
const [currentTrimStart, setCurrentTrimStart] = useState(element.trimStart);
|
||||||
@@ -458,7 +455,6 @@ export function useTimelineElementResize({
|
|||||||
trimEnd: finalTrimEnd,
|
trimEnd: finalTrimEnd,
|
||||||
startTime: startTimeChanged ? finalStartTime : undefined,
|
startTime: startTimeChanged ? finalStartTime : undefined,
|
||||||
duration: durationChanged ? finalDuration : undefined,
|
duration: durationChanged ? finalDuration : undefined,
|
||||||
rippleEnabled: rippleEditingEnabled,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,7 +467,6 @@ export function useTimelineElementResize({
|
|||||||
element.id,
|
element.id,
|
||||||
onResizeStateChange,
|
onResizeStateChange,
|
||||||
onSnapPointChange,
|
onSnapPointChange,
|
||||||
rippleEditingEnabled,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,23 +1,18 @@
|
|||||||
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 { TimelineTrack } from "@/lib/timeline";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import { rippleShiftElements } from "@/lib/timeline";
|
|
||||||
|
|
||||||
export class DeleteElementsCommand extends Command {
|
export class DeleteElementsCommand extends Command {
|
||||||
private savedState: TimelineTrack[] | null = null;
|
private savedState: TimelineTrack[] | null = null;
|
||||||
private readonly elements: { trackId: string; elementId: string }[];
|
private readonly elements: { trackId: string; elementId: string }[];
|
||||||
private readonly rippleEnabled: boolean;
|
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
elements,
|
elements,
|
||||||
rippleEnabled = false,
|
|
||||||
}: {
|
}: {
|
||||||
elements: { trackId: string; elementId: string }[];
|
elements: { trackId: string; elementId: string }[];
|
||||||
rippleEnabled?: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
super();
|
super();
|
||||||
this.elements = elements;
|
this.elements = elements;
|
||||||
this.rippleEnabled = rippleEnabled;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
@@ -25,45 +20,25 @@ export class DeleteElementsCommand extends Command {
|
|||||||
this.savedState = editor.timeline.getTracks();
|
this.savedState = editor.timeline.getTracks();
|
||||||
|
|
||||||
const updatedTracks = this.savedState.map((track) => {
|
const updatedTracks = this.savedState.map((track) => {
|
||||||
const elementsToDeleteOnTrack = this.elements.filter(
|
const elementsToDeleteOnTrack = this.elements.filter(
|
||||||
(target) => target.trackId === track.id,
|
(target) => target.trackId === track.id,
|
||||||
);
|
);
|
||||||
const hasElementsToDelete = elementsToDeleteOnTrack.length > 0;
|
|
||||||
|
|
||||||
if (!hasElementsToDelete) {
|
if (elementsToDeleteOnTrack.length === 0) {
|
||||||
return track;
|
return track;
|
||||||
}
|
}
|
||||||
|
|
||||||
const deletedElementInfos = elementsToDeleteOnTrack
|
const elements = track.elements.filter(
|
||||||
.map((target) =>
|
(element) =>
|
||||||
track.elements.find((element) => element.id === target.elementId),
|
!this.elements.some(
|
||||||
)
|
(target) =>
|
||||||
.filter((element): element is NonNullable<typeof element> => element !== undefined)
|
target.trackId === track.id &&
|
||||||
.map((element) => ({ startTime: element.startTime, duration: element.duration }));
|
target.elementId === element.id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
let elements = track.elements.filter(
|
return { ...track, elements } as typeof track;
|
||||||
(element) =>
|
});
|
||||||
!this.elements.some(
|
|
||||||
(target) =>
|
|
||||||
target.trackId === track.id && target.elementId === element.id,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (this.rippleEnabled && deletedElementInfos.length > 0) {
|
|
||||||
const sortedByStartDesc = [...deletedElementInfos].sort(
|
|
||||||
(a, b) => b.startTime - a.startTime,
|
|
||||||
);
|
|
||||||
for (const { startTime, duration } of sortedByStartDesc) {
|
|
||||||
elements = rippleShiftElements({
|
|
||||||
elements,
|
|
||||||
afterTime: startTime,
|
|
||||||
shiftAmount: duration,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...track, elements } as typeof track;
|
|
||||||
});
|
|
||||||
|
|
||||||
editor.timeline.updateTracks(updatedTracks);
|
editor.timeline.updateTracks(updatedTracks);
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +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 { UpdateElementTrimCommand } from "./update-element-trim";
|
|
||||||
export { UpdateElementDurationCommand } from "./update-element-duration";
|
|
||||||
export { UpdateElementStartTimeCommand } from "./update-element-start-time";
|
|
||||||
export { SplitElementsCommand } from "./split-elements";
|
export { SplitElementsCommand } from "./split-elements";
|
||||||
export { UpdateElementCommand } from "./update-element";
|
export { UpdateElementsCommand } from "./update-elements";
|
||||||
export { ToggleElementsVisibilityCommand } from "./toggle-elements-visibility";
|
|
||||||
export { ToggleElementsMutedCommand } from "./toggle-elements-muted";
|
|
||||||
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";
|
||||||
export * from "./retime";
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
validateElementTrackCompatibility,
|
validateElementTrackCompatibility,
|
||||||
enforceMainTrackStart,
|
enforceMainTrackStart,
|
||||||
} from "@/lib/timeline/placement";
|
} from "@/lib/timeline/placement";
|
||||||
import { rippleShiftElements } from "@/lib/timeline/ripple-utils";
|
|
||||||
|
|
||||||
export class MoveElementCommand extends Command {
|
export class MoveElementCommand extends Command {
|
||||||
private savedState: TimelineTrack[] | null = null;
|
private savedState: TimelineTrack[] | null = null;
|
||||||
@@ -19,7 +18,6 @@ export class MoveElementCommand extends Command {
|
|||||||
private readonly elementId: string;
|
private readonly elementId: string;
|
||||||
private readonly newStartTime: number;
|
private readonly newStartTime: number;
|
||||||
private readonly createTrack: { type: TrackType; index: number } | undefined;
|
private readonly createTrack: { type: TrackType; index: number } | undefined;
|
||||||
private readonly rippleEnabled: boolean;
|
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
sourceTrackId,
|
sourceTrackId,
|
||||||
@@ -27,14 +25,12 @@ export class MoveElementCommand extends Command {
|
|||||||
elementId,
|
elementId,
|
||||||
newStartTime,
|
newStartTime,
|
||||||
createTrack,
|
createTrack,
|
||||||
rippleEnabled = false,
|
|
||||||
}: {
|
}: {
|
||||||
sourceTrackId: string;
|
sourceTrackId: string;
|
||||||
targetTrackId: string;
|
targetTrackId: string;
|
||||||
elementId: string;
|
elementId: string;
|
||||||
newStartTime: number;
|
newStartTime: number;
|
||||||
createTrack?: { type: TrackType; index: number };
|
createTrack?: { type: TrackType; index: number };
|
||||||
rippleEnabled?: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
super();
|
super();
|
||||||
this.sourceTrackId = sourceTrackId;
|
this.sourceTrackId = sourceTrackId;
|
||||||
@@ -42,7 +38,6 @@ export class MoveElementCommand extends Command {
|
|||||||
this.elementId = elementId;
|
this.elementId = elementId;
|
||||||
this.newStartTime = newStartTime;
|
this.newStartTime = newStartTime;
|
||||||
this.createTrack = createTrack;
|
this.createTrack = createTrack;
|
||||||
this.rippleEnabled = rippleEnabled;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
@@ -113,14 +108,7 @@ export class MoveElementCommand extends Command {
|
|||||||
const remainingElements = track.elements.filter(
|
const remainingElements = track.elements.filter(
|
||||||
(trackElement) => trackElement.id !== this.elementId,
|
(trackElement) => trackElement.id !== this.elementId,
|
||||||
);
|
);
|
||||||
const shiftedElements = this.rippleEnabled
|
return { ...track, elements: remainingElements } as typeof track;
|
||||||
? rippleShiftElements({
|
|
||||||
elements: remainingElements,
|
|
||||||
afterTime: element.startTime,
|
|
||||||
shiftAmount: element.duration,
|
|
||||||
})
|
|
||||||
: remainingElements;
|
|
||||||
return { ...track, elements: shiftedElements } as typeof track;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (track.id === this.targetTrackId) {
|
if (track.id === this.targetTrackId) {
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export { UpdateElementRetimeCommand } from "./update-element-retime";
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
import { EditorCore } from "@/core";
|
|
||||||
import { clampRetimeRate } from "@/lib/retime/rate";
|
|
||||||
import { clampAnimationsToDuration } from "@/lib/animation";
|
|
||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
|
||||||
import { getTimelineDurationForSourceSpan, getSourceSpanAtClipTime } from "@/lib/retime";
|
|
||||||
import { isRetimableElement, updateElementInTracks } from "@/lib/timeline";
|
|
||||||
import type { RetimeConfig, TimelineTrack } from "@/lib/timeline";
|
|
||||||
|
|
||||||
function getSourceDuration({
|
|
||||||
trimStart,
|
|
||||||
trimEnd,
|
|
||||||
duration,
|
|
||||||
sourceDuration,
|
|
||||||
retime,
|
|
||||||
}: {
|
|
||||||
trimStart: number;
|
|
||||||
trimEnd: number;
|
|
||||||
duration: number;
|
|
||||||
sourceDuration?: number;
|
|
||||||
retime?: RetimeConfig;
|
|
||||||
}): number {
|
|
||||||
if (typeof sourceDuration === "number") {
|
|
||||||
return sourceDuration;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
trimStart +
|
|
||||||
getSourceSpanAtClipTime({
|
|
||||||
clipTime: duration,
|
|
||||||
retime,
|
|
||||||
}) +
|
|
||||||
trimEnd
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UpdateElementRetimeCommand extends Command {
|
|
||||||
private savedState: TimelineTrack[] | null = null;
|
|
||||||
private readonly trackId: string;
|
|
||||||
private readonly elementId: string;
|
|
||||||
private readonly retime: RetimeConfig | undefined;
|
|
||||||
|
|
||||||
constructor({
|
|
||||||
trackId,
|
|
||||||
elementId,
|
|
||||||
retime,
|
|
||||||
}: {
|
|
||||||
trackId: string;
|
|
||||||
elementId: string;
|
|
||||||
retime?: RetimeConfig;
|
|
||||||
}) {
|
|
||||||
super();
|
|
||||||
this.trackId = trackId;
|
|
||||||
this.elementId = elementId;
|
|
||||||
this.retime = retime;
|
|
||||||
}
|
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
|
||||||
const editor = EditorCore.getInstance();
|
|
||||||
this.savedState = editor.timeline.getTracks();
|
|
||||||
|
|
||||||
const updatedTracks = updateElementInTracks({
|
|
||||||
tracks: this.savedState,
|
|
||||||
trackId: this.trackId,
|
|
||||||
elementId: this.elementId,
|
|
||||||
elementPredicate: isRetimableElement,
|
|
||||||
update: (element) => {
|
|
||||||
if (!isRetimableElement(element)) {
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextRetime = this.retime
|
|
||||||
? {
|
|
||||||
...this.retime,
|
|
||||||
rate: clampRetimeRate({ rate: this.retime.rate }),
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
const sourceDuration = getSourceDuration({
|
|
||||||
trimStart: element.trimStart,
|
|
||||||
trimEnd: element.trimEnd,
|
|
||||||
duration: element.duration,
|
|
||||||
sourceDuration: element.sourceDuration,
|
|
||||||
retime: element.retime,
|
|
||||||
});
|
|
||||||
const visibleSourceSpan = Math.max(
|
|
||||||
0,
|
|
||||||
sourceDuration - element.trimStart - element.trimEnd,
|
|
||||||
);
|
|
||||||
const nextDuration = getTimelineDurationForSourceSpan({
|
|
||||||
sourceSpan: visibleSourceSpan,
|
|
||||||
retime: nextRetime,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
...element,
|
|
||||||
retime: nextRetime,
|
|
||||||
duration: nextDuration,
|
|
||||||
animations: clampAnimationsToDuration({
|
|
||||||
animations: element.animations,
|
|
||||||
duration: nextDuration,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
editor.timeline.updateTracks(updatedTracks);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
undo(): void {
|
|
||||||
if (this.savedState) {
|
|
||||||
const editor = EditorCore.getInstance();
|
|
||||||
editor.timeline.updateTracks(this.savedState);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@ import { Command, type CommandResult } from "@/lib/commands/base-command";
|
|||||||
import type { TimelineTrack } from "@/lib/timeline";
|
import type { TimelineTrack } from "@/lib/timeline";
|
||||||
import { generateUUID } from "@/utils/id";
|
import { generateUUID } from "@/utils/id";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import { isRetimableElement, rippleShiftElements } from "@/lib/timeline";
|
import { isRetimableElement } from "@/lib/timeline";
|
||||||
import { splitAnimationsAtTime } from "@/lib/animation";
|
import { splitAnimationsAtTime } from "@/lib/animation";
|
||||||
import { getSourceSpanAtClipTime } from "@/lib/retime";
|
import { getSourceSpanAtClipTime } from "@/lib/retime";
|
||||||
|
|
||||||
@@ -12,24 +12,20 @@ export class SplitElementsCommand extends Command {
|
|||||||
private readonly elements: { trackId: string; elementId: string }[];
|
private readonly elements: { trackId: string; elementId: string }[];
|
||||||
private readonly splitTime: number;
|
private readonly splitTime: number;
|
||||||
private readonly retainSide: "both" | "left" | "right";
|
private readonly retainSide: "both" | "left" | "right";
|
||||||
private readonly rippleEnabled: boolean;
|
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
elements,
|
elements,
|
||||||
splitTime,
|
splitTime,
|
||||||
retainSide = "both",
|
retainSide = "both",
|
||||||
rippleEnabled = false,
|
|
||||||
}: {
|
}: {
|
||||||
elements: { trackId: string; elementId: string }[];
|
elements: { trackId: string; elementId: string }[];
|
||||||
splitTime: number;
|
splitTime: number;
|
||||||
retainSide?: "both" | "left" | "right";
|
retainSide?: "both" | "left" | "right";
|
||||||
rippleEnabled?: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
super();
|
super();
|
||||||
this.elements = elements;
|
this.elements = elements;
|
||||||
this.splitTime = splitTime;
|
this.splitTime = splitTime;
|
||||||
this.retainSide = retainSide;
|
this.retainSide = retainSide;
|
||||||
this.rippleEnabled = rippleEnabled;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getRightSideElements(): { trackId: string; elementId: string }[] {
|
getRightSideElements(): { trackId: string; elementId: string }[] {
|
||||||
@@ -50,8 +46,6 @@ export class SplitElementsCommand extends Command {
|
|||||||
return track;
|
return track;
|
||||||
}
|
}
|
||||||
|
|
||||||
let leftVisibleDurationForRipple: number | null = null;
|
|
||||||
|
|
||||||
let elements = track.elements.flatMap((element) => {
|
let elements = track.elements.flatMap((element) => {
|
||||||
const shouldSplit = elementsToSplit.some(
|
const shouldSplit = elementsToSplit.some(
|
||||||
(target) => target.elementId === element.id,
|
(target) => target.elementId === element.id,
|
||||||
@@ -106,9 +100,6 @@ export class SplitElementsCommand extends Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.retainSide === "right") {
|
if (this.retainSide === "right") {
|
||||||
if (this.rippleEnabled && elementsToSplit.length === 1) {
|
|
||||||
leftVisibleDurationForRipple = leftVisibleDuration;
|
|
||||||
}
|
|
||||||
const newId = generateUUID();
|
const newId = generateUUID();
|
||||||
this.rightSideElements.push({
|
this.rightSideElements.push({
|
||||||
trackId: track.id,
|
trackId: track.id,
|
||||||
@@ -157,14 +148,6 @@ export class SplitElementsCommand extends Command {
|
|||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
if (this.rippleEnabled && leftVisibleDurationForRipple !== null) {
|
|
||||||
elements = rippleShiftElements({
|
|
||||||
elements,
|
|
||||||
afterTime: this.splitTime,
|
|
||||||
shiftAmount: leftVisibleDurationForRipple,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...track, elements } as typeof track;
|
return { ...track, elements } as typeof track;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
|
||||||
import type { TimelineTrack } from "@/lib/timeline";
|
|
||||||
import { canElementHaveAudio } from "@/lib/timeline/element-utils";
|
|
||||||
import { EditorCore } from "@/core";
|
|
||||||
|
|
||||||
export class ToggleElementsMutedCommand extends Command {
|
|
||||||
private savedState: TimelineTrack[] | null = null;
|
|
||||||
|
|
||||||
constructor(private elements: { trackId: string; elementId: string }[]) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
|
||||||
const editor = EditorCore.getInstance();
|
|
||||||
this.savedState = editor.timeline.getTracks();
|
|
||||||
|
|
||||||
const mutableElements = this.elements.filter(({ trackId, elementId }) => {
|
|
||||||
const track = this.savedState?.find((t) => t.id === trackId);
|
|
||||||
const element = track?.elements.find((e) => e.id === elementId);
|
|
||||||
return element && canElementHaveAudio(element);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (mutableElements.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const shouldMute = mutableElements.some(({ trackId, elementId }) => {
|
|
||||||
const track = this.savedState?.find((t) => t.id === trackId);
|
|
||||||
const element = track?.elements.find((e) => e.id === elementId);
|
|
||||||
return element && canElementHaveAudio(element) && !element.muted;
|
|
||||||
});
|
|
||||||
|
|
||||||
const updatedTracks = this.savedState.map((track) => {
|
|
||||||
const newElements = track.elements.map((element) => {
|
|
||||||
const shouldUpdate = mutableElements.some(
|
|
||||||
({ trackId, elementId }) =>
|
|
||||||
track.id === trackId && element.id === elementId,
|
|
||||||
);
|
|
||||||
return shouldUpdate &&
|
|
||||||
canElementHaveAudio(element) &&
|
|
||||||
element.muted !== shouldMute
|
|
||||||
? { ...element, muted: shouldMute }
|
|
||||||
: element;
|
|
||||||
});
|
|
||||||
return { ...track, elements: newElements } as typeof track;
|
|
||||||
});
|
|
||||||
|
|
||||||
editor.timeline.updateTracks(updatedTracks);
|
|
||||||
}
|
|
||||||
|
|
||||||
undo(): void {
|
|
||||||
if (this.savedState) {
|
|
||||||
const editor = EditorCore.getInstance();
|
|
||||||
editor.timeline.updateTracks(this.savedState);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
|
||||||
import type { TimelineTrack } from "@/lib/timeline";
|
|
||||||
import { canElementBeHidden } from "@/lib/timeline/element-utils";
|
|
||||||
import { EditorCore } from "@/core";
|
|
||||||
|
|
||||||
export class ToggleElementsVisibilityCommand extends Command {
|
|
||||||
private savedState: TimelineTrack[] | null = null;
|
|
||||||
|
|
||||||
constructor(private elements: { trackId: string; elementId: string }[]) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
|
||||||
const editor = EditorCore.getInstance();
|
|
||||||
this.savedState = editor.timeline.getTracks();
|
|
||||||
|
|
||||||
const shouldHide = this.elements.some(({ trackId, elementId }) => {
|
|
||||||
const track = this.savedState?.find((t) => t.id === trackId);
|
|
||||||
const element = track?.elements.find((e) => e.id === elementId);
|
|
||||||
return element && canElementBeHidden(element) && !element.hidden;
|
|
||||||
});
|
|
||||||
|
|
||||||
const updatedTracks = this.savedState.map((track) => {
|
|
||||||
const newElements = track.elements.map((element) => {
|
|
||||||
const shouldUpdate = this.elements.some(
|
|
||||||
({ trackId, elementId }) =>
|
|
||||||
track.id === trackId && element.id === elementId,
|
|
||||||
);
|
|
||||||
return shouldUpdate &&
|
|
||||||
canElementBeHidden(element) &&
|
|
||||||
element.hidden !== shouldHide
|
|
||||||
? { ...element, hidden: shouldHide }
|
|
||||||
: element;
|
|
||||||
});
|
|
||||||
return { ...track, elements: newElements } as typeof track;
|
|
||||||
});
|
|
||||||
|
|
||||||
editor.timeline.updateTracks(updatedTracks);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
undo(): void {
|
|
||||||
if (this.savedState) {
|
|
||||||
const editor = EditorCore.getInstance();
|
|
||||||
editor.timeline.updateTracks(this.savedState);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
|
||||||
import type { TimelineTrack } from "@/lib/timeline";
|
|
||||||
import { EditorCore } from "@/core";
|
|
||||||
import { clampAnimationsToDuration } from "@/lib/animation";
|
|
||||||
|
|
||||||
export class UpdateElementDurationCommand extends Command {
|
|
||||||
private savedState: TimelineTrack[] | null = null;
|
|
||||||
private readonly trackId: string;
|
|
||||||
private readonly elementId: string;
|
|
||||||
private readonly duration: number;
|
|
||||||
|
|
||||||
constructor({
|
|
||||||
trackId,
|
|
||||||
elementId,
|
|
||||||
duration,
|
|
||||||
}: {
|
|
||||||
trackId: string;
|
|
||||||
elementId: string;
|
|
||||||
duration: number;
|
|
||||||
}) {
|
|
||||||
super();
|
|
||||||
this.trackId = trackId;
|
|
||||||
this.elementId = elementId;
|
|
||||||
this.duration = duration;
|
|
||||||
}
|
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
|
||||||
const editor = EditorCore.getInstance();
|
|
||||||
this.savedState = editor.timeline.getTracks();
|
|
||||||
|
|
||||||
const updatedTracks = this.savedState.map((track) => {
|
|
||||||
if (track.id !== this.trackId) return track;
|
|
||||||
const newElements = track.elements.map((element) =>
|
|
||||||
element.id === this.elementId
|
|
||||||
? {
|
|
||||||
...element,
|
|
||||||
duration: this.duration,
|
|
||||||
animations: clampAnimationsToDuration({
|
|
||||||
animations: element.animations,
|
|
||||||
duration: this.duration,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
: element,
|
|
||||||
);
|
|
||||||
return { ...track, elements: newElements } as typeof track;
|
|
||||||
});
|
|
||||||
|
|
||||||
editor.timeline.updateTracks(updatedTracks);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
undo(): void {
|
|
||||||
if (this.savedState) {
|
|
||||||
const editor = EditorCore.getInstance();
|
|
||||||
editor.timeline.updateTracks(this.savedState);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
|
||||||
import type { TimelineTrack } from "@/lib/timeline";
|
|
||||||
import { EditorCore } from "@/core";
|
|
||||||
import { enforceMainTrackStart } from "@/lib/timeline/placement";
|
|
||||||
|
|
||||||
export class UpdateElementStartTimeCommand extends Command {
|
|
||||||
private savedState: TimelineTrack[] | null = null;
|
|
||||||
private readonly elements: { trackId: string; elementId: string }[];
|
|
||||||
private readonly startTime: number;
|
|
||||||
|
|
||||||
constructor({
|
|
||||||
elements,
|
|
||||||
startTime,
|
|
||||||
}: {
|
|
||||||
elements: { trackId: string; elementId: string }[];
|
|
||||||
startTime: number;
|
|
||||||
}) {
|
|
||||||
super();
|
|
||||||
this.elements = elements;
|
|
||||||
this.startTime = startTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
|
||||||
const editor = EditorCore.getInstance();
|
|
||||||
this.savedState = editor.timeline.getTracks();
|
|
||||||
|
|
||||||
const currentTracks = this.savedState;
|
|
||||||
const updatedTracks = currentTracks.map((track) => {
|
|
||||||
const hasElementsToUpdate = this.elements.some(
|
|
||||||
(elementEntry) => elementEntry.trackId === track.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!hasElementsToUpdate) {
|
|
||||||
return track;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newElements = track.elements.map((element) => {
|
|
||||||
const shouldUpdate = this.elements.some(
|
|
||||||
(elementEntry) =>
|
|
||||||
elementEntry.elementId === element.id &&
|
|
||||||
elementEntry.trackId === track.id,
|
|
||||||
);
|
|
||||||
if (!shouldUpdate) {
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseStartTime = Math.max(0, this.startTime);
|
|
||||||
const adjustedStartTime = enforceMainTrackStart({
|
|
||||||
tracks: currentTracks,
|
|
||||||
targetTrackId: track.id,
|
|
||||||
requestedStartTime: baseStartTime,
|
|
||||||
excludeElementId: element.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { ...element, startTime: adjustedStartTime };
|
|
||||||
});
|
|
||||||
return { ...track, elements: newElements } as typeof track;
|
|
||||||
});
|
|
||||||
|
|
||||||
editor.timeline.updateTracks(updatedTracks);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
undo(): void {
|
|
||||||
if (this.savedState) {
|
|
||||||
const editor = EditorCore.getInstance();
|
|
||||||
editor.timeline.updateTracks(this.savedState);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
|
||||||
import type { TimelineTrack } from "@/lib/timeline";
|
|
||||||
import { EditorCore } from "@/core";
|
|
||||||
import { clampAnimationsToDuration } from "@/lib/animation";
|
|
||||||
import { isRetimableElement, rippleShiftElements } from "@/lib/timeline";
|
|
||||||
import { enforceMainTrackStart } from "@/lib/timeline/placement";
|
|
||||||
|
|
||||||
export class UpdateElementTrimCommand extends Command {
|
|
||||||
private savedState: TimelineTrack[] | null = null;
|
|
||||||
private readonly elementId: string;
|
|
||||||
private readonly trimStart: number;
|
|
||||||
private readonly trimEnd: number;
|
|
||||||
private readonly startTime: number | undefined;
|
|
||||||
private readonly duration: number | undefined;
|
|
||||||
private readonly rippleEnabled: boolean;
|
|
||||||
|
|
||||||
constructor({
|
|
||||||
elementId,
|
|
||||||
trimStart,
|
|
||||||
trimEnd,
|
|
||||||
startTime,
|
|
||||||
duration,
|
|
||||||
rippleEnabled = false,
|
|
||||||
}: {
|
|
||||||
elementId: string;
|
|
||||||
trimStart: number;
|
|
||||||
trimEnd: number;
|
|
||||||
startTime?: number;
|
|
||||||
duration?: number;
|
|
||||||
rippleEnabled?: boolean;
|
|
||||||
}) {
|
|
||||||
super();
|
|
||||||
this.elementId = elementId;
|
|
||||||
this.trimStart = trimStart;
|
|
||||||
this.trimEnd = trimEnd;
|
|
||||||
this.startTime = startTime;
|
|
||||||
this.duration = duration;
|
|
||||||
this.rippleEnabled = rippleEnabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
|
||||||
const editor = EditorCore.getInstance();
|
|
||||||
this.savedState = editor.timeline.getTracks();
|
|
||||||
|
|
||||||
const updatedTracks = this.savedState.map((track) => {
|
|
||||||
const targetElement = track.elements.find(
|
|
||||||
(element) => element.id === this.elementId,
|
|
||||||
);
|
|
||||||
if (!targetElement) return track;
|
|
||||||
|
|
||||||
const nextDuration = this.duration ?? targetElement.duration;
|
|
||||||
const requestedStartTime = this.startTime ?? targetElement.startTime;
|
|
||||||
const nextStartTime = enforceMainTrackStart({
|
|
||||||
tracks: this.savedState ?? [],
|
|
||||||
targetTrackId: track.id,
|
|
||||||
requestedStartTime,
|
|
||||||
excludeElementId: this.elementId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const oldEndTime = targetElement.startTime + targetElement.duration;
|
|
||||||
const newEndTime = nextStartTime + nextDuration;
|
|
||||||
const shiftAmount = oldEndTime - newEndTime;
|
|
||||||
|
|
||||||
const updatedElement = {
|
|
||||||
...targetElement,
|
|
||||||
trimStart: this.trimStart,
|
|
||||||
trimEnd: this.trimEnd,
|
|
||||||
startTime: nextStartTime,
|
|
||||||
duration: nextDuration,
|
|
||||||
...(isRetimableElement(targetElement)
|
|
||||||
? { retime: targetElement.retime }
|
|
||||||
: {}),
|
|
||||||
animations: clampAnimationsToDuration({
|
|
||||||
animations: targetElement.animations,
|
|
||||||
duration: nextDuration,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.rippleEnabled && Math.abs(shiftAmount) > 0) {
|
|
||||||
const shiftedOthers = rippleShiftElements({
|
|
||||||
elements: track.elements.filter(
|
|
||||||
(element) => element.id !== this.elementId,
|
|
||||||
),
|
|
||||||
afterTime: oldEndTime,
|
|
||||||
shiftAmount,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
...track,
|
|
||||||
elements: track.elements.map((element) =>
|
|
||||||
element.id === this.elementId
|
|
||||||
? updatedElement
|
|
||||||
: (shiftedOthers.find((shifted) => shifted.id === element.id) ??
|
|
||||||
element),
|
|
||||||
),
|
|
||||||
} as typeof track;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...track,
|
|
||||||
elements: track.elements.map((element) =>
|
|
||||||
element.id === this.elementId ? updatedElement : element,
|
|
||||||
),
|
|
||||||
} as typeof track;
|
|
||||||
});
|
|
||||||
|
|
||||||
editor.timeline.updateTracks(updatedTracks);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
undo(): void {
|
|
||||||
if (this.savedState) {
|
|
||||||
const editor = EditorCore.getInstance();
|
|
||||||
editor.timeline.updateTracks(this.savedState);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
|
||||||
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
|
|
||||||
import { EditorCore } from "@/core";
|
|
||||||
import { updateElementInTracks } from "@/lib/timeline";
|
|
||||||
|
|
||||||
export class UpdateElementCommand extends Command {
|
|
||||||
private savedState: TimelineTrack[] | null = null;
|
|
||||||
private readonly trackId: string;
|
|
||||||
private readonly elementId: string;
|
|
||||||
private readonly updates: Partial<TimelineElement>;
|
|
||||||
|
|
||||||
constructor({
|
|
||||||
trackId,
|
|
||||||
elementId,
|
|
||||||
updates,
|
|
||||||
}: {
|
|
||||||
trackId: string;
|
|
||||||
elementId: string;
|
|
||||||
updates: Partial<TimelineElement>;
|
|
||||||
}) {
|
|
||||||
super();
|
|
||||||
this.trackId = trackId;
|
|
||||||
this.elementId = elementId;
|
|
||||||
this.updates = updates;
|
|
||||||
}
|
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
|
||||||
const editor = EditorCore.getInstance();
|
|
||||||
this.savedState = editor.timeline.getTracks();
|
|
||||||
|
|
||||||
const updatedTracks = updateElementInTracks({
|
|
||||||
tracks: this.savedState,
|
|
||||||
trackId: this.trackId,
|
|
||||||
elementId: this.elementId,
|
|
||||||
update: (element) => ({ ...element, ...this.updates }) as TimelineElement,
|
|
||||||
});
|
|
||||||
|
|
||||||
editor.timeline.updateTracks(updatedTracks);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
undo(): void {
|
|
||||||
if (this.savedState) {
|
|
||||||
const editor = EditorCore.getInstance();
|
|
||||||
editor.timeline.updateTracks(this.savedState);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { EditorCore } from "@/core";
|
||||||
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
|
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
|
||||||
|
import { updateElementInTracks } from "@/lib/timeline";
|
||||||
|
import { applyElementUpdate } from "@/lib/timeline/update-pipeline";
|
||||||
|
|
||||||
|
export class UpdateElementsCommand extends Command {
|
||||||
|
private savedState: TimelineTrack[] | null = null;
|
||||||
|
private readonly updates: Array<{
|
||||||
|
trackId: string;
|
||||||
|
elementId: string;
|
||||||
|
patch: Partial<TimelineElement>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
constructor({
|
||||||
|
updates,
|
||||||
|
}: {
|
||||||
|
updates: Array<{
|
||||||
|
trackId: string;
|
||||||
|
elementId: string;
|
||||||
|
patch: Partial<TimelineElement>;
|
||||||
|
}>;
|
||||||
|
}) {
|
||||||
|
super();
|
||||||
|
this.updates = updates;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(): CommandResult | undefined {
|
||||||
|
const editor = EditorCore.getInstance();
|
||||||
|
this.savedState = editor.timeline.getTracks();
|
||||||
|
let updatedTracks = this.savedState;
|
||||||
|
|
||||||
|
for (const updateEntry of this.updates) {
|
||||||
|
const currentTrack = updatedTracks.find(
|
||||||
|
(track) => track.id === updateEntry.trackId,
|
||||||
|
);
|
||||||
|
const currentElement = currentTrack?.elements.find(
|
||||||
|
(element) => element.id === updateEntry.elementId,
|
||||||
|
);
|
||||||
|
if (!currentTrack || !currentElement) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextElement = applyElementUpdate({
|
||||||
|
element: currentElement,
|
||||||
|
patch: updateEntry.patch,
|
||||||
|
context: {
|
||||||
|
tracks: updatedTracks,
|
||||||
|
trackId: updateEntry.trackId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
updatedTracks = updateElementInTracks({
|
||||||
|
tracks: updatedTracks,
|
||||||
|
trackId: updateEntry.trackId,
|
||||||
|
elementId: updateEntry.elementId,
|
||||||
|
update: () => nextElement,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.timeline.updateTracks(updatedTracks);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(): void {
|
||||||
|
if (this.savedState) {
|
||||||
|
const editor = EditorCore.getInstance();
|
||||||
|
editor.timeline.updateTracks(this.savedState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { TimelineTrack } from "@/lib/timeline/types";
|
||||||
|
import { rippleShiftElements } from "./shift";
|
||||||
|
|
||||||
|
export interface RippleAdjustment {
|
||||||
|
trackId: string;
|
||||||
|
afterTime: number;
|
||||||
|
shiftAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyRippleAdjustments({
|
||||||
|
tracks,
|
||||||
|
adjustments,
|
||||||
|
}: {
|
||||||
|
tracks: TimelineTrack[];
|
||||||
|
adjustments: RippleAdjustment[];
|
||||||
|
}): TimelineTrack[] {
|
||||||
|
if (adjustments.length === 0) {
|
||||||
|
return tracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
const adjustmentsByTrack = new Map<string, RippleAdjustment[]>();
|
||||||
|
for (const adjustment of adjustments) {
|
||||||
|
const trackAdjustments = adjustmentsByTrack.get(adjustment.trackId) ?? [];
|
||||||
|
trackAdjustments.push(adjustment);
|
||||||
|
adjustmentsByTrack.set(adjustment.trackId, trackAdjustments);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tracks.map((track) =>
|
||||||
|
applyTrackRippleAdjustments({
|
||||||
|
track,
|
||||||
|
adjustments: adjustmentsByTrack.get(track.id) ?? [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTrackRippleAdjustments<
|
||||||
|
TElement extends TimelineTrack["elements"][number],
|
||||||
|
TTrack extends TimelineTrack & { elements: TElement[] },
|
||||||
|
>({
|
||||||
|
track,
|
||||||
|
adjustments,
|
||||||
|
}: {
|
||||||
|
track: TTrack;
|
||||||
|
adjustments: RippleAdjustment[];
|
||||||
|
}): TTrack {
|
||||||
|
if (adjustments.length === 0) {
|
||||||
|
return track;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedAdjustments = [...adjustments].sort(
|
||||||
|
(firstAdjustment, secondAdjustment) =>
|
||||||
|
secondAdjustment.afterTime - firstAdjustment.afterTime,
|
||||||
|
);
|
||||||
|
|
||||||
|
let elements: TElement[] = track.elements;
|
||||||
|
for (const adjustment of sortedAdjustments) {
|
||||||
|
elements = rippleShiftElements({
|
||||||
|
elements,
|
||||||
|
afterTime: adjustment.afterTime,
|
||||||
|
shiftAmount: adjustment.shiftAmount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...track, elements };
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
||||||
|
import type { TimelineElement, TimelineTrack } from "@/lib/timeline/types";
|
||||||
|
import type { RippleAdjustment } from "./apply";
|
||||||
|
|
||||||
|
interface Interval {
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ElementSpan extends Interval {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeRippleAdjustments({
|
||||||
|
beforeTracks,
|
||||||
|
afterTracks,
|
||||||
|
}: {
|
||||||
|
beforeTracks: TimelineTrack[];
|
||||||
|
afterTracks: TimelineTrack[];
|
||||||
|
}): RippleAdjustment[] {
|
||||||
|
const afterTracksById = new Map(afterTracks.map((track) => [track.id, track]));
|
||||||
|
const allAfterElementIds = new Set(
|
||||||
|
afterTracks.flatMap((track) => track.elements.map((element) => element.id)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return beforeTracks.flatMap((beforeTrack): RippleAdjustment[] =>
|
||||||
|
computeTrackRippleAdjustments({
|
||||||
|
trackId: beforeTrack.id,
|
||||||
|
beforeElements: beforeTrack.elements,
|
||||||
|
afterElements: afterTracksById.get(beforeTrack.id)?.elements ?? [],
|
||||||
|
allAfterElementIds,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeTrackRippleAdjustments({
|
||||||
|
trackId,
|
||||||
|
beforeElements,
|
||||||
|
afterElements,
|
||||||
|
allAfterElementIds,
|
||||||
|
}: {
|
||||||
|
trackId: string;
|
||||||
|
beforeElements: TimelineElement[];
|
||||||
|
afterElements: TimelineElement[];
|
||||||
|
allAfterElementIds: Set<string>;
|
||||||
|
}): RippleAdjustment[] {
|
||||||
|
const beforeElementsById = buildElementSpanMap({ elements: beforeElements });
|
||||||
|
const afterElementsById = buildElementSpanMap({ elements: afterElements });
|
||||||
|
const { vacatedIntervals, joinedIntervals } = collectTrackIntervals({
|
||||||
|
beforeElementsById,
|
||||||
|
afterElementsById,
|
||||||
|
allAfterElementIds,
|
||||||
|
});
|
||||||
|
const freedIntervals = subtractIntervalSets({
|
||||||
|
sourceIntervals: vacatedIntervals,
|
||||||
|
overlappingIntervals: joinedIntervals,
|
||||||
|
});
|
||||||
|
|
||||||
|
return buildAdjustments({ trackId, intervals: freedIntervals });
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildElementSpanMap({
|
||||||
|
elements,
|
||||||
|
}: {
|
||||||
|
elements: TimelineElement[];
|
||||||
|
}): Map<string, ElementSpan> {
|
||||||
|
return new Map(
|
||||||
|
elements.map((element) => [
|
||||||
|
element.id,
|
||||||
|
{
|
||||||
|
id: element.id,
|
||||||
|
startTime: element.startTime,
|
||||||
|
endTime: element.startTime + element.duration,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectTrackIntervals({
|
||||||
|
beforeElementsById,
|
||||||
|
afterElementsById,
|
||||||
|
allAfterElementIds,
|
||||||
|
}: {
|
||||||
|
beforeElementsById: Map<string, ElementSpan>;
|
||||||
|
afterElementsById: Map<string, ElementSpan>;
|
||||||
|
allAfterElementIds: Set<string>;
|
||||||
|
}): {
|
||||||
|
vacatedIntervals: Interval[];
|
||||||
|
joinedIntervals: Interval[];
|
||||||
|
} {
|
||||||
|
const vacatedIntervals: Interval[] = [];
|
||||||
|
const joinedIntervals: Interval[] = [];
|
||||||
|
|
||||||
|
for (const beforeElement of beforeElementsById.values()) {
|
||||||
|
const afterElement = afterElementsById.get(beforeElement.id);
|
||||||
|
if (!afterElement) {
|
||||||
|
const wasMovedToAnotherTrack = allAfterElementIds.has(beforeElement.id);
|
||||||
|
if (!wasMovedToAnotherTrack) {
|
||||||
|
pushInterval({
|
||||||
|
intervals: vacatedIntervals,
|
||||||
|
startTime: beforeElement.startTime,
|
||||||
|
endTime: beforeElement.endTime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (beforeElement.endTime > afterElement.endTime + TIME_EPSILON_SECONDS) {
|
||||||
|
pushInterval({
|
||||||
|
intervals: vacatedIntervals,
|
||||||
|
startTime: afterElement.endTime,
|
||||||
|
endTime: beforeElement.endTime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const afterElement of afterElementsById.values()) {
|
||||||
|
if (beforeElementsById.has(afterElement.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
pushInterval({
|
||||||
|
intervals: joinedIntervals,
|
||||||
|
startTime: afterElement.startTime,
|
||||||
|
endTime: afterElement.endTime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
vacatedIntervals: normalizeIntervals({ intervals: vacatedIntervals }),
|
||||||
|
joinedIntervals: normalizeIntervals({ intervals: joinedIntervals }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAdjustments({
|
||||||
|
trackId,
|
||||||
|
intervals,
|
||||||
|
}: {
|
||||||
|
trackId: string;
|
||||||
|
intervals: Interval[];
|
||||||
|
}): RippleAdjustment[] {
|
||||||
|
return intervals.flatMap((interval): RippleAdjustment[] => {
|
||||||
|
const shiftAmount = interval.endTime - interval.startTime;
|
||||||
|
if (shiftAmount <= TIME_EPSILON_SECONDS) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
trackId,
|
||||||
|
afterTime: interval.endTime,
|
||||||
|
shiftAmount,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function subtractIntervalSets({
|
||||||
|
sourceIntervals,
|
||||||
|
overlappingIntervals,
|
||||||
|
}: {
|
||||||
|
sourceIntervals: Interval[];
|
||||||
|
overlappingIntervals: Interval[];
|
||||||
|
}): Interval[] {
|
||||||
|
const normalizedSourceIntervals = normalizeIntervals({
|
||||||
|
intervals: sourceIntervals,
|
||||||
|
});
|
||||||
|
const normalizedOverlappingIntervals = normalizeIntervals({
|
||||||
|
intervals: overlappingIntervals,
|
||||||
|
});
|
||||||
|
|
||||||
|
return normalizedSourceIntervals.flatMap((sourceInterval) =>
|
||||||
|
subtractSingleInterval({
|
||||||
|
sourceInterval,
|
||||||
|
overlappingIntervals: normalizedOverlappingIntervals,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIntervals({
|
||||||
|
intervals,
|
||||||
|
}: {
|
||||||
|
intervals: Interval[];
|
||||||
|
}): Interval[] {
|
||||||
|
const validIntervals: Interval[] = [];
|
||||||
|
for (const interval of intervals) {
|
||||||
|
pushInterval({
|
||||||
|
intervals: validIntervals,
|
||||||
|
startTime: interval.startTime,
|
||||||
|
endTime: interval.endTime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedIntervals = validIntervals.sort(
|
||||||
|
(leftInterval, rightInterval) =>
|
||||||
|
leftInterval.startTime - rightInterval.startTime,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sortedIntervals.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedIntervals: Interval[] = [{ ...sortedIntervals[0] }];
|
||||||
|
for (const interval of sortedIntervals.slice(1)) {
|
||||||
|
const previousInterval = mergedIntervals[mergedIntervals.length - 1];
|
||||||
|
if (interval.startTime <= previousInterval.endTime + TIME_EPSILON_SECONDS) {
|
||||||
|
previousInterval.endTime = Math.max(
|
||||||
|
previousInterval.endTime,
|
||||||
|
interval.endTime,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
mergedIntervals.push({ ...interval });
|
||||||
|
}
|
||||||
|
|
||||||
|
return mergedIntervals;
|
||||||
|
}
|
||||||
|
|
||||||
|
function subtractSingleInterval({
|
||||||
|
sourceInterval,
|
||||||
|
overlappingIntervals,
|
||||||
|
}: {
|
||||||
|
sourceInterval: Interval;
|
||||||
|
overlappingIntervals: Interval[];
|
||||||
|
}): Interval[] {
|
||||||
|
let remainingIntervals: Interval[] = [{ ...sourceInterval }];
|
||||||
|
|
||||||
|
for (const overlappingInterval of overlappingIntervals) {
|
||||||
|
remainingIntervals = remainingIntervals.flatMap((remainingInterval) => {
|
||||||
|
if (
|
||||||
|
overlappingInterval.endTime <=
|
||||||
|
remainingInterval.startTime + TIME_EPSILON_SECONDS ||
|
||||||
|
overlappingInterval.startTime >=
|
||||||
|
remainingInterval.endTime - TIME_EPSILON_SECONDS
|
||||||
|
) {
|
||||||
|
return [remainingInterval];
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextIntervals: Interval[] = [];
|
||||||
|
pushInterval({
|
||||||
|
intervals: nextIntervals,
|
||||||
|
startTime: remainingInterval.startTime,
|
||||||
|
endTime: overlappingInterval.startTime,
|
||||||
|
});
|
||||||
|
pushInterval({
|
||||||
|
intervals: nextIntervals,
|
||||||
|
startTime: overlappingInterval.endTime,
|
||||||
|
endTime: remainingInterval.endTime,
|
||||||
|
});
|
||||||
|
return nextIntervals;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (remainingIntervals.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return remainingIntervals;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushInterval({
|
||||||
|
intervals,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
}: { intervals: Interval[]; startTime: number; endTime: number }): void {
|
||||||
|
if (endTime - startTime <= TIME_EPSILON_SECONDS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
intervals.push({ startTime, endTime });
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export type { RippleAdjustment } from "./apply";
|
||||||
|
export { applyRippleAdjustments } from "./apply";
|
||||||
|
export { computeRippleAdjustments } from "./diff";
|
||||||
|
export { rippleShiftElements } from "./shift";
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { TimelineElement } from "@/lib/timeline/types";
|
||||||
|
|
||||||
|
export function rippleShiftElements<TElement extends TimelineElement>({
|
||||||
|
elements,
|
||||||
|
afterTime,
|
||||||
|
shiftAmount,
|
||||||
|
}: {
|
||||||
|
elements: TElement[];
|
||||||
|
afterTime: number;
|
||||||
|
shiftAmount: number;
|
||||||
|
}): TElement[] {
|
||||||
|
return elements.map((element) =>
|
||||||
|
element.startTime >= afterTime
|
||||||
|
? ({ ...element, startTime: element.startTime - shiftAmount } as TElement)
|
||||||
|
: element,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import type { AudioElement, VideoElement } from "@/lib/timeline";
|
import type { UploadAudioElement, VideoElement } from "@/lib/timeline";
|
||||||
import {
|
import {
|
||||||
buildSeparatedAudioElement,
|
buildSeparatedAudioElement,
|
||||||
doesElementHaveEnabledAudio,
|
doesElementHaveEnabledAudio,
|
||||||
@@ -80,31 +80,11 @@ describe("audio separation", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("skips source audio collection when the source clip is separated", () => {
|
test("skips source audio collection when the source clip is separated", () => {
|
||||||
const mediaAsset = {
|
const mediaAsset = { hasAudio: true };
|
||||||
id: "media-1",
|
|
||||||
type: "video",
|
|
||||||
name: "Clip",
|
|
||||||
size: 1,
|
|
||||||
lastModified: 1,
|
|
||||||
file: new File(["video"], "clip.mp4", { type: "video/mp4" }),
|
|
||||||
url: "blob:clip",
|
|
||||||
hasAudio: true,
|
|
||||||
};
|
|
||||||
const videoElement = buildVideoElement({
|
const videoElement = buildVideoElement({
|
||||||
isSourceAudioEnabled: false,
|
isSourceAudioEnabled: false,
|
||||||
});
|
});
|
||||||
const audioElement = {
|
const audioElement = buildAudioElement();
|
||||||
id: "audio-1",
|
|
||||||
type: "audio",
|
|
||||||
sourceType: "upload",
|
|
||||||
mediaId: "audio-media-1",
|
|
||||||
name: "Detached audio",
|
|
||||||
duration: 5,
|
|
||||||
startTime: 0,
|
|
||||||
trimStart: 0,
|
|
||||||
trimEnd: 0,
|
|
||||||
volume: 0,
|
|
||||||
} as AudioElement;
|
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
doesElementHaveEnabledAudio({
|
doesElementHaveEnabledAudio({
|
||||||
@@ -145,3 +125,21 @@ function buildVideoElement(
|
|||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildAudioElement(
|
||||||
|
overrides: Partial<UploadAudioElement> = {},
|
||||||
|
): UploadAudioElement {
|
||||||
|
return {
|
||||||
|
id: "audio-1",
|
||||||
|
type: "audio",
|
||||||
|
sourceType: "upload",
|
||||||
|
mediaId: "audio-media-1",
|
||||||
|
name: "Detached audio",
|
||||||
|
duration: 5,
|
||||||
|
startTime: 0,
|
||||||
|
trimStart: 0,
|
||||||
|
trimEnd: 0,
|
||||||
|
volume: 0,
|
||||||
|
...overrides,
|
||||||
|
} satisfies UploadAudioElement;
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import type {
|
|||||||
VideoElement,
|
VideoElement,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
|
||||||
|
type MediaAudioState = Pick<MediaAsset, "hasAudio">;
|
||||||
|
|
||||||
export function isSourceAudioEnabled({
|
export function isSourceAudioEnabled({
|
||||||
element,
|
element,
|
||||||
}: {
|
}: {
|
||||||
@@ -30,7 +32,7 @@ export function canExtractSourceAudio({
|
|||||||
mediaAsset,
|
mediaAsset,
|
||||||
}: {
|
}: {
|
||||||
element: TimelineElement;
|
element: TimelineElement;
|
||||||
mediaAsset: MediaAsset | null | undefined;
|
mediaAsset: MediaAudioState | null | undefined;
|
||||||
}): element is VideoElement {
|
}): element is VideoElement {
|
||||||
return (
|
return (
|
||||||
element.type === "video" &&
|
element.type === "video" &&
|
||||||
@@ -53,7 +55,7 @@ export function canToggleSourceAudio({
|
|||||||
mediaAsset,
|
mediaAsset,
|
||||||
}: {
|
}: {
|
||||||
element: TimelineElement;
|
element: TimelineElement;
|
||||||
mediaAsset: MediaAsset | null | undefined;
|
mediaAsset: MediaAudioState | null | undefined;
|
||||||
}): element is VideoElement {
|
}): element is VideoElement {
|
||||||
return (
|
return (
|
||||||
canRecoverSourceAudio({ element }) ||
|
canRecoverSourceAudio({ element }) ||
|
||||||
@@ -66,7 +68,7 @@ export function doesElementHaveEnabledAudio({
|
|||||||
mediaAsset,
|
mediaAsset,
|
||||||
}: {
|
}: {
|
||||||
element: AudioElement | VideoElement;
|
element: AudioElement | VideoElement;
|
||||||
mediaAsset?: MediaAsset | null;
|
mediaAsset?: MediaAudioState | null;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
if (element.type === "audio") {
|
if (element.type === "audio") {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ export * from "./element-utils";
|
|||||||
export * from "./audio-separation";
|
export * from "./audio-separation";
|
||||||
export * from "./zoom-utils";
|
export * from "./zoom-utils";
|
||||||
export * from "./ruler-utils";
|
export * from "./ruler-utils";
|
||||||
export * from "./ripple-utils";
|
|
||||||
export * from "./pixel-utils";
|
export * from "./pixel-utils";
|
||||||
|
|
||||||
export function calculateTotalDuration({
|
export function calculateTotalDuration({
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import type { TimelineElement } from "@/lib/timeline";
|
|
||||||
|
|
||||||
export function rippleShiftElements({
|
|
||||||
elements,
|
|
||||||
afterTime,
|
|
||||||
shiftAmount,
|
|
||||||
}: {
|
|
||||||
elements: TimelineElement[];
|
|
||||||
afterTime: number;
|
|
||||||
shiftAmount: number;
|
|
||||||
}): TimelineElement[] {
|
|
||||||
return elements.map((element) =>
|
|
||||||
element.startTime >= afterTime
|
|
||||||
? { ...element, startTime: element.startTime - shiftAmount }
|
|
||||||
: element,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { clampAnimationsToDuration } from "@/lib/animation";
|
||||||
|
import {
|
||||||
|
clampRetimeRate,
|
||||||
|
getSourceSpanAtClipTime,
|
||||||
|
getTimelineDurationForSourceSpan,
|
||||||
|
} from "@/lib/retime";
|
||||||
|
import { enforceMainTrackStart } from "@/lib/timeline/placement";
|
||||||
|
import type { RetimeConfig, TimelineElement, TimelineTrack } from "@/lib/timeline";
|
||||||
|
import { isRetimableElement } from "@/lib/timeline";
|
||||||
|
|
||||||
|
type ElementUpdateField = keyof TimelineElement;
|
||||||
|
|
||||||
|
export interface ElementUpdateContext {
|
||||||
|
tracks: TimelineTrack[];
|
||||||
|
trackId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ElementUpdateRuleResult {
|
||||||
|
element: TimelineElement;
|
||||||
|
changedFields?: ElementUpdateField[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ElementUpdateRuleParams {
|
||||||
|
element: TimelineElement;
|
||||||
|
originalElement: TimelineElement;
|
||||||
|
patch: Partial<TimelineElement>;
|
||||||
|
context: ElementUpdateContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ElementUpdateRule {
|
||||||
|
triggers: ElementUpdateField[];
|
||||||
|
apply: (params: ElementUpdateRuleParams) => ElementUpdateRuleResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deriveRules: ElementUpdateRule[] = [
|
||||||
|
{
|
||||||
|
triggers: ["retime"],
|
||||||
|
apply: ({ element, originalElement, patch }) => {
|
||||||
|
if (!("retime" in patch) || !isRetimableElement(element)) {
|
||||||
|
return { element };
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextRetime = patch.retime
|
||||||
|
? {
|
||||||
|
...patch.retime,
|
||||||
|
rate: clampRetimeRate({ rate: patch.retime.rate }),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const sourceDuration = getSourceDuration({
|
||||||
|
trimStart: originalElement.trimStart,
|
||||||
|
trimEnd: originalElement.trimEnd,
|
||||||
|
duration: originalElement.duration,
|
||||||
|
sourceDuration: isRetimableElement(originalElement)
|
||||||
|
? originalElement.sourceDuration
|
||||||
|
: undefined,
|
||||||
|
retime: isRetimableElement(originalElement)
|
||||||
|
? originalElement.retime
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
const visibleSourceSpan = Math.max(
|
||||||
|
0,
|
||||||
|
sourceDuration - element.trimStart - element.trimEnd,
|
||||||
|
);
|
||||||
|
const nextDuration = getTimelineDurationForSourceSpan({
|
||||||
|
sourceSpan: visibleSourceSpan,
|
||||||
|
retime: nextRetime,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
element: {
|
||||||
|
...element,
|
||||||
|
retime: nextRetime,
|
||||||
|
duration: nextDuration,
|
||||||
|
},
|
||||||
|
changedFields: ["retime", "duration"],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const enforceRules: ElementUpdateRule[] = [
|
||||||
|
{
|
||||||
|
triggers: ["duration"],
|
||||||
|
apply: ({ element }) => ({
|
||||||
|
element: {
|
||||||
|
...element,
|
||||||
|
animations: clampAnimationsToDuration({
|
||||||
|
animations: element.animations,
|
||||||
|
duration: element.duration,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
triggers: ["startTime"],
|
||||||
|
apply: ({ element, context }) => ({
|
||||||
|
element: {
|
||||||
|
...element,
|
||||||
|
startTime: enforceMainTrackStart({
|
||||||
|
tracks: context.tracks,
|
||||||
|
targetTrackId: context.trackId,
|
||||||
|
requestedStartTime: Math.max(0, element.startTime),
|
||||||
|
excludeElementId: element.id,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function applyElementUpdate({
|
||||||
|
element,
|
||||||
|
patch,
|
||||||
|
context,
|
||||||
|
}: {
|
||||||
|
element: TimelineElement;
|
||||||
|
patch: Partial<TimelineElement>;
|
||||||
|
context: ElementUpdateContext;
|
||||||
|
}): TimelineElement {
|
||||||
|
let nextElement = { ...element, ...patch } as TimelineElement;
|
||||||
|
const changedFields = new Set(
|
||||||
|
Object.keys(patch) as ElementUpdateField[],
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const rule of deriveRules) {
|
||||||
|
if (!shouldApplyRule({ rule, changedFields })) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = rule.apply({
|
||||||
|
element: nextElement,
|
||||||
|
originalElement: element,
|
||||||
|
patch,
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
nextElement = result.element;
|
||||||
|
for (const field of result.changedFields ?? []) {
|
||||||
|
changedFields.add(field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rule of enforceRules) {
|
||||||
|
if (!shouldApplyRule({ rule, changedFields })) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextElement = rule.apply({
|
||||||
|
element: nextElement,
|
||||||
|
originalElement: element,
|
||||||
|
patch,
|
||||||
|
context,
|
||||||
|
}).element;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldApplyRule({
|
||||||
|
rule,
|
||||||
|
changedFields,
|
||||||
|
}: {
|
||||||
|
rule: ElementUpdateRule;
|
||||||
|
changedFields: Set<ElementUpdateField>;
|
||||||
|
}): boolean {
|
||||||
|
return rule.triggers.some((trigger) => changedFields.has(trigger));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSourceDuration({
|
||||||
|
trimStart,
|
||||||
|
trimEnd,
|
||||||
|
duration,
|
||||||
|
sourceDuration,
|
||||||
|
retime,
|
||||||
|
}: {
|
||||||
|
trimStart: number;
|
||||||
|
trimEnd: number;
|
||||||
|
duration: number;
|
||||||
|
sourceDuration?: number;
|
||||||
|
retime?: RetimeConfig;
|
||||||
|
}): number {
|
||||||
|
if (typeof sourceDuration === "number") {
|
||||||
|
return sourceDuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
trimStart +
|
||||||
|
getSourceSpanAtClipTime({
|
||||||
|
clipTime: duration,
|
||||||
|
retime,
|
||||||
|
}) +
|
||||||
|
trimEnd
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"turbo": "^2.8.20",
|
"turbo": "^2.8.20",
|
||||||
"typescript": "5.8.3",
|
"typescript": "^6.0.2",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"apps/web": {
|
"apps/web": {
|
||||||
@@ -1601,7 +1601,7 @@
|
|||||||
|
|
||||||
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
|
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
|
||||||
|
|
||||||
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
|
"typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="],
|
||||||
|
|
||||||
"uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="],
|
"uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="],
|
||||||
|
|
||||||
@@ -1723,6 +1723,8 @@
|
|||||||
|
|
||||||
"@node-minify/core/mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="],
|
"@node-minify/core/mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="],
|
||||||
|
|
||||||
|
"@opencut/web/typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
|
||||||
|
|
||||||
"@opennextjs/aws/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="],
|
"@opennextjs/aws/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="],
|
||||||
|
|
||||||
"@radix-ui/react-accordion/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
"@radix-ui/react-accordion/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||||
|
|||||||
+1
-1
@@ -31,7 +31,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"turbo": "^2.8.20",
|
"turbo": "^2.8.20",
|
||||||
"typescript": "5.8.3"
|
"typescript": "^6.0.2"
|
||||||
},
|
},
|
||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
"@tailwindcss/oxide"
|
"@tailwindcss/oxide"
|
||||||
|
|||||||
Reference in New Issue
Block a user