summaryrefslogtreecommitdiff
path: root/apps/web/src/ripple
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
commit3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch)
tree57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/ripple
parent729d10592f4e802f91c555c309ff3ba4b119f843 (diff)
downloadopencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/ripple')
-rw-r--r--apps/web/src/ripple/apply.ts77
-rw-r--r--apps/web/src/ripple/diff.ts279
-rw-r--r--apps/web/src/ripple/index.ts4
-rw-r--r--apps/web/src/ripple/shift.ts17
4 files changed, 377 insertions, 0 deletions
diff --git a/apps/web/src/ripple/apply.ts b/apps/web/src/ripple/apply.ts
new file mode 100644
index 00000000..4ad83529
--- /dev/null
+++ b/apps/web/src/ripple/apply.ts
@@ -0,0 +1,77 @@
+import type { SceneTracks, TimelineTrack } from "@/timeline/types";
+import { rippleShiftElements } from "./shift";
+
+export interface RippleAdjustment {
+ trackId: string;
+ afterTime: number;
+ shiftAmount: number;
+}
+
+export function applyRippleAdjustments({
+ tracks,
+ adjustments,
+}: {
+ tracks: SceneTracks;
+ adjustments: RippleAdjustment[];
+}): SceneTracks {
+ 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 {
+ overlay: tracks.overlay.map((track) =>
+ applyTrackRippleAdjustments({
+ track,
+ adjustments: adjustmentsByTrack.get(track.id) ?? [],
+ }),
+ ),
+ main: applyTrackRippleAdjustments({
+ track: tracks.main,
+ adjustments: adjustmentsByTrack.get(tracks.main.id) ?? [],
+ }),
+ audio: tracks.audio.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/ripple/diff.ts b/apps/web/src/ripple/diff.ts
new file mode 100644
index 00000000..e450d5ae
--- /dev/null
+++ b/apps/web/src/ripple/diff.ts
@@ -0,0 +1,279 @@
+import type { SceneTracks, TimelineElement, TimelineTrack } from "@/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: SceneTracks;
+ afterTracks: SceneTracks;
+}): RippleAdjustment[] {
+ const beforeTrackList = [
+ ...beforeTracks.overlay,
+ beforeTracks.main,
+ ...beforeTracks.audio,
+ ];
+ const afterTrackList = [
+ ...afterTracks.overlay,
+ afterTracks.main,
+ ...afterTracks.audio,
+ ];
+ const afterTracksById = new Map(afterTrackList.map((track) => [track.id, track]));
+ const allAfterElementIds = new Set(
+ afterTrackList.flatMap((track) => track.elements.map((element) => element.id)),
+ );
+
+ return beforeTrackList.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) {
+ 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 <= 0) {
+ 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) {
+ 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 ||
+ overlappingInterval.startTime >= remainingInterval.endTime
+ ) {
+ 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) {
+ return;
+ }
+
+ intervals.push({ startTime, endTime });
+}
diff --git a/apps/web/src/ripple/index.ts b/apps/web/src/ripple/index.ts
new file mode 100644
index 00000000..5a14de64
--- /dev/null
+++ b/apps/web/src/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/ripple/shift.ts b/apps/web/src/ripple/shift.ts
new file mode 100644
index 00000000..a88d2b2d
--- /dev/null
+++ b/apps/web/src/ripple/shift.ts
@@ -0,0 +1,17 @@
+import type { TimelineElement } from "@/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,
+ );
+}