summaryrefslogtreecommitdiff
path: root/apps/web/src/animation/keyframe-query.ts
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/animation/keyframe-query.ts
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/animation/keyframe-query.ts')
-rw-r--r--apps/web/src/animation/keyframe-query.ts294
1 files changed, 294 insertions, 0 deletions
diff --git a/apps/web/src/animation/keyframe-query.ts b/apps/web/src/animation/keyframe-query.ts
new file mode 100644
index 00000000..231aa5f2
--- /dev/null
+++ b/apps/web/src/animation/keyframe-query.ts
@@ -0,0 +1,294 @@
+import type {
+ AnimationBindingInstance,
+ AnimationChannel,
+ AnimationPath,
+ ElementAnimations,
+ ElementKeyframe,
+} from "@/animation/types";
+import {
+ type AnimationComponentValue,
+ composeAnimationValue,
+} from "./binding-values";
+import {
+ getChannelValueAtTime,
+ getScalarSegmentInterpolation,
+} from "./interpolation";
+import { isAnimationPath } from "./target-resolver";
+
+function getBindingFallbackValue({
+ channel,
+}: {
+ channel: ElementAnimations["channels"][string];
+}) {
+ if (!channel || channel.keys.length === 0) {
+ return channel?.kind === "discrete" ? false : 0;
+ }
+
+ return channel.keys[0].value;
+}
+
+interface BindingKeyframeMatch {
+ componentIndex: number;
+ channel: AnimationChannel;
+ keyframe: AnimationChannel["keys"][number];
+}
+
+function getBindingKeyframeMatches({
+ animations,
+ binding,
+}: {
+ animations: ElementAnimations;
+ binding: AnimationBindingInstance;
+}): BindingKeyframeMatch[] {
+ return binding.components.flatMap((component, componentIndex) => {
+ const channel = animations.channels[component.channelId];
+ if (!channel || channel.keys.length === 0) {
+ return [];
+ }
+
+ return channel.keys.map((keyframe) => ({
+ componentIndex,
+ channel,
+ keyframe,
+ }));
+ });
+}
+
+function getUniqueBindingKeyframeMatches({
+ animations,
+ binding,
+}: {
+ animations: ElementAnimations;
+ binding: AnimationBindingInstance;
+}): BindingKeyframeMatch[] {
+ const sortedMatches = getBindingKeyframeMatches({
+ animations,
+ binding,
+ }).sort(
+ (leftMatch, rightMatch) =>
+ leftMatch.keyframe.time - rightMatch.keyframe.time ||
+ leftMatch.componentIndex - rightMatch.componentIndex,
+ );
+ const uniqueMatches: BindingKeyframeMatch[] = [];
+
+ for (const match of sortedMatches) {
+ const previousMatch = uniqueMatches[uniqueMatches.length - 1];
+ if (
+ !previousMatch ||
+ previousMatch.keyframe.time !== match.keyframe.time
+ ) {
+ uniqueMatches.push(match);
+ continue;
+ }
+
+ if (
+ previousMatch.componentIndex !== 0 &&
+ match.componentIndex === 0
+ ) {
+ uniqueMatches[uniqueMatches.length - 1] = match;
+ }
+ }
+
+ return uniqueMatches;
+}
+
+function getPreferredBindingKeyframeMatch({
+ matches,
+}: {
+ matches: BindingKeyframeMatch[];
+}): BindingKeyframeMatch | null {
+ return (
+ matches.find((match) => match.componentIndex === 0) ??
+ matches[0] ??
+ null
+ );
+}
+
+function getComposedBindingValueAtTime({
+ animations,
+ binding,
+ time,
+}: {
+ animations: ElementAnimations;
+ binding: AnimationBindingInstance;
+ time: number;
+}) {
+ const componentValues = Object.fromEntries(
+ binding.components.map((component) => {
+ const channel = animations.channels[component.channelId];
+ return [
+ component.key,
+ getChannelValueAtTime({
+ channel,
+ time,
+ fallbackValue: getBindingFallbackValue({ channel }),
+ }),
+ ];
+ }),
+ ) as Record<string, AnimationComponentValue | undefined>;
+
+ return composeAnimationValue({
+ binding,
+ componentValues,
+ });
+}
+
+function getKeyframeInterpolation({
+ channel,
+ keyframe,
+}: {
+ channel: AnimationChannel;
+ keyframe: AnimationChannel["keys"][number];
+}) {
+ return channel.kind === "scalar" && "segmentToNext" in keyframe
+ ? getScalarSegmentInterpolation({ segment: keyframe.segmentToNext })
+ : "hold";
+}
+
+function toElementKeyframe({
+ animations,
+ binding,
+ propertyPath,
+ keyframeMatch,
+}: {
+ animations: ElementAnimations;
+ binding: AnimationBindingInstance;
+ propertyPath: AnimationPath;
+ keyframeMatch: BindingKeyframeMatch;
+}): ElementKeyframe | null {
+ const value = getComposedBindingValueAtTime({
+ animations,
+ binding,
+ time: keyframeMatch.keyframe.time,
+ });
+ if (value === null) {
+ return null;
+ }
+
+ return {
+ propertyPath,
+ id: keyframeMatch.keyframe.id,
+ time: keyframeMatch.keyframe.time,
+ value,
+ interpolation: getKeyframeInterpolation({
+ channel: keyframeMatch.channel,
+ keyframe: keyframeMatch.keyframe,
+ }),
+ };
+}
+
+export function getElementKeyframes({
+ animations,
+}: {
+ animations: ElementAnimations | undefined;
+}): ElementKeyframe[] {
+ if (!animations) {
+ return [];
+ }
+
+ return Object.entries(animations.bindings).flatMap(
+ ([propertyPath, binding]) => {
+ if (!binding || !isAnimationPath(propertyPath)) {
+ return [];
+ }
+
+ return getUniqueBindingKeyframeMatches({
+ animations,
+ binding,
+ }).flatMap((keyframeMatch) => {
+ const keyframe = toElementKeyframe({
+ animations,
+ binding,
+ propertyPath,
+ keyframeMatch,
+ });
+ if (!keyframe) {
+ return [];
+ }
+
+ return [keyframe];
+ });
+ },
+ );
+}
+
+export function hasKeyframesForPath({
+ animations,
+ propertyPath,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+}): boolean {
+ const binding = animations?.bindings[propertyPath];
+ if (!binding) {
+ return false;
+ }
+
+ return binding.components.some((component) =>
+ Boolean(animations?.channels[component.channelId]?.keys.length),
+ );
+}
+
+export function getKeyframeAtTime({
+ animations,
+ propertyPath,
+ time,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+ time: number;
+}): ElementKeyframe | null {
+ const binding = animations?.bindings[propertyPath];
+ if (!binding) {
+ return null;
+ }
+
+ const keyframeMatch = getPreferredBindingKeyframeMatch({
+ matches: getBindingKeyframeMatches({
+ animations,
+ binding,
+ }).filter(({ keyframe }) => keyframe.time === time),
+ });
+ if (!keyframeMatch) {
+ return null;
+ }
+
+ return toElementKeyframe({
+ animations,
+ binding,
+ propertyPath,
+ keyframeMatch,
+ });
+}
+
+export function getKeyframeById({
+ animations,
+ propertyPath,
+ keyframeId,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+ keyframeId: string;
+}): ElementKeyframe | null {
+ const binding = animations?.bindings[propertyPath];
+ if (!binding) {
+ return null;
+ }
+
+ const keyframeMatch = getPreferredBindingKeyframeMatch({
+ matches: getBindingKeyframeMatches({
+ animations,
+ binding,
+ }).filter(({ keyframe }) => keyframe.id === keyframeId),
+ });
+ if (!keyframeMatch) {
+ return null;
+ }
+
+ return toElementKeyframe({
+ animations,
+ binding,
+ propertyPath,
+ keyframeMatch,
+ });
+}