diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-06 00:02:23 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-06 00:02:23 +0200 |
| commit | 44d8c4c8764d49a8e8d0ae713432c07f07cb7597 (patch) | |
| tree | 4657187279adb475ea710231feb3d0bd4add36ed /apps/web/src/lib/timeline | |
| parent | 20235d0724a70e6787b6cbdbfa054b1c5d8f62e9 (diff) | |
| download | opencut-44d8c4c8764d49a8e8d0ae713432c07f07cb7597.tar.gz | |
refactor: unify element updates into pipeline and centralize ripple
Diffstat (limited to 'apps/web/src/lib/timeline')
| -rw-r--r-- | apps/web/src/lib/timeline/audio-separation/__tests__/index.test.ts | 44 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/audio-separation/index.ts | 8 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/index.ts | 1 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/ripple-utils.ts | 17 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/update-pipeline.ts | 193 |
5 files changed, 219 insertions, 44 deletions
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 + ); +} |
