OpenCut/apps/web/src/lib/timeline/update-pipeline.ts

194 lines
4.1 KiB
TypeScript

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 | string;
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
);
}