summaryrefslogtreecommitdiff
path: root/apps/web/src/lib
diff options
context:
space:
mode:
authorMaze <mazewinther@gmail.com>2026-04-06 01:13:14 +0200
committerMaze <mazewinther@gmail.com>2026-04-06 01:13:14 +0200
commitdedb13546a16235e7d6649961d0c9b235f62aa34 (patch)
tree08b6e14bf6b28b4c8dc8bc9f849246c75f921593 /apps/web/src/lib
parent099746633183cd061fc89a63aeb9fcfb909f6db2 (diff)
downloadopencut-dedb13546a16235e7d6649961d0c9b235f62aa34.tar.gz
feat: graphs popover
Diffstat (limited to 'apps/web/src/lib')
-rw-r--r--apps/web/src/lib/animation/curve-bridge.ts82
-rw-r--r--apps/web/src/lib/animation/graph-channels.ts95
-rw-r--r--apps/web/src/lib/animation/index.ts13
-rw-r--r--apps/web/src/lib/animation/keyframes.ts141
-rw-r--r--apps/web/src/lib/animation/resolve.ts20
-rw-r--r--apps/web/src/lib/animation/types.ts60
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/index.ts11
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts85
8 files changed, 480 insertions, 27 deletions
diff --git a/apps/web/src/lib/animation/curve-bridge.ts b/apps/web/src/lib/animation/curve-bridge.ts
new file mode 100644
index 00000000..55f96933
--- /dev/null
+++ b/apps/web/src/lib/animation/curve-bridge.ts
@@ -0,0 +1,82 @@
+import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
+import {
+ getDefaultLeftHandle,
+ getDefaultRightHandle,
+} from "@/lib/animation/bezier";
+import type {
+ CurveHandle,
+ NormalizedCubicBezier,
+ ScalarAnimationKey,
+} from "@/lib/animation/types";
+
+const VALUE_EPSILON = 1e-6;
+
+function clamp01({ value }: { value: number }): number {
+ return Math.max(0, Math.min(1, value));
+}
+
+export function getNormalizedCubicBezierForScalarSegment({
+ leftKey,
+ rightKey,
+}: {
+ leftKey: ScalarAnimationKey;
+ rightKey: ScalarAnimationKey;
+}): NormalizedCubicBezier | null {
+ const spanTime = rightKey.time - leftKey.time;
+ const spanValue = rightKey.value - leftKey.value;
+ if (
+ Math.abs(spanTime) <= TIME_EPSILON_SECONDS ||
+ Math.abs(spanValue) <= VALUE_EPSILON
+ ) {
+ return null;
+ }
+
+ const rightHandle =
+ leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey });
+ const leftHandle =
+ rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey });
+
+ return [
+ clamp01({ value: rightHandle.dt / spanTime }),
+ rightHandle.dv / spanValue,
+ clamp01({ value: 1 + leftHandle.dt / spanTime }),
+ 1 + leftHandle.dv / spanValue,
+ ];
+}
+
+export function getCurveHandlesForNormalizedCubicBezier({
+ leftKey,
+ rightKey,
+ cubicBezier,
+}: {
+ leftKey: ScalarAnimationKey;
+ rightKey: ScalarAnimationKey;
+ cubicBezier: NormalizedCubicBezier;
+}): {
+ rightHandle: CurveHandle;
+ leftHandle: CurveHandle;
+} | null {
+ const spanTime = rightKey.time - leftKey.time;
+ const spanValue = rightKey.value - leftKey.value;
+ if (
+ Math.abs(spanTime) <= TIME_EPSILON_SECONDS ||
+ Math.abs(spanValue) <= VALUE_EPSILON
+ ) {
+ return null;
+ }
+
+ const [rawX1, y1, rawX2, y2] = cubicBezier;
+ const x1 = clamp01({ value: rawX1 });
+ const x2 = clamp01({ value: rawX2 });
+
+ return {
+ rightHandle: {
+ dt: spanTime * x1,
+ dv: spanValue * y1,
+ },
+ leftHandle: {
+ dt: spanTime * (x2 - 1),
+ dv: spanValue * (y2 - 1),
+ },
+ };
+}
diff --git a/apps/web/src/lib/animation/graph-channels.ts b/apps/web/src/lib/animation/graph-channels.ts
new file mode 100644
index 00000000..240b00ed
--- /dev/null
+++ b/apps/web/src/lib/animation/graph-channels.ts
@@ -0,0 +1,95 @@
+import type {
+ AnimationPath,
+ ElementAnimations,
+ ScalarAnimationChannel,
+ ScalarGraphChannel,
+ ScalarGraphKeyframeContext,
+} from "@/lib/animation/types";
+
+function isScalarAnimationChannel(
+ channel: ElementAnimations["channels"][string],
+): channel is ScalarAnimationChannel {
+ return channel?.kind === "scalar";
+}
+
+export function getEditableScalarChannels({
+ animations,
+ propertyPath,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+}): ScalarGraphChannel[] {
+ const binding = animations?.bindings[propertyPath];
+ if (!binding) {
+ return [];
+ }
+
+ return binding.components.flatMap((component) => {
+ const channel = animations?.channels[component.channelId];
+ if (!isScalarAnimationChannel(channel)) {
+ return [];
+ }
+
+ return [
+ {
+ propertyPath,
+ componentKey: component.key,
+ channelId: component.channelId,
+ channel,
+ },
+ ];
+ });
+}
+
+export function getEditableScalarChannel({
+ animations,
+ propertyPath,
+ componentKey,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+ componentKey: string;
+}): ScalarGraphChannel | null {
+ return (
+ getEditableScalarChannels({
+ animations,
+ propertyPath,
+ }).find((channel) => channel.componentKey === componentKey) ?? null
+ );
+}
+
+export function getScalarKeyframeContext({
+ animations,
+ propertyPath,
+ componentKey,
+ keyframeId,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+ componentKey: string;
+ keyframeId: string;
+}): ScalarGraphKeyframeContext | null {
+ const scalarChannel = getEditableScalarChannel({
+ animations,
+ propertyPath,
+ componentKey,
+ });
+ if (!scalarChannel) {
+ return null;
+ }
+
+ const keyframeIndex = scalarChannel.channel.keys.findIndex(
+ (keyframe) => keyframe.id === keyframeId,
+ );
+ if (keyframeIndex < 0) {
+ return null;
+ }
+
+ return {
+ ...scalarChannel,
+ keyframe: scalarChannel.channel.keys[keyframeIndex],
+ keyframeIndex,
+ previousKey: scalarChannel.channel.keys[keyframeIndex - 1] ?? null,
+ nextKey: scalarChannel.channel.keys[keyframeIndex + 1] ?? null,
+ };
+}
diff --git a/apps/web/src/lib/animation/index.ts b/apps/web/src/lib/animation/index.ts
index 9cb85d2a..7d2646d9 100644
--- a/apps/web/src/lib/animation/index.ts
+++ b/apps/web/src/lib/animation/index.ts
@@ -12,8 +12,10 @@ export {
getChannel,
removeElementKeyframe,
retimeElementKeyframe,
+ setBindingComponentChannel,
setChannel,
splitAnimationsAtTime,
+ updateScalarKeyframeCurve,
upsertElementKeyframe,
upsertPathKeyframe,
} from "./keyframes";
@@ -47,6 +49,17 @@ export {
} from "./keyframe-query";
export {
+ getEditableScalarChannel,
+ getEditableScalarChannels,
+ getScalarKeyframeContext,
+} from "./graph-channels";
+
+export {
+ getCurveHandlesForNormalizedCubicBezier,
+ getNormalizedCubicBezierForScalarSegment,
+} from "./curve-bridge";
+
+export {
buildGraphicParamPath,
isGraphicParamPath,
parseGraphicParamPath,
diff --git a/apps/web/src/lib/animation/keyframes.ts b/apps/web/src/lib/animation/keyframes.ts
index 8c17f8a6..7eaa5ed6 100644
--- a/apps/web/src/lib/animation/keyframes.ts
+++ b/apps/web/src/lib/animation/keyframes.ts
@@ -11,6 +11,7 @@ import type {
ElementAnimations,
ScalarAnimationChannel,
ScalarAnimationKey,
+ ScalarCurveKeyframePatch,
ScalarSegmentType,
} from "@/lib/animation/types";
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
@@ -209,7 +210,7 @@ function getBinding({
propertyPath,
}: {
animations: ElementAnimations | undefined;
- propertyPath: string;
+ propertyPath: AnimationPath;
}): AnimationBindingInstance | undefined {
return animations?.bindings[propertyPath];
}
@@ -224,6 +225,16 @@ function getChannelById({
return animations?.channels[channelId];
}
+function getBindingComponent({
+ binding,
+ componentKey,
+}: {
+ binding: AnimationBindingInstance;
+ componentKey: string;
+}) {
+ return binding.components.find((component) => component.key === componentKey) ?? null;
+}
+
function getTargetKeyMetadata({
channel,
time,
@@ -402,7 +413,7 @@ export function getChannel({
propertyPath,
}: {
animations: ElementAnimations | undefined;
- propertyPath: string;
+ propertyPath: AnimationPath;
}): AnimationChannel | undefined {
const binding = getBinding({ animations, propertyPath });
const primaryChannelId =
@@ -651,7 +662,7 @@ export function setChannel({
channel,
}: {
animations: ElementAnimations | undefined;
- propertyPath: string;
+ propertyPath: AnimationPath;
channel: AnimationChannel | undefined;
}): ElementAnimations | undefined {
const binding = getBinding({ animations, propertyPath });
@@ -659,29 +670,66 @@ export function setChannel({
return animations;
}
+ if (binding.components.length !== 1) {
+ throw new Error(
+ `setChannel only supports single-component bindings. Received "${propertyPath}" with ${binding.components.length} components.`,
+ );
+ }
+
const primaryComponent = getPrimaryComponent({ binding });
if (!primaryComponent) {
return animations;
}
+ return setBindingComponentChannel({
+ animations,
+ propertyPath,
+ componentKey: primaryComponent.key,
+ channel,
+ });
+}
+
+export function setBindingComponentChannel({
+ animations,
+ propertyPath,
+ componentKey,
+ channel,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+ componentKey: string;
+ channel: AnimationChannel | undefined;
+}): ElementAnimations | undefined {
+ const binding = getBinding({ animations, propertyPath });
+ if (!binding) {
+ return animations;
+ }
+
+ const component = getBindingComponent({
+ binding,
+ componentKey,
+ });
+ if (!component) {
+ return animations;
+ }
+
const nextAnimations = cloneAnimationsState({ animations });
if (!channel || !hasChannelKeys({ channel })) {
- for (const component of binding.components) {
- delete nextAnimations.channels[component.channelId];
+ delete nextAnimations.channels[component.channelId];
+ const hasRemainingKeys = binding.components.some((candidate) =>
+ hasChannelKeys({
+ channel: nextAnimations.channels[candidate.channelId],
+ }),
+ );
+ if (!hasRemainingKeys) {
+ delete nextAnimations.bindings[propertyPath];
}
- delete nextAnimations.bindings[propertyPath];
return toAnimation({
animations: nextAnimations,
});
}
- if (binding.components.length !== 1) {
- throw new Error(
- `setChannel only supports single-component bindings. Received "${propertyPath}" with ${binding.components.length} components.`,
- );
- }
-
- nextAnimations.channels[primaryComponent.channelId] = normalizeChannel({
+ nextAnimations.channels[component.channelId] = normalizeChannel({
channel,
});
return toAnimation({
@@ -689,6 +737,73 @@ export function setChannel({
});
}
+export function updateScalarKeyframeCurve({
+ animations,
+ propertyPath,
+ componentKey,
+ keyframeId,
+ patch,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+ componentKey: string;
+ keyframeId: string;
+ patch: ScalarCurveKeyframePatch;
+}): ElementAnimations | undefined {
+ const binding = getBinding({ animations, propertyPath });
+ if (!binding) {
+ return animations;
+ }
+
+ const component = getBindingComponent({
+ binding,
+ componentKey,
+ });
+ if (!component) {
+ return animations;
+ }
+
+ const channel = getChannelById({
+ animations,
+ channelId: component.channelId,
+ });
+ if (channel?.kind !== "scalar") {
+ return animations;
+ }
+
+ const keyframeIndex = channel.keys.findIndex((keyframe) => keyframe.id === keyframeId);
+ if (keyframeIndex < 0) {
+ return animations;
+ }
+
+ const nextKeys = [...channel.keys];
+ const currentKey = nextKeys[keyframeIndex];
+ nextKeys[keyframeIndex] = {
+ ...currentKey,
+ leftHandle:
+ patch.leftHandle === undefined
+ ? currentKey.leftHandle
+ : patch.leftHandle ?? undefined,
+ rightHandle:
+ patch.rightHandle === undefined
+ ? currentKey.rightHandle
+ : patch.rightHandle ?? undefined,
+ segmentToNext: patch.segmentToNext ?? currentKey.segmentToNext,
+ tangentMode: patch.tangentMode ?? currentKey.tangentMode,
+ };
+
+ return setBindingComponentChannel({
+ animations,
+ propertyPath,
+ componentKey,
+ channel: {
+ kind: "scalar",
+ keys: nextKeys,
+ extrapolation: channel.extrapolation,
+ },
+ });
+}
+
export function cloneAnimations({
animations,
shouldRegenerateKeyframeIds = false,
diff --git a/apps/web/src/lib/animation/resolve.ts b/apps/web/src/lib/animation/resolve.ts
index 3de97026..67f4a487 100644
--- a/apps/web/src/lib/animation/resolve.ts
+++ b/apps/web/src/lib/animation/resolve.ts
@@ -1,5 +1,9 @@
import type {
+ AnimationColorPropertyPath,
+ AnimationNumericPropertyPath,
+ AnimationPath,
AnimationPropertyPath,
+ AnimationValueForPath,
ElementAnimations,
} from "@/lib/animation/types";
import type { Transform } from "@/lib/rendering";
@@ -96,7 +100,7 @@ export function resolveNumberAtTime({
}: {
baseValue: number;
animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationNumericPropertyPath;
localTime: number;
}): number {
return resolveAnimationPathValueAtTime({
@@ -115,7 +119,7 @@ export function resolveColorAtTime({
}: {
baseColor: string;
animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationColorPropertyPath;
localTime: number;
}): string {
return resolveAnimationPathValueAtTime({
@@ -126,19 +130,17 @@ export function resolveColorAtTime({
});
}
-export function resolveAnimationPathValueAtTime<
- T extends number | string | boolean | Transform["position"],
->({
+export function resolveAnimationPathValueAtTime<TPath extends AnimationPath>({
animations,
propertyPath,
localTime,
fallbackValue,
}: {
animations: ElementAnimations | undefined;
- propertyPath: string;
+ propertyPath: TPath;
localTime: number;
- fallbackValue: T;
-}): T {
+ fallbackValue: AnimationValueForPath<TPath>;
+}): AnimationValueForPath<TPath> {
const binding = animations?.bindings[propertyPath];
if (!binding) {
return fallbackValue;
@@ -170,5 +172,5 @@ export function resolveAnimationPathValueAtTime<
return (composeAnimationValue({
binding,
componentValues,
- }) ?? fallbackValue) as T;
+ }) ?? fallbackValue) as AnimationValueForPath<TPath>;
}
diff --git a/apps/web/src/lib/animation/types.ts b/apps/web/src/lib/animation/types.ts
index 1d07e81d..453723b0 100644
--- a/apps/web/src/lib/animation/types.ts
+++ b/apps/web/src/lib/animation/types.ts
@@ -1,3 +1,5 @@
+import type { ParamValues } from "@/lib/params";
+
export const ANIMATION_PROPERTY_PATHS = [
"transform.position",
"transform.scaleX",
@@ -31,6 +33,34 @@ export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS;
export type VectorValue = { x: number; y: number };
export type DiscreteValue = boolean | string;
export type AnimationValue = number | string | boolean | VectorValue;
+export interface AnimationPropertyValueMap {
+ "transform.position": VectorValue;
+ "transform.scaleX": number;
+ "transform.scaleY": number;
+ "transform.rotate": number;
+ opacity: number;
+ volume: number;
+ color: string;
+ "background.color": string;
+ "background.paddingX": number;
+ "background.paddingY": number;
+ "background.offsetX": number;
+ "background.offsetY": number;
+ "background.cornerRadius": number;
+}
+export type DynamicAnimationPathValue = ParamValues[string];
+export type AnimationValueForPath<TPath extends AnimationPath> =
+ TPath extends AnimationPropertyPath
+ ? AnimationPropertyValueMap[TPath]
+ : TPath extends GraphicParamPath | EffectParamPath
+ ? DynamicAnimationPathValue
+ : never;
+export type AnimationNumericPropertyPath = {
+ [K in AnimationPropertyPath]: AnimationValueForPath<K> extends number ? K : never;
+}[AnimationPropertyPath];
+export type AnimationColorPropertyPath = {
+ [K in AnimationPropertyPath]: AnimationValueForPath<K> extends string ? K : never;
+}[AnimationPropertyPath];
export type ContinuousKeyframeInterpolation = "linear" | "hold" | "bezier";
export type DiscreteKeyframeInterpolation = "hold";
@@ -144,6 +174,36 @@ export interface ElementAnimations {
channels: ElementAnimationChannelMap;
}
+export type NormalizedCubicBezier = [number, number, number, number];
+
+export interface ScalarGraphChannelTarget {
+ propertyPath: AnimationPath;
+ componentKey: string;
+ channelId: string;
+}
+
+export interface ScalarGraphChannel extends ScalarGraphChannelTarget {
+ channel: ScalarAnimationChannel;
+}
+
+export interface ScalarGraphKeyframeRef extends ScalarGraphChannelTarget {
+ keyframeId: string;
+}
+
+export interface ScalarGraphKeyframeContext extends ScalarGraphChannel {
+ keyframe: ScalarAnimationKey;
+ keyframeIndex: number;
+ previousKey: ScalarAnimationKey | null;
+ nextKey: ScalarAnimationKey | null;
+}
+
+export interface ScalarCurveKeyframePatch {
+ leftHandle?: CurveHandle | null;
+ rightHandle?: CurveHandle | null;
+ segmentToNext?: ScalarSegmentType;
+ tangentMode?: TangentMode;
+}
+
export interface ElementKeyframe {
propertyPath: AnimationPath;
id: string;
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/index.ts b/apps/web/src/lib/commands/timeline/element/keyframes/index.ts
index ea19286c..319a827b 100644
--- a/apps/web/src/lib/commands/timeline/element/keyframes/index.ts
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/index.ts
@@ -1,5 +1,6 @@
-export * from "./remove-effect-param-keyframe";
-export * from "./remove-keyframe";
-export * from "./retime-keyframe";
-export * from "./upsert-effect-param-keyframe";
-export * from "./upsert-keyframe";
+export * from "./remove-effect-param-keyframe";
+export * from "./remove-keyframe";
+export * from "./retime-keyframe";
+export * from "./update-scalar-keyframe-curve";
+export * from "./upsert-effect-param-keyframe";
+export * from "./upsert-keyframe";
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts b/apps/web/src/lib/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts
new file mode 100644
index 00000000..f1eaab9d
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts
@@ -0,0 +1,85 @@
+import { EditorCore } from "@/core";
+import {
+ resolveAnimationTarget,
+ updateScalarKeyframeCurve,
+} from "@/lib/animation";
+import { Command, type CommandResult } from "@/lib/commands/base-command";
+import { updateElementInTracks } from "@/lib/timeline";
+import type {
+ AnimationPath,
+ ScalarCurveKeyframePatch,
+} from "@/lib/animation/types";
+import type { TimelineTrack } from "@/lib/timeline";
+
+export class UpdateScalarKeyframeCurveCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly propertyPath: AnimationPath;
+ private readonly componentKey: string;
+ private readonly keyframeId: string;
+ private readonly patch: ScalarCurveKeyframePatch;
+
+ constructor({
+ trackId,
+ elementId,
+ propertyPath,
+ componentKey,
+ keyframeId,
+ patch,
+ }: {
+ trackId: string;
+ elementId: string;
+ propertyPath: AnimationPath;
+ componentKey: string;
+ keyframeId: string;
+ patch: ScalarCurveKeyframePatch;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.propertyPath = propertyPath;
+ this.componentKey = componentKey;
+ this.keyframeId = keyframeId;
+ this.patch = patch;
+ }
+
+ 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) => {
+ if (!resolveAnimationTarget({ element, path: this.propertyPath })) {
+ return element;
+ }
+
+ return {
+ ...element,
+ animations: updateScalarKeyframeCurve({
+ animations: element.animations,
+ propertyPath: this.propertyPath,
+ componentKey: this.componentKey,
+ keyframeId: this.keyframeId,
+ patch: this.patch,
+ }),
+ };
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ return undefined;
+ }
+
+ undo(): void {
+ if (!this.savedState) {
+ return;
+ }
+
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+}