diff options
Diffstat (limited to 'apps/web/src/lib')
22 files changed, 667 insertions, 636 deletions
diff --git a/apps/web/src/lib/commands/timeline/element/delete-elements.ts b/apps/web/src/lib/commands/timeline/element/delete-elements.ts index 1e1ad661..c8cc4494 100644 --- a/apps/web/src/lib/commands/timeline/element/delete-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/delete-elements.ts @@ -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); diff --git a/apps/web/src/lib/commands/timeline/element/index.ts b/apps/web/src/lib/commands/timeline/element/index.ts index 60e12ddd..eabdc385 100644 --- a/apps/web/src/lib/commands/timeline/element/index.ts +++ b/apps/web/src/lib/commands/timeline/element/index.ts @@ -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";
diff --git a/apps/web/src/lib/commands/timeline/element/move-elements.ts b/apps/web/src/lib/commands/timeline/element/move-elements.ts index e944ca81..cf3afa6d 100644 --- a/apps/web/src/lib/commands/timeline/element/move-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/move-elements.ts @@ -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) { diff --git a/apps/web/src/lib/commands/timeline/element/retime/index.ts b/apps/web/src/lib/commands/timeline/element/retime/index.ts deleted file mode 100644 index e9c24c44..00000000 --- a/apps/web/src/lib/commands/timeline/element/retime/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { UpdateElementRetimeCommand } from "./update-element-retime";
diff --git a/apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts b/apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts deleted file mode 100644 index 474396a1..00000000 --- a/apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts +++ /dev/null @@ -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); - } - } -} diff --git a/apps/web/src/lib/commands/timeline/element/split-elements.ts b/apps/web/src/lib/commands/timeline/element/split-elements.ts index 250a1ce6..79722b4c 100644 --- a/apps/web/src/lib/commands/timeline/element/split-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/split-elements.ts @@ -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; }); diff --git a/apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts b/apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts deleted file mode 100644 index 75b77562..00000000 --- a/apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts +++ /dev/null @@ -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);
- }
- }
-}
diff --git a/apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts b/apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts deleted file mode 100644 index 271b0a00..00000000 --- a/apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts +++ /dev/null @@ -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);
- }
- }
-}
diff --git a/apps/web/src/lib/commands/timeline/element/update-element-duration.ts b/apps/web/src/lib/commands/timeline/element/update-element-duration.ts deleted file mode 100644 index 7ad62000..00000000 --- a/apps/web/src/lib/commands/timeline/element/update-element-duration.ts +++ /dev/null @@ -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); - } - } -} diff --git a/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts b/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts deleted file mode 100644 index 6232d539..00000000 --- a/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts +++ /dev/null @@ -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); - } - } -} diff --git a/apps/web/src/lib/commands/timeline/element/update-element-trim.ts b/apps/web/src/lib/commands/timeline/element/update-element-trim.ts deleted file mode 100644 index e3cb596b..00000000 --- a/apps/web/src/lib/commands/timeline/element/update-element-trim.ts +++ /dev/null @@ -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); - } - } -} diff --git a/apps/web/src/lib/commands/timeline/element/update-element.ts b/apps/web/src/lib/commands/timeline/element/update-element.ts deleted file mode 100644 index 45b2603f..00000000 --- a/apps/web/src/lib/commands/timeline/element/update-element.ts +++ /dev/null @@ -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); - } - } -} diff --git a/apps/web/src/lib/commands/timeline/element/update-elements.ts b/apps/web/src/lib/commands/timeline/element/update-elements.ts new file mode 100644 index 00000000..abeec842 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/update-elements.ts @@ -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); + } + } +} diff --git a/apps/web/src/lib/ripple/apply.ts b/apps/web/src/lib/ripple/apply.ts new file mode 100644 index 00000000..098a0247 --- /dev/null +++ b/apps/web/src/lib/ripple/apply.ts @@ -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 }; +} diff --git a/apps/web/src/lib/ripple/diff.ts b/apps/web/src/lib/ripple/diff.ts new file mode 100644 index 00000000..8a9730e1 --- /dev/null +++ b/apps/web/src/lib/ripple/diff.ts @@ -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 }); +} diff --git a/apps/web/src/lib/ripple/index.ts b/apps/web/src/lib/ripple/index.ts new file mode 100644 index 00000000..5a14de64 --- /dev/null +++ b/apps/web/src/lib/ripple/index.ts @@ -0,0 +1,4 @@ +export type { RippleAdjustment } from "./apply"; +export { applyRippleAdjustments } from "./apply"; +export { computeRippleAdjustments } from "./diff"; +export { rippleShiftElements } from "./shift"; diff --git a/apps/web/src/lib/ripple/shift.ts b/apps/web/src/lib/ripple/shift.ts new file mode 100644 index 00000000..03ab4a67 --- /dev/null +++ b/apps/web/src/lib/ripple/shift.ts @@ -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, + ); +} diff --git a/apps/web/src/lib/timeline/audio-separation/__tests__/index.test.ts b/apps/web/src/lib/timeline/audio-separation/__tests__/index.test.ts index 173b6402..1946f1e0 100644 --- a/apps/web/src/lib/timeline/audio-separation/__tests__/index.test.ts +++ b/apps/web/src/lib/timeline/audio-separation/__tests__/index.test.ts @@ -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; +} diff --git a/apps/web/src/lib/timeline/audio-separation/index.ts b/apps/web/src/lib/timeline/audio-separation/index.ts index b92cb79b..093edfbc 100644 --- a/apps/web/src/lib/timeline/audio-separation/index.ts +++ b/apps/web/src/lib/timeline/audio-separation/index.ts @@ -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; diff --git a/apps/web/src/lib/timeline/index.ts b/apps/web/src/lib/timeline/index.ts index 469b916e..194cd84b 100644 --- a/apps/web/src/lib/timeline/index.ts +++ b/apps/web/src/lib/timeline/index.ts @@ -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({
diff --git a/apps/web/src/lib/timeline/ripple-utils.ts b/apps/web/src/lib/timeline/ripple-utils.ts deleted file mode 100644 index 5e17e9a3..00000000 --- a/apps/web/src/lib/timeline/ripple-utils.ts +++ /dev/null @@ -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,
- );
-}
diff --git a/apps/web/src/lib/timeline/update-pipeline.ts b/apps/web/src/lib/timeline/update-pipeline.ts new file mode 100644 index 00000000..cef99abc --- /dev/null +++ b/apps/web/src/lib/timeline/update-pipeline.ts @@ -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 + ); +} |
