summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/timeline
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-06 00:02:23 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-06 00:02:23 +0200
commit44d8c4c8764d49a8e8d0ae713432c07f07cb7597 (patch)
tree4657187279adb475ea710231feb3d0bd4add36ed /apps/web/src/lib/timeline
parent20235d0724a70e6787b6cbdbfa054b1c5d8f62e9 (diff)
downloadopencut-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.ts44
-rw-r--r--apps/web/src/lib/timeline/audio-separation/index.ts8
-rw-r--r--apps/web/src/lib/timeline/index.ts1
-rw-r--r--apps/web/src/lib/timeline/ripple-utils.ts17
-rw-r--r--apps/web/src/lib/timeline/update-pipeline.ts193
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
+ );
+}