refactor: unify element updates into pipeline and centralize ripple
This commit is contained in:
parent
20235d0724
commit
44d8c4c876
|
|
@ -144,7 +144,7 @@ export function useKeyframedNumberProperty({
|
|||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: buildBaseUpdates({ value: nextValue }),
|
||||
patch: buildBaseUpdates({ value: nextValue }),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -152,7 +152,7 @@ export function useKeyframedVectorProperty({
|
|||
}
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{ trackId, elementId, updates: buildBaseUpdates({ value: vector }) },
|
||||
{ trackId, elementId, patch: buildBaseUpdates({ value: vector }) },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
|
@ -172,7 +172,7 @@ export function useKeyframedVectorProperty({
|
|||
}
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{ trackId, elementId, updates: buildBaseUpdates({ value: vector }) },
|
||||
{ trackId, elementId, patch: buildBaseUpdates({ value: vector }) },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ type BlendingElement = {
|
|||
animations?: ElementAnimations;
|
||||
};
|
||||
|
||||
const BLEND_MODE_GROUPS = [
|
||||
const BLEND_MODE_GROUPS: { value: BlendMode; label: string }[][] = [
|
||||
[{ value: "normal", label: "Normal" }],
|
||||
[
|
||||
{ value: "darken", label: "Darken" },
|
||||
|
|
@ -99,7 +99,7 @@ export function BlendingTab({
|
|||
],
|
||||
});
|
||||
|
||||
const commitBlendMode = (value: string) => {
|
||||
const commitBlendMode = (value: BlendMode) => {
|
||||
if (editor.timeline.isPreviewActive()) {
|
||||
editor.timeline.commitPreview();
|
||||
} else {
|
||||
|
|
@ -108,7 +108,7 @@ export function BlendingTab({
|
|||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { blendMode: value as BlendMode },
|
||||
patch: { blendMode: value },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -209,7 +209,7 @@ export function BlendingTab({
|
|||
key={option.value}
|
||||
value={option.value}
|
||||
onPointerEnter={() =>
|
||||
previewBlendMode({ value: option.value as BlendMode })
|
||||
previewBlendMode({ value: option.value })
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ function StrokeSection({
|
|||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { params: { ...element.params, strokeWidth: 0 } },
|
||||
patch: { params: { ...element.params, strokeWidth: 0 } },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -133,7 +133,7 @@ function StrokeSection({
|
|||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
patch: {
|
||||
params: {
|
||||
...element.params,
|
||||
strokeWidth: lastStrokeWidth.current,
|
||||
|
|
|
|||
|
|
@ -165,7 +165,7 @@ export function MasksTab({ element, trackId }: MasksTabProps) {
|
|||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
patch: {
|
||||
masks: [
|
||||
buildDefaultMaskInstance({
|
||||
maskType,
|
||||
|
|
|
|||
|
|
@ -165,7 +165,7 @@ function TypographySection({
|
|||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { fontFamily: value },
|
||||
patch: { fontFamily: value },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
|
@ -188,7 +188,7 @@ function TypographySection({
|
|||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
patch: {
|
||||
fontSize: DEFAULTS.text.element.fontSize,
|
||||
},
|
||||
},
|
||||
|
|
@ -291,7 +291,7 @@ function SpacingSection({
|
|||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { letterSpacing: DEFAULTS.text.letterSpacing },
|
||||
patch: { letterSpacing: DEFAULTS.text.letterSpacing },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
|
@ -317,7 +317,7 @@ function SpacingSection({
|
|||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { lineHeight: DEFAULTS.text.lineHeight },
|
||||
patch: { lineHeight: DEFAULTS.text.lineHeight },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
|
@ -512,7 +512,7 @@ function BackgroundSection({
|
|||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
patch: {
|
||||
background: {
|
||||
...element.background,
|
||||
enabled,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { EditorCore } from "@/core";
|
|||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useKeybindingsListener } from "@/hooks/use-keybindings";
|
||||
import { useKeybindingsStore } from "@/stores/keybindings-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useEditorActions } from "@/hooks/actions/use-editor-actions";
|
||||
import { loadFontAtlas } from "@/lib/fonts/google-fonts";
|
||||
import { initializeGpuRenderer } from "@/services/renderer/gpu-renderer";
|
||||
|
|
@ -117,6 +118,13 @@ export function EditorProvider({ projectId, children }: EditorProviderProps) {
|
|||
|
||||
function EditorRuntimeBindings() {
|
||||
const editor = useEditor();
|
||||
const rippleEditingEnabled = useTimelineStore(
|
||||
(state) => state.rippleEditingEnabled,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
editor.command.isRippleEnabled = rippleEditingEnabled;
|
||||
}, [editor, rippleEditingEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { EditorCore } from "@/core";
|
||||
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 {
|
||||
command: Command;
|
||||
|
|
@ -9,6 +10,7 @@ interface CommandHistoryEntry {
|
|||
}
|
||||
|
||||
export class CommandManager {
|
||||
public isRippleEnabled = false;
|
||||
private history: CommandHistoryEntry[] = [];
|
||||
private redoStack: CommandHistoryEntry[] = [];
|
||||
private reactors: Array<() => void> = [];
|
||||
|
|
@ -16,8 +18,10 @@ export class CommandManager {
|
|||
constructor(private editor: EditorCore) {}
|
||||
|
||||
execute({ command }: { command: Command }): Command {
|
||||
const beforeTracks = this.isRippleEnabled ? this.editor.timeline.getTracks() : null;
|
||||
const previousSelection = this.getSelectionSnapshot();
|
||||
const result = command.execute();
|
||||
this.applyRippleIfEnabled({ beforeTracks });
|
||||
const selectionOverride = this.applySelectionOverride(result);
|
||||
this.runReactors();
|
||||
this.history.push({
|
||||
|
|
@ -67,8 +71,10 @@ export class CommandManager {
|
|||
return;
|
||||
}
|
||||
|
||||
const beforeTracks = this.isRippleEnabled ? this.editor.timeline.getTracks() : null;
|
||||
const previousSelection = this.getSelectionSnapshot();
|
||||
const result = entry.command.redo();
|
||||
this.applyRippleIfEnabled({ beforeTracks });
|
||||
const selectionOverride = this.applySelectionOverride(result);
|
||||
this.runReactors();
|
||||
|
||||
|
|
@ -113,4 +119,29 @@ export class CommandManager {
|
|||
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,
|
||||
RetimeConfig,
|
||||
} from "@/lib/timeline";
|
||||
import { calculateTotalDuration } from "@/lib/timeline";
|
||||
import {
|
||||
canElementBeHidden,
|
||||
canElementHaveAudio,
|
||||
} from "@/lib/timeline/element-utils";
|
||||
import type {
|
||||
AnimationPath,
|
||||
AnimationInterpolation,
|
||||
AnimationValue,
|
||||
} from "@/lib/animation/types";
|
||||
import { calculateTotalDuration } from "@/lib/timeline";
|
||||
import { getLastFrameTime } from "opencut-wasm";
|
||||
import { BatchCommand } from "@/lib/commands";
|
||||
import {
|
||||
AddTrackCommand,
|
||||
RemoveTrackCommand,
|
||||
ToggleTrackMuteCommand,
|
||||
ToggleTrackVisibilityCommand,
|
||||
InsertElementCommand,
|
||||
UpdateElementTrimCommand,
|
||||
UpdateElementDurationCommand,
|
||||
DeleteElementsCommand,
|
||||
DuplicateElementsCommand,
|
||||
ToggleElementsVisibilityCommand,
|
||||
ToggleElementsMutedCommand,
|
||||
UpdateElementCommand,
|
||||
UpdateElementsCommand,
|
||||
SplitElementsCommand,
|
||||
PasteCommand,
|
||||
UpdateElementStartTimeCommand,
|
||||
MoveElementCommand,
|
||||
TracksSnapshotCommand,
|
||||
UpdateElementRetimeCommand,
|
||||
UpsertKeyframeCommand,
|
||||
RemoveKeyframeCommand,
|
||||
RetimeKeyframeCommand,
|
||||
|
|
@ -47,7 +46,6 @@ import {
|
|||
RemoveEffectParamKeyframeCommand,
|
||||
ToggleSourceAudioSeparationCommand,
|
||||
} from "@/lib/commands/timeline";
|
||||
import { BatchCommand } from "@/lib/commands";
|
||||
import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element";
|
||||
|
||||
export class TimelineManager {
|
||||
|
|
@ -80,7 +78,6 @@ export class TimelineManager {
|
|||
startTime,
|
||||
duration,
|
||||
pushHistory = true,
|
||||
rippleEnabled = false,
|
||||
}: {
|
||||
elementId: string;
|
||||
trimStart: number;
|
||||
|
|
@ -88,44 +85,33 @@ export class TimelineManager {
|
|||
startTime?: number;
|
||||
duration?: number;
|
||||
pushHistory?: boolean;
|
||||
rippleEnabled?: boolean;
|
||||
}): void {
|
||||
const command = new UpdateElementTrimCommand({
|
||||
elementId,
|
||||
const trackId = this.findTrackIdForElement({ elementId });
|
||||
if (!trackId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextUpdates: Partial<TimelineElement> = {
|
||||
trimStart,
|
||||
trimEnd,
|
||||
startTime,
|
||||
duration,
|
||||
rippleEnabled,
|
||||
});
|
||||
if (pushHistory) {
|
||||
this.editor.command.execute({ command });
|
||||
} else {
|
||||
command.execute();
|
||||
};
|
||||
if (startTime !== undefined) {
|
||||
nextUpdates.startTime = startTime;
|
||||
}
|
||||
if (duration !== undefined) {
|
||||
nextUpdates.duration = duration;
|
||||
}
|
||||
}
|
||||
|
||||
updateElementDuration({
|
||||
trackId,
|
||||
elementId,
|
||||
duration,
|
||||
pushHistory = true,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
duration: number;
|
||||
pushHistory?: boolean;
|
||||
}): void {
|
||||
const command = new UpdateElementDurationCommand({
|
||||
trackId,
|
||||
elementId,
|
||||
duration,
|
||||
this.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
patch: nextUpdates,
|
||||
},
|
||||
],
|
||||
pushHistory,
|
||||
});
|
||||
if (pushHistory) {
|
||||
this.editor.command.execute({ command });
|
||||
} else {
|
||||
command.execute();
|
||||
}
|
||||
}
|
||||
|
||||
updateElementRetime({
|
||||
|
|
@ -139,30 +125,18 @@ export class TimelineManager {
|
|||
retime?: RetimeConfig;
|
||||
pushHistory?: boolean;
|
||||
}): void {
|
||||
const command = new UpdateElementRetimeCommand({
|
||||
trackId,
|
||||
elementId,
|
||||
retime,
|
||||
this.updateElements({
|
||||
updates: [
|
||||
{
|
||||
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({
|
||||
|
|
@ -171,14 +145,12 @@ export class TimelineManager {
|
|||
elementId,
|
||||
newStartTime,
|
||||
createTrack,
|
||||
rippleEnabled = false,
|
||||
}: {
|
||||
sourceTrackId: string;
|
||||
targetTrackId: string;
|
||||
elementId: string;
|
||||
newStartTime: number;
|
||||
createTrack?: { type: TrackType; index: number };
|
||||
rippleEnabled?: boolean;
|
||||
}): void {
|
||||
const command = new MoveElementCommand({
|
||||
sourceTrackId,
|
||||
|
|
@ -186,7 +158,6 @@ export class TimelineManager {
|
|||
elementId,
|
||||
newStartTime,
|
||||
createTrack,
|
||||
rippleEnabled,
|
||||
});
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
|
@ -205,18 +176,15 @@ export class TimelineManager {
|
|||
elements,
|
||||
splitTime,
|
||||
retainSide = "both",
|
||||
rippleEnabled = false,
|
||||
}: {
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
splitTime: number;
|
||||
retainSide?: "both" | "left" | "right";
|
||||
rippleEnabled?: boolean;
|
||||
}): { trackId: string; elementId: string }[] {
|
||||
const command = new SplitElementsCommand({
|
||||
elements,
|
||||
splitTime,
|
||||
retainSide,
|
||||
rippleEnabled,
|
||||
});
|
||||
this.editor.command.execute({ command });
|
||||
return command.getRightSideElements();
|
||||
|
|
@ -273,12 +241,10 @@ export class TimelineManager {
|
|||
|
||||
deleteElements({
|
||||
elements,
|
||||
rippleEnabled = false,
|
||||
}: {
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
rippleEnabled?: boolean;
|
||||
}): void {
|
||||
const command = new DeleteElementsCommand({ elements, rippleEnabled });
|
||||
const command = new DeleteElementsCommand({ elements });
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
|
|
@ -303,20 +269,17 @@ export class TimelineManager {
|
|||
updates: Array<{
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
updates: Partial<TimelineElement>;
|
||||
patch: Partial<TimelineElement>;
|
||||
}>;
|
||||
pushHistory?: boolean;
|
||||
}): void {
|
||||
const commands = updates.map(
|
||||
({ trackId, elementId, updates: elementUpdates }) =>
|
||||
new UpdateElementCommand({
|
||||
trackId,
|
||||
elementId,
|
||||
updates: elementUpdates,
|
||||
}),
|
||||
);
|
||||
const command =
|
||||
commands.length === 1 ? commands[0] : new BatchCommand(commands);
|
||||
if (updates.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const command = new UpdateElementsCommand({
|
||||
updates,
|
||||
});
|
||||
if (pushHistory) {
|
||||
this.editor.command.execute({ command });
|
||||
} else {
|
||||
|
|
@ -679,8 +642,27 @@ export class TimelineManager {
|
|||
}: {
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
}): void {
|
||||
const command = new ToggleElementsVisibilityCommand(elements);
|
||||
this.editor.command.execute({ command });
|
||||
const shouldHide = elements.some(({ trackId, elementId }) => {
|
||||
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({
|
||||
|
|
@ -688,8 +670,27 @@ export class TimelineManager {
|
|||
}: {
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
}): void {
|
||||
const command = new ToggleElementsMutedCommand(elements);
|
||||
this.editor.command.execute({ command });
|
||||
const shouldMute = elements.some(({ trackId, elementId }) => {
|
||||
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[] {
|
||||
|
|
@ -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 {
|
||||
this.previewOverlay.clear();
|
||||
this.previewTracks = null;
|
||||
|
|
|
|||
|
|
@ -212,7 +212,6 @@ export function useEditorActions() {
|
|||
elements: elementsToSplit,
|
||||
splitTime: currentTime,
|
||||
retainSide: "right",
|
||||
rippleEnabled: rippleEditingEnabled,
|
||||
});
|
||||
|
||||
if (rippleEditingEnabled && rightSideElements.length > 0) {
|
||||
|
|
@ -263,7 +262,6 @@ export function useEditorActions() {
|
|||
}
|
||||
editor.timeline.deleteElements({
|
||||
elements: selectedElements,
|
||||
rippleEnabled: rippleEditingEnabled,
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import {
|
|||
} from "react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
||||
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
|
||||
|
|
@ -161,7 +160,6 @@ export function useElementInteraction({
|
|||
onSnapPointChange,
|
||||
}: UseElementInteractionProps) {
|
||||
const editor = useEditor();
|
||||
const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
|
||||
const isShiftHeldRef = useShiftKey();
|
||||
const tracks = editor.timeline.getTracks();
|
||||
const {
|
||||
|
|
@ -467,7 +465,6 @@ export function useElementInteraction({
|
|||
elementId: dragState.elementId,
|
||||
newStartTime: snappedTime,
|
||||
createTrack: { type: sourceTrack.type, index: dropTarget.trackIndex },
|
||||
rippleEnabled: rippleEditingEnabled,
|
||||
});
|
||||
selectElement({ trackId: newTrackId, elementId: dragState.elementId });
|
||||
} else {
|
||||
|
|
@ -478,7 +475,6 @@ export function useElementInteraction({
|
|||
targetTrackId: targetTrack.id,
|
||||
elementId: dragState.elementId,
|
||||
newStartTime: snappedTime,
|
||||
rippleEnabled: rippleEditingEnabled,
|
||||
});
|
||||
if (targetTrack.id !== dragState.trackId) {
|
||||
selectElement({
|
||||
|
|
@ -509,7 +505,6 @@ export function useElementInteraction({
|
|||
tracksContainerRef,
|
||||
tracksScrollRef,
|
||||
headerRef,
|
||||
rippleEditingEnabled,
|
||||
selectElement,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -45,9 +45,6 @@ export function useTimelineElementResize({
|
|||
const editor = useEditor();
|
||||
const isShiftHeldRef = useShiftKey();
|
||||
const snappingEnabled = useTimelineStore((state) => state.snappingEnabled);
|
||||
const rippleEditingEnabled = useTimelineStore(
|
||||
(state) => state.rippleEditingEnabled,
|
||||
);
|
||||
|
||||
const [resizing, setResizing] = useState<ResizeState | null>(null);
|
||||
const [currentTrimStart, setCurrentTrimStart] = useState(element.trimStart);
|
||||
|
|
@ -458,7 +455,6 @@ export function useTimelineElementResize({
|
|||
trimEnd: finalTrimEnd,
|
||||
startTime: startTimeChanged ? finalStartTime : undefined,
|
||||
duration: durationChanged ? finalDuration : undefined,
|
||||
rippleEnabled: rippleEditingEnabled,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -471,7 +467,6 @@ export function useTimelineElementResize({
|
|||
element.id,
|
||||
onResizeStateChange,
|
||||
onSnapPointChange,
|
||||
rippleEditingEnabled,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -1,23 +1,18 @@
|
|||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||
import type { TimelineTrack } from "@/lib/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
import { rippleShiftElements } from "@/lib/timeline";
|
||||
|
||||
export class DeleteElementsCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly elements: { trackId: string; elementId: string }[];
|
||||
private readonly rippleEnabled: boolean;
|
||||
|
||||
constructor({
|
||||
elements,
|
||||
rippleEnabled = false,
|
||||
}: {
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
rippleEnabled?: boolean;
|
||||
}) {
|
||||
super();
|
||||
this.elements = elements;
|
||||
this.rippleEnabled = rippleEnabled;
|
||||
}
|
||||
|
||||
execute(): CommandResult | undefined {
|
||||
|
|
@ -25,45 +20,25 @@ export class DeleteElementsCommand extends Command {
|
|||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = this.savedState.map((track) => {
|
||||
const elementsToDeleteOnTrack = this.elements.filter(
|
||||
(target) => target.trackId === track.id,
|
||||
);
|
||||
const hasElementsToDelete = elementsToDeleteOnTrack.length > 0;
|
||||
const elementsToDeleteOnTrack = this.elements.filter(
|
||||
(target) => target.trackId === track.id,
|
||||
);
|
||||
|
||||
if (!hasElementsToDelete) {
|
||||
return track;
|
||||
}
|
||||
if (elementsToDeleteOnTrack.length === 0) {
|
||||
return track;
|
||||
}
|
||||
|
||||
const deletedElementInfos = elementsToDeleteOnTrack
|
||||
.map((target) =>
|
||||
track.elements.find((element) => element.id === target.elementId),
|
||||
)
|
||||
.filter((element): element is NonNullable<typeof element> => element !== undefined)
|
||||
.map((element) => ({ startTime: element.startTime, duration: element.duration }));
|
||||
const elements = track.elements.filter(
|
||||
(element) =>
|
||||
!this.elements.some(
|
||||
(target) =>
|
||||
target.trackId === track.id &&
|
||||
target.elementId === element.id,
|
||||
),
|
||||
);
|
||||
|
||||
let elements = track.elements.filter(
|
||||
(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;
|
||||
});
|
||||
return { ...track, elements } as typeof track;
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,11 @@
|
|||
export { InsertElementCommand } from "./insert-element";
|
||||
export { DeleteElementsCommand } from "./delete-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 { UpdateElementCommand } from "./update-element";
|
||||
export { ToggleElementsVisibilityCommand } from "./toggle-elements-visibility";
|
||||
export { ToggleElementsMutedCommand } from "./toggle-elements-muted";
|
||||
export { UpdateElementsCommand } from "./update-elements";
|
||||
export { ToggleSourceAudioSeparationCommand } from "./toggle-source-audio-separation";
|
||||
export { MoveElementCommand } from "./move-elements";
|
||||
|
||||
export * from "./keyframes";
|
||||
export * from "./effects";
|
||||
export * from "./masks";
|
||||
export * from "./retime";
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import {
|
|||
validateElementTrackCompatibility,
|
||||
enforceMainTrackStart,
|
||||
} from "@/lib/timeline/placement";
|
||||
import { rippleShiftElements } from "@/lib/timeline/ripple-utils";
|
||||
|
||||
export class MoveElementCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
|
|
@ -19,7 +18,6 @@ export class MoveElementCommand extends Command {
|
|||
private readonly elementId: string;
|
||||
private readonly newStartTime: number;
|
||||
private readonly createTrack: { type: TrackType; index: number } | undefined;
|
||||
private readonly rippleEnabled: boolean;
|
||||
|
||||
constructor({
|
||||
sourceTrackId,
|
||||
|
|
@ -27,14 +25,12 @@ export class MoveElementCommand extends Command {
|
|||
elementId,
|
||||
newStartTime,
|
||||
createTrack,
|
||||
rippleEnabled = false,
|
||||
}: {
|
||||
sourceTrackId: string;
|
||||
targetTrackId: string;
|
||||
elementId: string;
|
||||
newStartTime: number;
|
||||
createTrack?: { type: TrackType; index: number };
|
||||
rippleEnabled?: boolean;
|
||||
}) {
|
||||
super();
|
||||
this.sourceTrackId = sourceTrackId;
|
||||
|
|
@ -42,7 +38,6 @@ export class MoveElementCommand extends Command {
|
|||
this.elementId = elementId;
|
||||
this.newStartTime = newStartTime;
|
||||
this.createTrack = createTrack;
|
||||
this.rippleEnabled = rippleEnabled;
|
||||
}
|
||||
|
||||
execute(): CommandResult | undefined {
|
||||
|
|
@ -113,14 +108,7 @@ export class MoveElementCommand extends Command {
|
|||
const remainingElements = track.elements.filter(
|
||||
(trackElement) => trackElement.id !== this.elementId,
|
||||
);
|
||||
const shiftedElements = this.rippleEnabled
|
||||
? rippleShiftElements({
|
||||
elements: remainingElements,
|
||||
afterTime: element.startTime,
|
||||
shiftAmount: element.duration,
|
||||
})
|
||||
: remainingElements;
|
||||
return { ...track, elements: shiftedElements } as typeof track;
|
||||
return { ...track, elements: remainingElements } as typeof track;
|
||||
}
|
||||
|
||||
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 { generateUUID } from "@/utils/id";
|
||||
import { EditorCore } from "@/core";
|
||||
import { isRetimableElement, rippleShiftElements } from "@/lib/timeline";
|
||||
import { isRetimableElement } from "@/lib/timeline";
|
||||
import { splitAnimationsAtTime } from "@/lib/animation";
|
||||
import { getSourceSpanAtClipTime } from "@/lib/retime";
|
||||
|
||||
|
|
@ -12,24 +12,20 @@ export class SplitElementsCommand extends Command {
|
|||
private readonly elements: { trackId: string; elementId: string }[];
|
||||
private readonly splitTime: number;
|
||||
private readonly retainSide: "both" | "left" | "right";
|
||||
private readonly rippleEnabled: boolean;
|
||||
|
||||
constructor({
|
||||
elements,
|
||||
splitTime,
|
||||
retainSide = "both",
|
||||
rippleEnabled = false,
|
||||
}: {
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
splitTime: number;
|
||||
retainSide?: "both" | "left" | "right";
|
||||
rippleEnabled?: boolean;
|
||||
}) {
|
||||
super();
|
||||
this.elements = elements;
|
||||
this.splitTime = splitTime;
|
||||
this.retainSide = retainSide;
|
||||
this.rippleEnabled = rippleEnabled;
|
||||
}
|
||||
|
||||
getRightSideElements(): { trackId: string; elementId: string }[] {
|
||||
|
|
@ -50,8 +46,6 @@ export class SplitElementsCommand extends Command {
|
|||
return track;
|
||||
}
|
||||
|
||||
let leftVisibleDurationForRipple: number | null = null;
|
||||
|
||||
let elements = track.elements.flatMap((element) => {
|
||||
const shouldSplit = elementsToSplit.some(
|
||||
(target) => target.elementId === element.id,
|
||||
|
|
@ -106,9 +100,6 @@ export class SplitElementsCommand extends Command {
|
|||
}
|
||||
|
||||
if (this.retainSide === "right") {
|
||||
if (this.rippleEnabled && elementsToSplit.length === 1) {
|
||||
leftVisibleDurationForRipple = leftVisibleDuration;
|
||||
}
|
||||
const newId = generateUUID();
|
||||
this.rightSideElements.push({
|
||||
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;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 type { AudioElement, VideoElement } from "@/lib/timeline";
|
||||
import type { UploadAudioElement, VideoElement } from "@/lib/timeline";
|
||||
import {
|
||||
buildSeparatedAudioElement,
|
||||
doesElementHaveEnabledAudio,
|
||||
|
|
@ -80,31 +80,11 @@ describe("audio separation", () => {
|
|||
});
|
||||
|
||||
test("skips source audio collection when the source clip is separated", () => {
|
||||
const mediaAsset = {
|
||||
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 mediaAsset = { hasAudio: true };
|
||||
const videoElement = buildVideoElement({
|
||||
isSourceAudioEnabled: false,
|
||||
});
|
||||
const audioElement = {
|
||||
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;
|
||||
const audioElement = buildAudioElement();
|
||||
|
||||
expect(
|
||||
doesElementHaveEnabledAudio({
|
||||
|
|
@ -145,3 +125,21 @@ function buildVideoElement(
|
|||
...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,
|
||||
} from "../types";
|
||||
|
||||
type MediaAudioState = Pick<MediaAsset, "hasAudio">;
|
||||
|
||||
export function isSourceAudioEnabled({
|
||||
element,
|
||||
}: {
|
||||
|
|
@ -30,7 +32,7 @@ export function canExtractSourceAudio({
|
|||
mediaAsset,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
mediaAsset: MediaAsset | null | undefined;
|
||||
mediaAsset: MediaAudioState | null | undefined;
|
||||
}): element is VideoElement {
|
||||
return (
|
||||
element.type === "video" &&
|
||||
|
|
@ -53,7 +55,7 @@ export function canToggleSourceAudio({
|
|||
mediaAsset,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
mediaAsset: MediaAsset | null | undefined;
|
||||
mediaAsset: MediaAudioState | null | undefined;
|
||||
}): element is VideoElement {
|
||||
return (
|
||||
canRecoverSourceAudio({ element }) ||
|
||||
|
|
@ -66,7 +68,7 @@ export function doesElementHaveEnabledAudio({
|
|||
mediaAsset,
|
||||
}: {
|
||||
element: AudioElement | VideoElement;
|
||||
mediaAsset?: MediaAsset | null;
|
||||
mediaAsset?: MediaAudioState | null;
|
||||
}): boolean {
|
||||
if (element.type === "audio") {
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ export * from "./element-utils";
|
|||
export * from "./audio-separation";
|
||||
export * from "./zoom-utils";
|
||||
export * from "./ruler-utils";
|
||||
export * from "./ripple-utils";
|
||||
export * from "./pixel-utils";
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
6
bun.lock
6
bun.lock
|
|
@ -13,7 +13,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"turbo": "^2.8.20",
|
||||
"typescript": "5.8.3",
|
||||
"typescript": "^6.0.2",
|
||||
},
|
||||
},
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
|
|
@ -1723,6 +1723,8 @@
|
|||
|
||||
"@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=="],
|
||||
|
||||
"@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=="],
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"turbo": "^2.8.20",
|
||||
"typescript": "5.8.3"
|
||||
"typescript": "^6.0.2"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@tailwindcss/oxide"
|
||||
|
|
|
|||
Loading…
Reference in New Issue