summaryrefslogtreecommitdiff
path: root/apps/web
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web')
-rw-r--r--apps/web/src/animation/__tests__/animated-params.test.ts241
-rw-r--r--apps/web/src/animation/animated-params.ts83
-rw-r--r--apps/web/src/animation/effect-param-channel.ts16
-rw-r--r--apps/web/src/animation/graphic-param-channel.ts32
-rw-r--r--apps/web/src/animation/index.ts30
-rw-r--r--apps/web/src/animation/interpolation.ts2
-rw-r--r--apps/web/src/animation/keyframe-query.ts2
-rw-r--r--apps/web/src/animation/keyframes.ts48
-rw-r--r--apps/web/src/animation/path.ts22
-rw-r--r--apps/web/src/animation/resolve.ts105
-rw-r--r--apps/web/src/animation/types.ts10
-rw-r--r--apps/web/src/animation/values.ts61
-rw-r--r--apps/web/src/commands/timeline/clipboard/paste-keyframes.ts2
-rw-r--r--apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts2
-rw-r--r--apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts3
-rw-r--r--apps/web/src/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts2
-rw-r--r--apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts2
-rw-r--r--apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts3
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts190
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts348
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts14
-rw-r--r--apps/web/src/core/managers/timeline-manager.ts2
-rw-r--r--apps/web/src/graphics/components/graphic-tab.tsx479
-rw-r--r--apps/web/src/graphics/index.ts24
-rw-r--r--apps/web/src/preview/components/text-edit-overlay.tsx2
-rw-r--r--apps/web/src/preview/controllers/transform-handle-controller.ts2
-rw-r--r--apps/web/src/preview/element-bounds.ts616
-rw-r--r--apps/web/src/rendering/NOTES.md27
-rw-r--r--apps/web/src/rendering/animation-values.ts49
-rw-r--r--apps/web/src/rendering/components/blending-tab.tsx2
-rw-r--r--apps/web/src/rendering/components/transform-tab.tsx2
-rw-r--r--apps/web/src/services/renderer/resolve.ts20
-rw-r--r--apps/web/src/text/components/text-tab.tsx5
-rw-r--r--apps/web/src/text/measure-element.ts2
-rw-r--r--apps/web/src/timeline/animation-properties.ts (renamed from apps/web/src/animation/property-registry.ts)65
-rw-r--r--apps/web/src/timeline/animation-snap-points.ts (renamed from apps/web/src/animation/timeline-snap-points.ts)0
-rw-r--r--apps/web/src/timeline/animation-targets.ts (renamed from apps/web/src/animation/target-resolver.ts)123
-rw-r--r--apps/web/src/timeline/audio-state.ts2
-rw-r--r--apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts2
-rw-r--r--apps/web/src/timeline/components/audio-tab.tsx302
-rw-r--r--apps/web/src/timeline/controllers/playhead-controller.ts2
-rw-r--r--apps/web/src/timeline/controllers/resize-controller.ts2
-rw-r--r--apps/web/src/timeline/group-move/snap.ts2
43 files changed, 1628 insertions, 1322 deletions
diff --git a/apps/web/src/animation/__tests__/animated-params.test.ts b/apps/web/src/animation/__tests__/animated-params.test.ts
new file mode 100644
index 00000000..7b9d312d
--- /dev/null
+++ b/apps/web/src/animation/__tests__/animated-params.test.ts
@@ -0,0 +1,241 @@
+import { describe, expect, test } from "bun:test";
+import {
+ coerceAnimationParamValue,
+ getAnimationParamDefaultInterpolation,
+ getAnimationParamNumericRange,
+ getAnimationParamValueKind,
+} from "@/animation/animated-params";
+
+describe("animated params", () => {
+ test("snaps and clamps number params", () => {
+ expect(
+ coerceAnimationParamValue({
+ param: {
+ key: "intensity",
+ label: "Intensity",
+ type: "number",
+ default: 0,
+ min: 0,
+ max: 1,
+ step: 0.25,
+ },
+ value: 0.62,
+ }),
+ ).toBe(0.5);
+
+ expect(
+ coerceAnimationParamValue({
+ param: {
+ key: "intensity",
+ label: "Intensity",
+ type: "number",
+ default: 0,
+ min: 0,
+ max: 1,
+ step: 0.25,
+ },
+ value: 1.2,
+ }),
+ ).toBe(1);
+ });
+
+ test("rejects NaN and non-number values for number params", () => {
+ const param = {
+ key: "intensity",
+ label: "Intensity",
+ type: "number" as const,
+ default: 0,
+ min: 0,
+ max: 1,
+ step: 0.25,
+ };
+ expect(coerceAnimationParamValue({ param, value: Number.NaN })).toBeNull();
+ expect(coerceAnimationParamValue({ param, value: "0.5" })).toBeNull();
+ expect(coerceAnimationParamValue({ param, value: true })).toBeNull();
+ });
+
+ test("passthrough with step <= 0 guard", () => {
+ expect(
+ coerceAnimationParamValue({
+ param: {
+ key: "x",
+ label: "X",
+ type: "number",
+ default: 0,
+ min: 0,
+ step: 0,
+ },
+ value: 0.123,
+ }),
+ ).toBe(0.123);
+ });
+
+ test("accepts valid select values", () => {
+ const param = {
+ key: "blend",
+ label: "Blend",
+ type: "select" as const,
+ default: "normal",
+ options: [
+ { value: "normal", label: "Normal" },
+ { value: "multiply", label: "Multiply" },
+ ],
+ };
+ expect(coerceAnimationParamValue({ param, value: "normal" })).toBe("normal");
+ expect(coerceAnimationParamValue({ param, value: "multiply" })).toBe("multiply");
+ });
+
+ test("rejects select values outside the allowed options", () => {
+ expect(
+ coerceAnimationParamValue({
+ param: {
+ key: "blend",
+ label: "Blend",
+ type: "select",
+ default: "normal",
+ options: [
+ { value: "normal", label: "Normal" },
+ { value: "multiply", label: "Multiply" },
+ ],
+ },
+ value: "screen",
+ }),
+ ).toBeNull();
+ });
+
+ test("rejects non-string select values", () => {
+ const param = {
+ key: "blend",
+ label: "Blend",
+ type: "select" as const,
+ default: "normal",
+ options: [{ value: "normal", label: "Normal" }],
+ };
+ expect(coerceAnimationParamValue({ param, value: 42 })).toBeNull();
+ expect(coerceAnimationParamValue({ param, value: null })).toBeNull();
+ expect(coerceAnimationParamValue({ param, value: undefined })).toBeNull();
+ });
+
+ test("boolean params accept booleans and reject other types", () => {
+ const param = {
+ key: "visible",
+ label: "Visible",
+ type: "boolean" as const,
+ default: true,
+ };
+ expect(coerceAnimationParamValue({ param, value: true })).toBe(true);
+ expect(coerceAnimationParamValue({ param, value: false })).toBe(false);
+ expect(coerceAnimationParamValue({ param, value: 1 })).toBeNull();
+ expect(coerceAnimationParamValue({ param, value: "true" })).toBeNull();
+ });
+
+ test("color params accept strings and reject other types", () => {
+ const param = {
+ key: "fill",
+ label: "Fill",
+ type: "color" as const,
+ default: "#ffffff",
+ };
+ expect(coerceAnimationParamValue({ param, value: "#ff0000" })).toBe("#ff0000");
+ expect(coerceAnimationParamValue({ param, value: 0xff0000 })).toBeNull();
+ expect(coerceAnimationParamValue({ param, value: null })).toBeNull();
+ });
+
+ test("getAnimationParamValueKind maps param type to binding kind", () => {
+ expect(
+ getAnimationParamValueKind({
+ param: {
+ key: "n",
+ label: "N",
+ type: "number",
+ default: 0,
+ min: 0,
+ step: 1,
+ },
+ }),
+ ).toBe("number");
+ expect(
+ getAnimationParamValueKind({
+ param: { key: "c", label: "C", type: "color", default: "#fff" },
+ }),
+ ).toBe("color");
+ expect(
+ getAnimationParamValueKind({
+ param: { key: "b", label: "B", type: "boolean", default: false },
+ }),
+ ).toBe("discrete");
+ expect(
+ getAnimationParamValueKind({
+ param: {
+ key: "s",
+ label: "S",
+ type: "select",
+ default: "a",
+ options: [{ value: "a", label: "A" }],
+ },
+ }),
+ ).toBe("discrete");
+ });
+
+ test("getAnimationParamDefaultInterpolation is linear for continuous, hold for discrete", () => {
+ expect(
+ getAnimationParamDefaultInterpolation({
+ param: {
+ key: "n",
+ label: "N",
+ type: "number",
+ default: 0,
+ min: 0,
+ step: 1,
+ },
+ }),
+ ).toBe("linear");
+ expect(
+ getAnimationParamDefaultInterpolation({
+ param: { key: "c", label: "C", type: "color", default: "#fff" },
+ }),
+ ).toBe("linear");
+ expect(
+ getAnimationParamDefaultInterpolation({
+ param: { key: "b", label: "B", type: "boolean", default: false },
+ }),
+ ).toBe("hold");
+ expect(
+ getAnimationParamDefaultInterpolation({
+ param: {
+ key: "s",
+ label: "S",
+ type: "select",
+ default: "a",
+ options: [{ value: "a", label: "A" }],
+ },
+ }),
+ ).toBe("hold");
+ });
+
+ test("getAnimationParamNumericRange returns spec for number params, undefined otherwise", () => {
+ expect(
+ getAnimationParamNumericRange({
+ param: {
+ key: "intensity",
+ label: "Intensity",
+ type: "number",
+ default: 0.5,
+ min: 0,
+ max: 1,
+ step: 0.1,
+ },
+ }),
+ ).toEqual({ min: 0, max: 1, step: 0.1 });
+ expect(
+ getAnimationParamNumericRange({
+ param: { key: "c", label: "C", type: "color", default: "#fff" },
+ }),
+ ).toBeUndefined();
+ expect(
+ getAnimationParamNumericRange({
+ param: { key: "b", label: "B", type: "boolean", default: false },
+ }),
+ ).toBeUndefined();
+ });
+});
diff --git a/apps/web/src/animation/animated-params.ts b/apps/web/src/animation/animated-params.ts
new file mode 100644
index 00000000..bc27b15d
--- /dev/null
+++ b/apps/web/src/animation/animated-params.ts
@@ -0,0 +1,83 @@
+import { snapToStep } from "@/utils/math";
+import type { ParamDefinition } from "@/params";
+import type { DynamicAnimationPathValue, NumericSpec } from "./types";
+
+export function getAnimationParamValueKind({
+ param,
+}: {
+ param: ParamDefinition;
+}): "number" | "color" | "discrete" {
+ if (param.type === "number") {
+ return "number";
+ }
+
+ if (param.type === "color") {
+ return "color";
+ }
+
+ return "discrete";
+}
+
+export function getAnimationParamDefaultInterpolation({
+ param,
+}: {
+ param: ParamDefinition;
+}): "linear" | "hold" {
+ return param.type === "number" || param.type === "color" ? "linear" : "hold";
+}
+
+export function getAnimationParamNumericRange({
+ param,
+}: {
+ param: ParamDefinition;
+}): NumericSpec | undefined {
+ if (param.type !== "number") {
+ return undefined;
+ }
+
+ return {
+ min: param.min,
+ max: param.max,
+ step: param.step,
+ };
+}
+
+/**
+ * `coerceAnimationParamValue` accepts `unknown` rather than a narrow
+ * `DynamicAnimationPathValue` because it doubles as a runtime gate for
+ * untrusted inputs (persisted state, paste payloads, programmatic updates).
+ * The caller does not need to pre-validate; null means "this value cannot live
+ * on this param".
+ */
+export function coerceAnimationParamValue({
+ param,
+ value,
+}: {
+ param: ParamDefinition;
+ value: unknown;
+}): DynamicAnimationPathValue | null {
+ if (param.type === "number") {
+ if (typeof value !== "number" || Number.isNaN(value)) {
+ return null;
+ }
+
+ const steppedValue = snapToStep({ value, step: param.step });
+ const minValue = param.min;
+ const maxValue = param.max ?? Number.POSITIVE_INFINITY;
+ return Math.min(maxValue, Math.max(minValue, steppedValue));
+ }
+
+ if (param.type === "color") {
+ return typeof value === "string" ? value : null;
+ }
+
+ if (param.type === "boolean") {
+ return typeof value === "boolean" ? value : null;
+ }
+
+ if (typeof value !== "string") {
+ return null;
+ }
+
+ return param.options.some((option) => option.value === value) ? value : null;
+}
diff --git a/apps/web/src/animation/effect-param-channel.ts b/apps/web/src/animation/effect-param-channel.ts
index c635bb82..e1c442f7 100644
--- a/apps/web/src/animation/effect-param-channel.ts
+++ b/apps/web/src/animation/effect-param-channel.ts
@@ -1,9 +1,8 @@
-import type { ParamValues } from "@/params";
-import type { Effect } from "@/effects/types";
import type {
ElementAnimations,
EffectParamPath,
} from "@/animation/types";
+import type { ParamValues } from "@/params";
import { removeElementKeyframe } from "./keyframes";
import { resolveAnimationPathValueAtTime } from "./resolve";
@@ -56,23 +55,26 @@ export function parseEffectParamPath({
}
export function resolveEffectParamsAtTime({
- effect,
+ effectId,
+ params,
animations,
localTime,
}: {
- effect: Effect;
+ effectId: string;
+ params: ParamValues;
animations: ElementAnimations | undefined;
localTime: number;
}): ParamValues {
+ const safeLocalTime = Math.max(0, localTime);
const resolved: ParamValues = {};
- for (const [paramKey, staticValue] of Object.entries(effect.params)) {
- const path = buildEffectParamPath({ effectId: effect.id, paramKey });
+ for (const [paramKey, staticValue] of Object.entries(params)) {
+ const path = buildEffectParamPath({ effectId, paramKey });
resolved[paramKey] = animations?.bindings[path]
? resolveAnimationPathValueAtTime({
animations,
propertyPath: path,
- localTime,
+ localTime: safeLocalTime,
fallbackValue: staticValue,
})
: staticValue;
diff --git a/apps/web/src/animation/graphic-param-channel.ts b/apps/web/src/animation/graphic-param-channel.ts
index ebf0f940..d0bfdded 100644
--- a/apps/web/src/animation/graphic-param-channel.ts
+++ b/apps/web/src/animation/graphic-param-channel.ts
@@ -2,11 +2,7 @@ import type {
ElementAnimations,
GraphicParamPath,
} from "@/animation/types";
-import type { ParamValues } from "@/params";
-import {
- getGraphicDefinition,
- resolveGraphicParams,
-} from "@/graphics";
+import type { ParamDefinition, ParamValues } from "@/params";
import { resolveAnimationPathValueAtTime } from "./resolve";
export const GRAPHIC_PARAM_PATH_PREFIX = "params.";
@@ -39,33 +35,29 @@ export function parseGraphicParamPath({
}
export function resolveGraphicParamsAtTime({
- element,
+ params,
+ definitions,
+ animations,
localTime,
}: {
- element: {
- definitionId: string;
- params: ParamValues;
- animations?: ElementAnimations;
- };
+ params: ParamValues;
+ definitions: ParamDefinition[];
+ animations?: ElementAnimations;
localTime: number;
}): ParamValues {
- const definition = getGraphicDefinition({
- definitionId: element.definitionId,
- });
- const baseParams = resolveGraphicParams(definition, element.params);
- const resolved: ParamValues = { ...baseParams };
+ const resolved: ParamValues = { ...params };
- for (const param of definition.params) {
+ for (const param of definitions) {
const path = buildGraphicParamPath({ paramKey: param.key });
- if (!element.animations?.bindings[path]) {
+ if (!animations?.bindings[path]) {
continue;
}
resolved[param.key] = resolveAnimationPathValueAtTime({
- animations: element.animations,
+ animations,
propertyPath: path,
localTime: Math.max(0, localTime),
- fallbackValue: baseParams[param.key] ?? param.default,
+ fallbackValue: params[param.key] ?? param.default,
});
}
diff --git a/apps/web/src/animation/index.ts b/apps/web/src/animation/index.ts
index 384f3d17..af3b910d 100644
--- a/apps/web/src/animation/index.ts
+++ b/apps/web/src/animation/index.ts
@@ -16,32 +16,15 @@ export {
setChannel,
splitAnimationsAtTime,
updateScalarKeyframeCurve,
- upsertElementKeyframe,
upsertPathKeyframe,
} from "./keyframes";
export {
getElementLocalTime,
resolveAnimationPathValueAtTime,
- resolveColorAtTime,
- resolveNumberAtTime,
- resolveOpacityAtTime,
- resolveTransformAtTime,
} from "./resolve";
export {
- coerceAnimationValueForProperty,
- getAnimationPropertyDefinition,
- getDefaultInterpolationForProperty,
- getElementBaseValueForProperty,
- isAnimationPropertyPath,
- supportsAnimationProperty,
- type AnimationPropertyDefinition,
- type NumericSpec,
- withElementBaseValueForProperty,
-} from "./property-registry";
-
-export {
getElementKeyframes,
getKeyframeById,
getKeyframeAtTime,
@@ -76,15 +59,6 @@ export {
} from "./effect-param-channel";
export {
- isAnimationPath,
- coerceAnimationValueForParam,
- resolveAnimationTarget,
- getParamValueKind,
- getParamDefaultInterpolation,
- type AnimationPathDescriptor,
-} from "./target-resolver";
-
-export {
getGroupKeyframesAtTime,
hasGroupKeyframeAtTime,
type GroupKeyframeRef,
@@ -94,3 +68,7 @@ export {
type EasingMode,
getEasingModeForKind,
} from "./binding-values";
+
+export {
+ isAnimationPath,
+} from "./path";
diff --git a/apps/web/src/animation/interpolation.ts b/apps/web/src/animation/interpolation.ts
index 277bfbb7..6bab66c4 100644
--- a/apps/web/src/animation/interpolation.ts
+++ b/apps/web/src/animation/interpolation.ts
@@ -8,13 +8,13 @@ import type {
ScalarAnimationKey,
ScalarSegmentType,
} from "@/animation/types";
-import { clamp } from "@/utils/math";
import {
getBezierPoint,
getDefaultLeftHandle,
getDefaultRightHandle,
solveBezierProgressForTime,
} from "./bezier";
+import { clamp } from "@/utils/math";
function byTimeAscending({
leftTime,
diff --git a/apps/web/src/animation/keyframe-query.ts b/apps/web/src/animation/keyframe-query.ts
index 231aa5f2..e65db881 100644
--- a/apps/web/src/animation/keyframe-query.ts
+++ b/apps/web/src/animation/keyframe-query.ts
@@ -13,7 +13,7 @@ import {
getChannelValueAtTime,
getScalarSegmentInterpolation,
} from "./interpolation";
-import { isAnimationPath } from "./target-resolver";
+import { isAnimationPath } from "./path";
function getBindingFallbackValue({
channel,
diff --git a/apps/web/src/animation/keyframes.ts b/apps/web/src/animation/keyframes.ts
index 12dc777d..ff0cef8f 100644
--- a/apps/web/src/animation/keyframes.ts
+++ b/apps/web/src/animation/keyframes.ts
@@ -4,7 +4,6 @@ import type {
AnimationChannel,
AnimationInterpolation,
AnimationPath,
- AnimationPropertyPath,
AnimationValue,
DiscreteAnimationChannel,
DiscreteAnimationKey,
@@ -14,7 +13,6 @@ import type {
ScalarCurveKeyframePatch,
ScalarSegmentType,
} from "@/animation/types";
-import { generateUUID } from "@/utils/id";
import {
cloneAnimationBinding,
createAnimationBinding,
@@ -31,10 +29,7 @@ import {
getScalarSegmentInterpolation,
normalizeChannel,
} from "./interpolation";
-import {
- coerceAnimationValueForProperty,
- getAnimationPropertyDefinition,
-} from "./property-registry";
+import { generateUUID } from "@/utils/id";
function isNearlySameTime({
leftTime,
@@ -527,47 +522,6 @@ export function upsertPathKeyframe({
});
}
-export function upsertElementKeyframe({
- animations,
- propertyPath,
- time,
- value,
- interpolation,
- keyframeId,
-}: {
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
- time: number;
- value: AnimationValue;
- interpolation?: AnimationInterpolation;
- keyframeId?: string;
-}): ElementAnimations | undefined {
- const coercedValue = coerceAnimationValueForProperty({
- propertyPath,
- value,
- });
- if (coercedValue === null) {
- return animations;
- }
-
- const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
- return upsertPathKeyframe({
- animations,
- propertyPath,
- time,
- value: coercedValue,
- interpolation,
- keyframeId,
- kind: propertyDefinition.kind,
- defaultInterpolation: propertyDefinition.defaultInterpolation,
- coerceValue: ({ value: nextValue }) =>
- coerceAnimationValueForProperty({
- propertyPath,
- value: nextValue,
- }),
- });
-}
-
export function upsertKeyframe({
channel,
time,
diff --git a/apps/web/src/animation/path.ts b/apps/web/src/animation/path.ts
new file mode 100644
index 00000000..69e5e228
--- /dev/null
+++ b/apps/web/src/animation/path.ts
@@ -0,0 +1,22 @@
+import type { AnimationPath, AnimationPropertyPath } from "@/animation/types";
+import { ANIMATION_PROPERTY_PATHS } from "./types";
+import { isEffectParamPath } from "./effect-param-channel";
+import { isGraphicParamPath } from "./graphic-param-channel";
+
+const ANIMATION_PROPERTY_PATH_SET = new Set<string>(ANIMATION_PROPERTY_PATHS);
+
+export function isAnimationPropertyPath(
+ propertyPath: string,
+): propertyPath is AnimationPropertyPath {
+ return ANIMATION_PROPERTY_PATH_SET.has(propertyPath);
+}
+
+export function isAnimationPath(
+ propertyPath: string,
+): propertyPath is AnimationPath {
+ return (
+ isAnimationPropertyPath(propertyPath) ||
+ isGraphicParamPath(propertyPath) ||
+ isEffectParamPath(propertyPath)
+ );
+}
diff --git a/apps/web/src/animation/resolve.ts b/apps/web/src/animation/resolve.ts
index 1b51c126..19685cea 100644
--- a/apps/web/src/animation/resolve.ts
+++ b/apps/web/src/animation/resolve.ts
@@ -1,12 +1,8 @@
import type {
- AnimationColorPropertyPath,
- AnimationNumericPropertyPath,
AnimationPath,
- AnimationPropertyPath,
AnimationValueForPath,
ElementAnimations,
} from "@/animation/types";
-import type { Transform } from "@/rendering";
import {
type AnimationComponentValue,
composeAnimationValue,
@@ -37,107 +33,6 @@ export function getElementLocalTime({
return localTime;
}
-export function resolveTransformAtTime({
- baseTransform,
- animations,
- localTime,
-}: {
- baseTransform: Transform;
- animations: ElementAnimations | undefined;
- localTime: number;
-}): Transform {
- const safeLocalTime = Math.max(0, localTime);
- return {
- position: {
- x: resolveAnimationPathValueAtTime({
- animations,
- propertyPath: "transform.positionX",
- localTime: safeLocalTime,
- fallbackValue: baseTransform.position.x,
- }),
- y: resolveAnimationPathValueAtTime({
- animations,
- propertyPath: "transform.positionY",
- localTime: safeLocalTime,
- fallbackValue: baseTransform.position.y,
- }),
- },
- scaleX: resolveAnimationPathValueAtTime({
- animations,
- propertyPath: "transform.scaleX",
- localTime: safeLocalTime,
- fallbackValue: baseTransform.scaleX,
- }),
- scaleY: resolveAnimationPathValueAtTime({
- animations,
- propertyPath: "transform.scaleY",
- localTime: safeLocalTime,
- fallbackValue: baseTransform.scaleY,
- }),
- rotate: resolveAnimationPathValueAtTime({
- animations,
- propertyPath: "transform.rotate",
- localTime: safeLocalTime,
- fallbackValue: baseTransform.rotate,
- }),
- };
-}
-
-export function resolveOpacityAtTime({
- baseOpacity,
- animations,
- localTime,
-}: {
- baseOpacity: number;
- animations: ElementAnimations | undefined;
- localTime: number;
-}): number {
- return resolveAnimationPathValueAtTime({
- animations,
- propertyPath: "opacity",
- localTime: Math.max(0, localTime),
- fallbackValue: baseOpacity,
- });
-}
-
-export function resolveNumberAtTime({
- baseValue,
- animations,
- propertyPath,
- localTime,
-}: {
- baseValue: number;
- animations: ElementAnimations | undefined;
- propertyPath: AnimationNumericPropertyPath;
- localTime: number;
-}): number {
- return resolveAnimationPathValueAtTime({
- animations,
- propertyPath,
- localTime: Math.max(0, localTime),
- fallbackValue: baseValue,
- });
-}
-
-export function resolveColorAtTime({
- baseColor,
- animations,
- propertyPath,
- localTime,
-}: {
- baseColor: string;
- animations: ElementAnimations | undefined;
- propertyPath: AnimationColorPropertyPath;
- localTime: number;
-}): string {
- return resolveAnimationPathValueAtTime({
- animations,
- propertyPath,
- localTime: Math.max(0, localTime),
- fallbackValue: baseColor,
- });
-}
-
export function resolveAnimationPathValueAtTime<TPath extends AnimationPath>({
animations,
propertyPath,
diff --git a/apps/web/src/animation/types.ts b/apps/web/src/animation/types.ts
index 3afddfa1..7e241994 100644
--- a/apps/web/src/animation/types.ts
+++ b/apps/web/src/animation/types.ts
@@ -1,5 +1,3 @@
-import type { ParamValues } from "@/params";
-
export const ANIMATION_PROPERTY_PATHS = [
"transform.positionX",
"transform.positionY",
@@ -50,7 +48,13 @@ export interface AnimationPropertyValueMap {
"background.offsetY": number;
"background.cornerRadius": number;
}
-export type DynamicAnimationPathValue = ParamValues[string];
+export type DynamicAnimationPathValue = number | string | boolean;
+
+export interface NumericSpec {
+ min?: number;
+ max?: number;
+ step?: number;
+}
export type AnimationValueForPath<TPath extends AnimationPath> =
TPath extends AnimationPropertyPath
? AnimationPropertyValueMap[TPath]
diff --git a/apps/web/src/animation/values.ts b/apps/web/src/animation/values.ts
new file mode 100644
index 00000000..7733ec4c
--- /dev/null
+++ b/apps/web/src/animation/values.ts
@@ -0,0 +1,61 @@
+import type {
+ AnimationColorPropertyPath,
+ AnimationNumericPropertyPath,
+ ElementAnimations,
+} from "./types";
+import { resolveAnimationPathValueAtTime } from "./resolve";
+
+export function resolveOpacityAtTime({
+ baseOpacity,
+ animations,
+ localTime,
+}: {
+ baseOpacity: number;
+ animations: ElementAnimations | undefined;
+ localTime: number;
+}): number {
+ return resolveAnimationPathValueAtTime({
+ animations,
+ propertyPath: "opacity",
+ localTime: Math.max(0, localTime),
+ fallbackValue: baseOpacity,
+ });
+}
+
+export function resolveNumberAtTime({
+ baseValue,
+ animations,
+ propertyPath,
+ localTime,
+}: {
+ baseValue: number;
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationNumericPropertyPath;
+ localTime: number;
+}): number {
+ return resolveAnimationPathValueAtTime({
+ animations,
+ propertyPath,
+ localTime: Math.max(0, localTime),
+ fallbackValue: baseValue,
+ });
+}
+
+export function resolveColorAtTime({
+ baseColor,
+ animations,
+ propertyPath,
+ localTime,
+}: {
+ baseColor: string;
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationColorPropertyPath;
+ localTime: number;
+}): string {
+ return resolveAnimationPathValueAtTime({
+ animations,
+ propertyPath,
+ localTime: Math.max(0, localTime),
+ fallbackValue: baseColor,
+ });
+}
diff --git a/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts b/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts
index e05b49fa..c19bd526 100644
--- a/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts
+++ b/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts
@@ -1,7 +1,6 @@
import { EditorCore } from "@/core";
import {
getKeyframeAtTime,
- resolveAnimationTarget,
updateScalarKeyframeCurve,
upsertPathKeyframe,
} from "@/animation";
@@ -9,6 +8,7 @@ import { Command, type CommandResult } from "@/commands/base-command";
import type { KeyframeClipboardItem } from "@/clipboard";
import type { SceneTracks, TimelineElement } from "@/timeline";
import { updateElementInSceneTracks } from "@/timeline";
+import { resolveAnimationTarget } from "@/timeline/animation-targets";
import { generateUUID } from "@/utils/id";
function pasteKeyframesIntoElement({
diff --git a/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts
index ed917406..07abe2be 100644
--- a/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts
+++ b/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts
@@ -2,12 +2,12 @@ import { EditorCore } from "@/core";
import {
hasKeyframesForPath,
removeElementKeyframe,
- resolveAnimationTarget,
} from "@/animation";
import { Command, type CommandResult } from "@/commands/base-command";
import { updateElementInSceneTracks } from "@/timeline";
import type { AnimationPath, AnimationValue } from "@/animation/types";
import type { SceneTracks, TimelineElement } from "@/timeline";
+import { resolveAnimationTarget } from "@/timeline/animation-targets";
function removeKeyframeAndPersist({
element,
diff --git a/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts
index ccbdbc5d..aa04e2bc 100644
--- a/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts
+++ b/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts
@@ -1,9 +1,10 @@
import { EditorCore } from "@/core";
-import { resolveAnimationTarget, retimeElementKeyframe } from "@/animation";
+import { retimeElementKeyframe } from "@/animation";
import { Command, type CommandResult } from "@/commands/base-command";
import { updateElementInSceneTracks } from "@/timeline";
import type { AnimationPath } from "@/animation/types";
import type { SceneTracks } from "@/timeline";
+import { resolveAnimationTarget } from "@/timeline/animation-targets";
export class RetimeKeyframeCommand extends Command {
private savedState: SceneTracks | null = null;
diff --git a/apps/web/src/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts b/apps/web/src/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts
index 4a3db39d..51f10ee9 100644
--- a/apps/web/src/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts
+++ b/apps/web/src/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts
@@ -1,10 +1,10 @@
import { EditorCore } from "@/core";
import {
- resolveAnimationTarget,
updateScalarKeyframeCurve,
} from "@/animation";
import { Command, type CommandResult } from "@/commands/base-command";
import { updateElementInSceneTracks } from "@/timeline";
+import { resolveAnimationTarget } from "@/timeline/animation-targets";
import type {
AnimationPath,
ScalarCurveKeyframePatch,
diff --git a/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts
index eae8f039..7bd1b2e5 100644
--- a/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts
+++ b/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts
@@ -2,11 +2,11 @@ import { EditorCore } from "@/core";
import { Command, type CommandResult } from "@/commands/base-command";
import {
buildEffectParamPath,
- resolveAnimationTarget,
upsertPathKeyframe,
} from "@/animation";
import { updateElementInSceneTracks } from "@/timeline";
import { isVisualElement } from "@/timeline/element-utils";
+import { resolveAnimationTarget } from "@/timeline/animation-targets";
import type { AnimationInterpolation } from "@/animation/types";
import type { SceneTracks } from "@/timeline";
diff --git a/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts
index 30514146..15c18bfc 100644
--- a/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts
+++ b/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts
@@ -1,8 +1,9 @@
import { EditorCore } from "@/core";
import { Command, type CommandResult } from "@/commands/base-command";
-import { resolveAnimationTarget, upsertPathKeyframe } from "@/animation";
+import { upsertPathKeyframe } from "@/animation";
import { updateElementInSceneTracks } from "@/timeline";
import type { SceneTracks } from "@/timeline";
+import { resolveAnimationTarget } from "@/timeline/animation-targets";
import type {
AnimationPath,
AnimationInterpolation,
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts
index 734b514f..80fc171b 100644
--- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts
@@ -1,95 +1,95 @@
-import { useEditor } from "@/editor/use-editor";
-import {
- getKeyframeAtTime,
- hasKeyframesForPath,
- upsertElementKeyframe,
-} from "@/animation";
-import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
-import type { TimelineElement } from "@/timeline";
-
-export function useKeyframedColorProperty({
- trackId,
- elementId,
- animations,
- propertyPath,
- localTime,
- isPlayheadWithinElementRange,
- resolvedColor,
- buildBaseUpdates,
-}: {
- trackId: string;
- elementId: string;
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
- localTime: number;
- isPlayheadWithinElementRange: boolean;
- resolvedColor: string;
- buildBaseUpdates: ({ value }: { value: string }) => Partial<TimelineElement>;
-}) {
- const editor = useEditor();
-
- const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
- const keyframeAtTime = isPlayheadWithinElementRange
- ? getKeyframeAtTime({ animations, propertyPath, time: localTime })
- : null;
- const keyframeIdAtTime = keyframeAtTime?.id ?? null;
- const isKeyframedAtTime = keyframeAtTime !== null;
- const shouldUseAnimatedChannel =
- hasAnimatedKeyframes && isPlayheadWithinElementRange;
-
- const onChange = ({ color }: { color: string }) => {
- if (shouldUseAnimatedChannel) {
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId,
- updates: {
- animations: upsertElementKeyframe({
- animations,
- propertyPath,
- time: localTime,
- value: color,
- }),
- },
- },
- ],
- });
- return;
- }
-
- editor.timeline.previewElements({
- updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: color }) }],
- });
- };
-
- const onChangeEnd = () => editor.timeline.commitPreview();
-
- const toggleKeyframe = () => {
- if (!isPlayheadWithinElementRange) {
- return;
- }
-
- if (keyframeIdAtTime) {
- editor.timeline.removeKeyframes({
- keyframes: [{ trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }],
- });
- return;
- }
-
- editor.timeline.upsertKeyframes({
- keyframes: [
- { trackId, elementId, propertyPath, time: localTime, value: resolvedColor },
- ],
- });
- };
-
- return {
- isKeyframedAtTime,
- hasAnimatedKeyframes,
- keyframeIdAtTime,
- onChange,
- onChangeEnd,
- toggleKeyframe,
- };
-}
+import { useEditor } from "@/editor/use-editor";
+import {
+ getKeyframeAtTime,
+ hasKeyframesForPath,
+} from "@/animation";
+import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
+import type { TimelineElement } from "@/timeline";
+import { upsertElementKeyframe } from "@/timeline/animation-properties";
+
+export function useKeyframedColorProperty({
+ trackId,
+ elementId,
+ animations,
+ propertyPath,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedColor,
+ buildBaseUpdates,
+}: {
+ trackId: string;
+ elementId: string;
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+ localTime: number;
+ isPlayheadWithinElementRange: boolean;
+ resolvedColor: string;
+ buildBaseUpdates: ({ value }: { value: string }) => Partial<TimelineElement>;
+}) {
+ const editor = useEditor();
+
+ const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
+ const keyframeAtTime = isPlayheadWithinElementRange
+ ? getKeyframeAtTime({ animations, propertyPath, time: localTime })
+ : null;
+ const keyframeIdAtTime = keyframeAtTime?.id ?? null;
+ const isKeyframedAtTime = keyframeAtTime !== null;
+ const shouldUseAnimatedChannel =
+ hasAnimatedKeyframes && isPlayheadWithinElementRange;
+
+ const onChange = ({ color }: { color: string }) => {
+ if (shouldUseAnimatedChannel) {
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: {
+ animations: upsertElementKeyframe({
+ animations,
+ propertyPath,
+ time: localTime,
+ value: color,
+ }),
+ },
+ },
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.previewElements({
+ updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: color }) }],
+ });
+ };
+
+ const onChangeEnd = () => editor.timeline.commitPreview();
+
+ const toggleKeyframe = () => {
+ if (!isPlayheadWithinElementRange) {
+ return;
+ }
+
+ if (keyframeIdAtTime) {
+ editor.timeline.removeKeyframes({
+ keyframes: [{ trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }],
+ });
+ return;
+ }
+
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ { trackId, elementId, propertyPath, time: localTime, value: resolvedColor },
+ ],
+ });
+ };
+
+ return {
+ isKeyframedAtTime,
+ hasAnimatedKeyframes,
+ keyframeIdAtTime,
+ onChange,
+ onChangeEnd,
+ toggleKeyframe,
+ };
+}
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts
index 9dd39768..32222b7a 100644
--- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts
@@ -1,174 +1,174 @@
-import { useEditor } from "@/editor/use-editor";
-import {
- getKeyframeAtTime,
- hasKeyframesForPath,
- upsertElementKeyframe,
-} from "@/animation";
-import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
-import type { TimelineElement } from "@/timeline";
-import { snapToStep } from "@/utils/math";
-import { usePropertyDraft } from "./use-property-draft";
-
-export function useKeyframedNumberProperty({
- trackId,
- elementId,
- animations,
- propertyPath,
- localTime,
- isPlayheadWithinElementRange,
- displayValue,
- parse,
- valueAtPlayhead,
- step,
- buildBaseUpdates,
- buildAdditionalKeyframes,
-}: {
- trackId: string;
- elementId: string;
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
- localTime: number;
- isPlayheadWithinElementRange: boolean;
- displayValue: string;
- parse: (input: string) => number | null;
- valueAtPlayhead: number;
- step?: number;
- buildBaseUpdates: ({ value }: { value: number }) => Partial<TimelineElement>;
- buildAdditionalKeyframes?: ({
- value,
- }: { value: number }) => Array<{ propertyPath: AnimationPropertyPath; value: number }>;
-}) {
- const editor = useEditor();
- const snapValue = (value: number) =>
- step != null ? snapToStep({ value, step }) : value;
-
- const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
- const keyframeAtTime = isPlayheadWithinElementRange
- ? getKeyframeAtTime({ animations, propertyPath, time: localTime })
- : null;
- const keyframeIdAtTime = keyframeAtTime?.id ?? null;
- const isKeyframedAtTime = keyframeAtTime !== null;
- const shouldUseAnimatedChannel =
- hasAnimatedKeyframes && isPlayheadWithinElementRange;
-
- const previewValue = ({ value }: { value: number }) => {
- const nextValue = snapValue(value);
- if (shouldUseAnimatedChannel) {
- const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? [];
- const updatedAnimations = [
- { propertyPath, value: nextValue },
- ...additionalKeyframes,
- ].reduce(
- (currentAnimations, keyframe) =>
- upsertElementKeyframe({
- animations: currentAnimations,
- propertyPath: keyframe.propertyPath,
- time: localTime,
- value: keyframe.value,
- }),
- animations,
- );
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId,
- updates: { animations: updatedAnimations },
- },
- ],
- });
- return;
- }
-
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId,
- updates: buildBaseUpdates({ value: nextValue }),
- },
- ],
- });
- };
-
- const propertyDraft = usePropertyDraft({
- displayValue,
- parse: (input) => {
- const parsedValue = parse(input);
- return parsedValue === null ? null : snapValue(parsedValue);
- },
- onPreview: (value) => previewValue({ value }),
- onCommit: () => editor.timeline.commitPreview(),
- });
-
- const toggleKeyframe = () => {
- if (!isPlayheadWithinElementRange) {
- return;
- }
-
- if (keyframeIdAtTime) {
- editor.timeline.removeKeyframes({
- keyframes: [
- {
- trackId,
- elementId,
- propertyPath,
- keyframeId: keyframeIdAtTime,
- },
- ],
- });
- return;
- }
-
- editor.timeline.upsertKeyframes({
- keyframes: [
- {
- trackId,
- elementId,
- propertyPath,
- time: localTime,
- value: snapValue(valueAtPlayhead),
- },
- ],
- });
- };
-
- const commitValue = ({ value }: { value: number }) => {
- const nextValue = snapValue(value);
- if (shouldUseAnimatedChannel) {
- const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? [];
- editor.timeline.upsertKeyframes({
- keyframes: [
- { trackId, elementId, propertyPath, time: localTime, value: nextValue },
- ...additionalKeyframes.map((keyframe) => ({
- trackId,
- elementId,
- propertyPath: keyframe.propertyPath,
- time: localTime,
- value: keyframe.value,
- })),
- ],
- });
- return;
- }
-
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId,
- patch: buildBaseUpdates({ value: nextValue }),
- },
- ],
- });
- };
-
- return {
- ...propertyDraft,
- hasAnimatedKeyframes,
- isKeyframedAtTime,
- keyframeIdAtTime,
- toggleKeyframe,
- commitValue,
- };
-}
+import { useEditor } from "@/editor/use-editor";
+import {
+ getKeyframeAtTime,
+ hasKeyframesForPath,
+} from "@/animation";
+import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
+import type { TimelineElement } from "@/timeline";
+import { upsertElementKeyframe } from "@/timeline/animation-properties";
+import { snapToStep } from "@/utils/math";
+import { usePropertyDraft } from "./use-property-draft";
+
+export function useKeyframedNumberProperty({
+ trackId,
+ elementId,
+ animations,
+ propertyPath,
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue,
+ parse,
+ valueAtPlayhead,
+ step,
+ buildBaseUpdates,
+ buildAdditionalKeyframes,
+}: {
+ trackId: string;
+ elementId: string;
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+ localTime: number;
+ isPlayheadWithinElementRange: boolean;
+ displayValue: string;
+ parse: (input: string) => number | null;
+ valueAtPlayhead: number;
+ step?: number;
+ buildBaseUpdates: ({ value }: { value: number }) => Partial<TimelineElement>;
+ buildAdditionalKeyframes?: ({
+ value,
+ }: { value: number }) => Array<{ propertyPath: AnimationPropertyPath; value: number }>;
+}) {
+ const editor = useEditor();
+ const snapValue = (value: number) =>
+ step != null ? snapToStep({ value, step }) : value;
+
+ const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
+ const keyframeAtTime = isPlayheadWithinElementRange
+ ? getKeyframeAtTime({ animations, propertyPath, time: localTime })
+ : null;
+ const keyframeIdAtTime = keyframeAtTime?.id ?? null;
+ const isKeyframedAtTime = keyframeAtTime !== null;
+ const shouldUseAnimatedChannel =
+ hasAnimatedKeyframes && isPlayheadWithinElementRange;
+
+ const previewValue = ({ value }: { value: number }) => {
+ const nextValue = snapValue(value);
+ if (shouldUseAnimatedChannel) {
+ const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? [];
+ const updatedAnimations = [
+ { propertyPath, value: nextValue },
+ ...additionalKeyframes,
+ ].reduce(
+ (currentAnimations, keyframe) =>
+ upsertElementKeyframe({
+ animations: currentAnimations,
+ propertyPath: keyframe.propertyPath,
+ time: localTime,
+ value: keyframe.value,
+ }),
+ animations,
+ );
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: { animations: updatedAnimations },
+ },
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: buildBaseUpdates({ value: nextValue }),
+ },
+ ],
+ });
+ };
+
+ const propertyDraft = usePropertyDraft({
+ displayValue,
+ parse: (input) => {
+ const parsedValue = parse(input);
+ return parsedValue === null ? null : snapValue(parsedValue);
+ },
+ onPreview: (value) => previewValue({ value }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
+
+ const toggleKeyframe = () => {
+ if (!isPlayheadWithinElementRange) {
+ return;
+ }
+
+ if (keyframeIdAtTime) {
+ editor.timeline.removeKeyframes({
+ keyframes: [
+ {
+ trackId,
+ elementId,
+ propertyPath,
+ keyframeId: keyframeIdAtTime,
+ },
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ {
+ trackId,
+ elementId,
+ propertyPath,
+ time: localTime,
+ value: snapValue(valueAtPlayhead),
+ },
+ ],
+ });
+ };
+
+ const commitValue = ({ value }: { value: number }) => {
+ const nextValue = snapValue(value);
+ if (shouldUseAnimatedChannel) {
+ const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? [];
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ { trackId, elementId, propertyPath, time: localTime, value: nextValue },
+ ...additionalKeyframes.map((keyframe) => ({
+ trackId,
+ elementId,
+ propertyPath: keyframe.propertyPath,
+ time: localTime,
+ value: keyframe.value,
+ })),
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ patch: buildBaseUpdates({ value: nextValue }),
+ },
+ ],
+ });
+ };
+
+ return {
+ ...propertyDraft,
+ hasAnimatedKeyframes,
+ isKeyframedAtTime,
+ keyframeIdAtTime,
+ toggleKeyframe,
+ commitValue,
+ };
+}
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts
index b0b6f01a..18e027c7 100644
--- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts
@@ -3,16 +3,18 @@
import { useEditor } from "@/editor/use-editor";
import {
buildGraphicParamPath,
- coerceAnimationValueForParam,
getKeyframeAtTime,
- getParamDefaultInterpolation,
- getParamValueKind,
hasKeyframesForPath,
upsertPathKeyframe,
} from "@/animation";
import type {
ElementAnimations,
} from "@/animation/types";
+import {
+ coerceAnimationParamValue,
+ getAnimationParamDefaultInterpolation,
+ getAnimationParamValueKind,
+} from "@/animation/animated-params";
import type { ParamDefinition } from "@/params";
import type { TimelineElement } from "@/timeline";
@@ -79,12 +81,12 @@ export function useKeyframedParamProperty({
propertyPath,
time: localTime,
value,
- kind: getParamValueKind({ param }),
- defaultInterpolation: getParamDefaultInterpolation({
+ kind: getAnimationParamValueKind({ param }),
+ defaultInterpolation: getAnimationParamDefaultInterpolation({
param,
}),
coerceValue: ({ value: nextValue }) =>
- coerceAnimationValueForParam({
+ coerceAnimationParamValue({
param,
value: nextValue,
}),
diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts
index 6f54c5aa..a2bf7451 100644
--- a/apps/web/src/core/managers/timeline-manager.ts
+++ b/apps/web/src/core/managers/timeline-manager.ts
@@ -24,9 +24,9 @@ import type {
} from "@/animation/types";
import {
getElementLocalTime,
- resolveAnimationTarget,
resolveAnimationPathValueAtTime,
} from "@/animation";
+import { resolveAnimationTarget } from "@/timeline/animation-targets";
import { lastFrameTime } from "opencut-wasm";
import { BatchCommand } from "@/commands";
import {
diff --git a/apps/web/src/graphics/components/graphic-tab.tsx b/apps/web/src/graphics/components/graphic-tab.tsx
index 60599772..e0ae0236 100644
--- a/apps/web/src/graphics/components/graphic-tab.tsx
+++ b/apps/web/src/graphics/components/graphic-tab.tsx
@@ -1,238 +1,241 @@
-"use client";
-
-import { useRef } from "react";
-import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
-import {
- useKeyframedParamProperty,
- type KeyframedParamPropertyResult,
-} from "@/components/editor/panels/properties/hooks/use-keyframed-param-property";
-import { resolveGraphicParamsAtTime } from "@/animation";
-import type { ParamDefinition, ParamValues } from "@/params";
-import type { GraphicElement } from "@/timeline";
-import { graphicsRegistry, registerDefaultGraphics } from "@/graphics";
-import { useElementPreview } from "@/timeline/hooks/use-element-preview";
-import { useEditor } from "@/editor/use-editor";
-import {
- Section,
- SectionContent,
- SectionFields,
- SectionHeader,
- SectionTitle,
-} from "@/components/section";
-import { PropertyParamField } from "@/components/editor/panels/properties/components/property-param-field";
-import { Button } from "@/components/ui/button";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons";
-import { cn } from "@/utils/ui";
-
-registerDefaultGraphics();
-
-const DEFAULT_STROKE_WIDTH = 2;
-
-export function GraphicTab({
- element,
- trackId,
-}: {
- element: GraphicElement;
- trackId: string;
-}) {
- const definition = graphicsRegistry.get(element.definitionId);
- const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
- startTime: element.startTime,
- duration: element.duration,
- });
- const { renderElement } = useElementPreview({
- trackId,
- elementId: element.id,
- fallback: element,
- });
-
- const liveElement = renderElement as GraphicElement;
- const resolvedParams = resolveGraphicParamsAtTime({
- element: liveElement,
- localTime,
- });
-
- const shapeParams = definition.params.filter((p) => p.group !== "stroke");
- const hasStrokeParams = definition.params.some((p) => p.group === "stroke");
-
- return (
- <div className="flex flex-col">
- <Section collapsible sectionKey={`${element.id}:graphic`}>
- <SectionHeader>
- <SectionTitle>{definition.name}</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <SectionFields>
- {shapeParams.map((param) => (
- <AnimatedGraphicParamField
- key={param.key}
- param={param}
- trackId={trackId}
- element={liveElement}
- localTime={localTime}
- isPlayheadWithinElementRange={isPlayheadWithinElementRange}
- resolvedParams={resolvedParams}
- />
- ))}
- </SectionFields>
- </SectionContent>
- </Section>
- {hasStrokeParams && <StrokeSection element={element} trackId={trackId} />}
- </div>
- );
-}
-
-function StrokeSection({
- element,
- trackId,
-}: {
- element: GraphicElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const definition = graphicsRegistry.get(element.definitionId);
- const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
- startTime: element.startTime,
- duration: element.duration,
- });
- const { renderElement } = useElementPreview({
- trackId,
- elementId: element.id,
- fallback: element,
- });
-
- const liveElement = renderElement as GraphicElement;
- const resolvedParams = resolveGraphicParamsAtTime({
- element: liveElement,
- localTime,
- });
- const strokeParams = definition.params.filter((p) => p.group === "stroke");
- const lastStrokeWidth = useRef(DEFAULT_STROKE_WIDTH);
- const isStrokeEnabled = Number(element.params.strokeWidth ?? 0) > 0;
-
- const toggleStroke = () => {
- if (isStrokeEnabled) {
- lastStrokeWidth.current = Number(
- element.params.strokeWidth ?? DEFAULT_STROKE_WIDTH,
- );
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- patch: { params: { ...element.params, strokeWidth: 0 } },
- },
- ],
- });
- } else {
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- patch: {
- params: {
- ...element.params,
- strokeWidth: lastStrokeWidth.current,
- },
- },
- },
- ],
- });
- }
- };
-
- return (
- <Section
- collapsible
- defaultOpen={isStrokeEnabled}
- sectionKey={`${element.id}:stroke`}
- >
- <SectionHeader
- trailing={
- <Button
- variant="ghost"
- size="icon"
- onClick={(event) => {
- event.stopPropagation();
- toggleStroke();
- }}
- >
- <HugeiconsIcon
- icon={isStrokeEnabled ? MinusSignIcon : PlusSignIcon}
- strokeWidth={1}
- />
- </Button>
- }
- >
- <SectionTitle>Stroke</SectionTitle>
- </SectionHeader>
- <SectionContent
- className={cn(!isStrokeEnabled && "pointer-events-none opacity-50")}
- >
- <SectionFields>
- {strokeParams.map((param) => (
- <AnimatedGraphicParamField
- key={param.key}
- param={param}
- trackId={trackId}
- element={liveElement}
- localTime={localTime}
- isPlayheadWithinElementRange={isPlayheadWithinElementRange}
- resolvedParams={resolvedParams}
- />
- ))}
- </SectionFields>
- </SectionContent>
- </Section>
- );
-}
-
-function AnimatedGraphicParamField({
- param,
- trackId,
- element,
- localTime,
- isPlayheadWithinElementRange,
- resolvedParams,
-}: {
- param: ParamDefinition;
- trackId: string;
- element: GraphicElement;
- localTime: number;
- isPlayheadWithinElementRange: boolean;
- resolvedParams: ParamValues;
-}) {
- const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty(
- {
- param,
- trackId,
- elementId: element.id,
- animations: element.animations,
- localTime,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedParams[param.key] ?? param.default,
- buildBaseUpdates: ({ value }) => ({
- params: {
- ...element.params,
- [param.key]: value,
- },
- }),
- },
- );
-
- return (
- <PropertyParamField
- param={param}
- value={resolvedParams[param.key] ?? param.default}
- onPreview={animatedParam.onPreview}
- onCommit={animatedParam.onCommit}
- keyframe={{
- isActive: animatedParam.isKeyframedAtTime,
- isDisabled: !isPlayheadWithinElementRange,
- onToggle: animatedParam.toggleKeyframe,
- }}
- />
- );
-}
+"use client";
+
+import { useRef } from "react";
+import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
+import {
+ useKeyframedParamProperty,
+ type KeyframedParamPropertyResult,
+} from "@/components/editor/panels/properties/hooks/use-keyframed-param-property";
+import type { ParamDefinition, ParamValues } from "@/params";
+import type { GraphicElement } from "@/timeline";
+import {
+ graphicsRegistry,
+ registerDefaultGraphics,
+ resolveGraphicElementParamsAtTime,
+} from "@/graphics";
+import { useElementPreview } from "@/timeline/hooks/use-element-preview";
+import { useEditor } from "@/editor/use-editor";
+import {
+ Section,
+ SectionContent,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { PropertyParamField } from "@/components/editor/panels/properties/components/property-param-field";
+import { Button } from "@/components/ui/button";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons";
+import { cn } from "@/utils/ui";
+
+registerDefaultGraphics();
+
+const DEFAULT_STROKE_WIDTH = 2;
+
+export function GraphicTab({
+ element,
+ trackId,
+}: {
+ element: GraphicElement;
+ trackId: string;
+}) {
+ const definition = graphicsRegistry.get(element.definitionId);
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const { renderElement } = useElementPreview({
+ trackId,
+ elementId: element.id,
+ fallback: element,
+ });
+
+ const liveElement = renderElement as GraphicElement;
+ const resolvedParams = resolveGraphicElementParamsAtTime({
+ element: liveElement,
+ localTime,
+ });
+
+ const shapeParams = definition.params.filter((p) => p.group !== "stroke");
+ const hasStrokeParams = definition.params.some((p) => p.group === "stroke");
+
+ return (
+ <div className="flex flex-col">
+ <Section collapsible sectionKey={`${element.id}:graphic`}>
+ <SectionHeader>
+ <SectionTitle>{definition.name}</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <SectionFields>
+ {shapeParams.map((param) => (
+ <AnimatedGraphicParamField
+ key={param.key}
+ param={param}
+ trackId={trackId}
+ element={liveElement}
+ localTime={localTime}
+ isPlayheadWithinElementRange={isPlayheadWithinElementRange}
+ resolvedParams={resolvedParams}
+ />
+ ))}
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ {hasStrokeParams && <StrokeSection element={element} trackId={trackId} />}
+ </div>
+ );
+}
+
+function StrokeSection({
+ element,
+ trackId,
+}: {
+ element: GraphicElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const definition = graphicsRegistry.get(element.definitionId);
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const { renderElement } = useElementPreview({
+ trackId,
+ elementId: element.id,
+ fallback: element,
+ });
+
+ const liveElement = renderElement as GraphicElement;
+ const resolvedParams = resolveGraphicElementParamsAtTime({
+ element: liveElement,
+ localTime,
+ });
+ const strokeParams = definition.params.filter((p) => p.group === "stroke");
+ const lastStrokeWidth = useRef(DEFAULT_STROKE_WIDTH);
+ const isStrokeEnabled = Number(element.params.strokeWidth ?? 0) > 0;
+
+ const toggleStroke = () => {
+ if (isStrokeEnabled) {
+ lastStrokeWidth.current = Number(
+ element.params.strokeWidth ?? DEFAULT_STROKE_WIDTH,
+ );
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ patch: { params: { ...element.params, strokeWidth: 0 } },
+ },
+ ],
+ });
+ } else {
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ patch: {
+ params: {
+ ...element.params,
+ strokeWidth: lastStrokeWidth.current,
+ },
+ },
+ },
+ ],
+ });
+ }
+ };
+
+ return (
+ <Section
+ collapsible
+ defaultOpen={isStrokeEnabled}
+ sectionKey={`${element.id}:stroke`}
+ >
+ <SectionHeader
+ trailing={
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={(event) => {
+ event.stopPropagation();
+ toggleStroke();
+ }}
+ >
+ <HugeiconsIcon
+ icon={isStrokeEnabled ? MinusSignIcon : PlusSignIcon}
+ strokeWidth={1}
+ />
+ </Button>
+ }
+ >
+ <SectionTitle>Stroke</SectionTitle>
+ </SectionHeader>
+ <SectionContent
+ className={cn(!isStrokeEnabled && "pointer-events-none opacity-50")}
+ >
+ <SectionFields>
+ {strokeParams.map((param) => (
+ <AnimatedGraphicParamField
+ key={param.key}
+ param={param}
+ trackId={trackId}
+ element={liveElement}
+ localTime={localTime}
+ isPlayheadWithinElementRange={isPlayheadWithinElementRange}
+ resolvedParams={resolvedParams}
+ />
+ ))}
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
+
+function AnimatedGraphicParamField({
+ param,
+ trackId,
+ element,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedParams,
+}: {
+ param: ParamDefinition;
+ trackId: string;
+ element: GraphicElement;
+ localTime: number;
+ isPlayheadWithinElementRange: boolean;
+ resolvedParams: ParamValues;
+}) {
+ const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty(
+ {
+ param,
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedParams[param.key] ?? param.default,
+ buildBaseUpdates: ({ value }) => ({
+ params: {
+ ...element.params,
+ [param.key]: value,
+ },
+ }),
+ },
+ );
+
+ return (
+ <PropertyParamField
+ param={param}
+ value={resolvedParams[param.key] ?? param.default}
+ onPreview={animatedParam.onPreview}
+ onCommit={animatedParam.onCommit}
+ keyframe={{
+ isActive: animatedParam.isKeyframedAtTime,
+ isDisabled: !isPlayheadWithinElementRange,
+ onToggle: animatedParam.toggleKeyframe,
+ }}
+ />
+ );
+}
diff --git a/apps/web/src/graphics/index.ts b/apps/web/src/graphics/index.ts
index c69a4ab0..3f277d40 100644
--- a/apps/web/src/graphics/index.ts
+++ b/apps/web/src/graphics/index.ts
@@ -1,3 +1,5 @@
+import { resolveGraphicParamsAtTime } from "@/animation";
+import type { ElementAnimations } from "@/animation/types";
import { buildDefaultParamValues } from "@/params/registry";
import type { ParamValues } from "@/params";
import { graphicsRegistry } from "./registry";
@@ -68,6 +70,28 @@ export function resolveGraphicParams(
};
}
+export function resolveGraphicElementParamsAtTime({
+ element,
+ localTime,
+}: {
+ element: {
+ definitionId: string;
+ params: ParamValues;
+ animations?: ElementAnimations;
+ };
+ localTime: number;
+}): ParamValues {
+ const definition = getGraphicDefinition({
+ definitionId: element.definitionId,
+ });
+ return resolveGraphicParamsAtTime({
+ params: resolveGraphicParams(definition, element.params),
+ definitions: definition.params,
+ animations: element.animations,
+ localTime,
+ });
+}
+
export function buildGraphicPreviewUrl({
definitionId,
params,
diff --git a/apps/web/src/preview/components/text-edit-overlay.tsx b/apps/web/src/preview/components/text-edit-overlay.tsx
index bc27c724..1d599b3b 100644
--- a/apps/web/src/preview/components/text-edit-overlay.tsx
+++ b/apps/web/src/preview/components/text-edit-overlay.tsx
@@ -7,8 +7,8 @@ import type { TextElement } from "@/timeline";
import { DEFAULTS } from "@/timeline/defaults";
import {
getElementLocalTime,
- resolveTransformAtTime,
} from "@/animation";
+import { resolveTransformAtTime } from "@/rendering/animation-values";
import { resolveTextLayout } from "@/text/primitives";
export function TextEditOverlay({
diff --git a/apps/web/src/preview/controllers/transform-handle-controller.ts b/apps/web/src/preview/controllers/transform-handle-controller.ts
index e9d251e3..67f677f6 100644
--- a/apps/web/src/preview/controllers/transform-handle-controller.ts
+++ b/apps/web/src/preview/controllers/transform-handle-controller.ts
@@ -20,11 +20,11 @@ import { isVisualElement } from "@/timeline/element-utils";
import {
getElementLocalTime,
hasKeyframesForPath,
- resolveTransformAtTime,
setChannel,
} from "@/animation";
import type { ElementAnimations } from "@/animation/types";
import type { Transform } from "@/rendering";
+import { resolveTransformAtTime } from "@/rendering/animation-values";
import type {
ElementRef,
SceneTracks,
diff --git a/apps/web/src/preview/element-bounds.ts b/apps/web/src/preview/element-bounds.ts
index ac556181..0afc6e4e 100644
--- a/apps/web/src/preview/element-bounds.ts
+++ b/apps/web/src/preview/element-bounds.ts
@@ -1,308 +1,308 @@
-import type { SceneTracks, TimelineElement } from "@/timeline";
-import type { MediaAsset } from "@/media/types";
-import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/stickers/intrinsic-size";
-import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/graphics";
-import { measureTextElement } from "@/text/measure-element";
-import {
- getElementLocalTime,
- resolveTransformAtTime,
-} from "@/animation";
-
-export interface ElementBounds {
- cx: number;
- cy: number;
- width: number;
- height: number;
- rotation: number;
-}
-
-export interface ElementWithBounds {
- trackId: string;
- elementId: string;
- element: TimelineElement;
- bounds: ElementBounds;
-}
-
-function getVisualElementBounds({
- canvasWidth,
- canvasHeight,
- sourceWidth,
- sourceHeight,
- transform,
-}: {
- canvasWidth: number;
- canvasHeight: number;
- sourceWidth: number;
- sourceHeight: number;
- transform: {
- scaleX: number;
- scaleY: number;
- position: { x: number; y: number };
- rotate: number;
- };
-}): ElementBounds {
- const containScale = Math.min(
- canvasWidth / sourceWidth,
- canvasHeight / sourceHeight,
- );
- const scaledWidth = sourceWidth * containScale * transform.scaleX;
- const scaledHeight = sourceHeight * containScale * transform.scaleY;
- const cx = canvasWidth / 2 + transform.position.x;
- const cy = canvasHeight / 2 + transform.position.y;
-
- return {
- cx,
- cy,
- width: scaledWidth,
- height: scaledHeight,
- rotation: transform.rotate,
- };
-}
-
-function getTransformedRectBounds({
- canvasWidth,
- canvasHeight,
- rect,
- transform,
-}: {
- canvasWidth: number;
- canvasHeight: number;
- rect: { left: number; top: number; width: number; height: number };
- transform: {
- scaleX: number;
- scaleY: number;
- position: { x: number; y: number };
- rotate: number;
- };
-}): ElementBounds {
- const localCenterX = rect.left + rect.width / 2;
- const localCenterY = rect.top + rect.height / 2;
- const scaledCenterX = localCenterX * transform.scaleX;
- const scaledCenterY = localCenterY * transform.scaleY;
- const rotationRad = (transform.rotate * Math.PI) / 180;
- const cos = Math.cos(rotationRad);
- const sin = Math.sin(rotationRad);
- return {
- cx:
- canvasWidth / 2 +
- transform.position.x +
- scaledCenterX * cos -
- scaledCenterY * sin,
- cy:
- canvasHeight / 2 +
- transform.position.y +
- scaledCenterX * sin +
- scaledCenterY * cos,
- width: rect.width * transform.scaleX,
- height: rect.height * transform.scaleY,
- rotation: transform.rotate,
- };
-}
-
-/**
- * Bounds policy: bounds reflect base content geometry (text glyphs + background,
- * sticker/image/video content area) and base transform. Post-effect spill (blur,
- * glow) and mask-clipped regions are intentionally excluded — handles manipulate
- * the canonical element geometry, not visual effect output.
- */
-function getElementBounds({
- element,
- canvasSize,
- mediaAsset,
- localTime,
-}: {
- element: TimelineElement;
- canvasSize: { width: number; height: number };
- mediaAsset?: MediaAsset | null;
- localTime: number;
-}): ElementBounds | null {
- if (element.type === "audio" || element.type === "effect") return null;
- if ("hidden" in element && element.hidden) return null;
-
- const { width: canvasWidth, height: canvasHeight } = canvasSize;
-
- if (element.type === "video" || element.type === "image") {
- const transform = resolveTransformAtTime({
- baseTransform: element.transform,
- animations: element.animations,
- localTime,
- });
- const sourceWidth = mediaAsset?.width ?? canvasWidth;
- const sourceHeight = mediaAsset?.height ?? canvasHeight;
- return getVisualElementBounds({
- canvasWidth,
- canvasHeight,
- sourceWidth,
- sourceHeight,
- transform,
- });
- }
-
- if (element.type === "sticker") {
- const transform = resolveTransformAtTime({
- baseTransform: element.transform,
- animations: element.animations,
- localTime,
- });
- return getVisualElementBounds({
- canvasWidth,
- canvasHeight,
- sourceWidth: element.intrinsicWidth ?? STICKER_INTRINSIC_SIZE_FALLBACK,
- sourceHeight: element.intrinsicHeight ?? STICKER_INTRINSIC_SIZE_FALLBACK,
- transform,
- });
- }
-
- if (element.type === "graphic") {
- const transform = resolveTransformAtTime({
- baseTransform: element.transform,
- animations: element.animations,
- localTime,
- });
- return getVisualElementBounds({
- canvasWidth,
- canvasHeight,
- sourceWidth: DEFAULT_GRAPHIC_SOURCE_SIZE,
- sourceHeight: DEFAULT_GRAPHIC_SOURCE_SIZE,
- transform,
- });
- }
-
- if (element.type === "text") {
- const transform = resolveTransformAtTime({
- baseTransform: element.transform,
- animations: element.animations,
- localTime,
- });
-
- const canvas = document.createElement("canvas");
- const ctx = canvas.getContext("2d");
- if (!ctx) return null;
-
- const measured = measureTextElement({
- element,
- canvasHeight,
- localTime,
- ctx,
- });
-
- return getTransformedRectBounds({
- canvasWidth,
- canvasHeight,
- rect: measured.visualRect,
- transform,
- });
- }
-
- return null;
-}
-
-export const ROTATION_HANDLE_OFFSET = 24;
-
-export type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
-export type Edge = "right" | "left" | "bottom";
-
-export function getCornerPosition({
- bounds,
- corner,
-}: {
- bounds: ElementBounds;
- corner: Corner;
-}): { x: number; y: number } {
- const halfW = bounds.width / 2;
- const halfH = bounds.height / 2;
- const angleRad = (bounds.rotation * Math.PI) / 180;
- const cos = Math.cos(angleRad);
- const sin = Math.sin(angleRad);
- const localX =
- corner === "top-left" || corner === "bottom-left" ? -halfW : halfW;
- const localY =
- corner === "top-left" || corner === "top-right" ? -halfH : halfH;
- return {
- x: bounds.cx + (localX * cos - localY * sin),
- y: bounds.cy + (localX * sin + localY * cos),
- };
-}
-
-export function getEdgeHandlePosition({
- bounds,
- edge,
-}: {
- bounds: ElementBounds;
- edge: Edge;
-}): { x: number; y: number } {
- const halfWidth = bounds.width / 2;
- const halfHeight = bounds.height / 2;
- const angleRad = (bounds.rotation * Math.PI) / 180;
- const cos = Math.cos(angleRad);
- const sin = Math.sin(angleRad);
- const localX = edge === "right" ? halfWidth : edge === "left" ? -halfWidth : 0;
- const localY = edge === "bottom" ? halfHeight : 0;
- return {
- x: bounds.cx + (localX * cos - localY * sin),
- y: bounds.cy + (localX * sin + localY * cos),
- };
-}
-
-export function getVisibleElementsWithBounds({
- tracks,
- currentTime,
- canvasSize,
- mediaAssets,
-}: {
- tracks: SceneTracks;
- currentTime: number;
- canvasSize: { width: number; height: number };
- mediaAssets: MediaAsset[];
-}): ElementWithBounds[] {
- const mediaMap = new Map(mediaAssets.map((m) => [m.id, m]));
- const orderedTracks = [
- ...tracks.overlay.filter((track) => !("hidden" in track && track.hidden)),
- ...(!tracks.main.hidden ? [tracks.main] : []),
- ].reverse();
-
- const result: ElementWithBounds[] = [];
-
- for (const track of orderedTracks) {
- const elements = track.elements
- .filter((element) => !("hidden" in element && element.hidden))
- .filter(
- (element) =>
- currentTime >= element.startTime &&
- currentTime < element.startTime + element.duration,
- )
- .slice()
- .sort((a, b) => {
- if (a.startTime !== b.startTime) return a.startTime - b.startTime;
- return a.id.localeCompare(b.id);
- });
-
- for (const element of elements) {
- const localTime = getElementLocalTime({
- timelineTime: currentTime,
- elementStartTime: element.startTime,
- elementDuration: element.duration,
- });
- const mediaAsset =
- element.type === "video" || element.type === "image"
- ? mediaMap.get(element.mediaId)
- : undefined;
- const bounds = getElementBounds({
- element,
- canvasSize,
- mediaAsset,
- localTime,
- });
- if (bounds) {
- result.push({
- trackId: track.id,
- elementId: element.id,
- element,
- bounds,
- });
- }
- }
- }
-
- return result;
-}
+import type { SceneTracks, TimelineElement } from "@/timeline";
+import type { MediaAsset } from "@/media/types";
+import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/stickers/intrinsic-size";
+import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/graphics";
+import { measureTextElement } from "@/text/measure-element";
+import {
+ getElementLocalTime,
+} from "@/animation";
+import { resolveTransformAtTime } from "@/rendering/animation-values";
+
+export interface ElementBounds {
+ cx: number;
+ cy: number;
+ width: number;
+ height: number;
+ rotation: number;
+}
+
+export interface ElementWithBounds {
+ trackId: string;
+ elementId: string;
+ element: TimelineElement;
+ bounds: ElementBounds;
+}
+
+function getVisualElementBounds({
+ canvasWidth,
+ canvasHeight,
+ sourceWidth,
+ sourceHeight,
+ transform,
+}: {
+ canvasWidth: number;
+ canvasHeight: number;
+ sourceWidth: number;
+ sourceHeight: number;
+ transform: {
+ scaleX: number;
+ scaleY: number;
+ position: { x: number; y: number };
+ rotate: number;
+ };
+}): ElementBounds {
+ const containScale = Math.min(
+ canvasWidth / sourceWidth,
+ canvasHeight / sourceHeight,
+ );
+ const scaledWidth = sourceWidth * containScale * transform.scaleX;
+ const scaledHeight = sourceHeight * containScale * transform.scaleY;
+ const cx = canvasWidth / 2 + transform.position.x;
+ const cy = canvasHeight / 2 + transform.position.y;
+
+ return {
+ cx,
+ cy,
+ width: scaledWidth,
+ height: scaledHeight,
+ rotation: transform.rotate,
+ };
+}
+
+function getTransformedRectBounds({
+ canvasWidth,
+ canvasHeight,
+ rect,
+ transform,
+}: {
+ canvasWidth: number;
+ canvasHeight: number;
+ rect: { left: number; top: number; width: number; height: number };
+ transform: {
+ scaleX: number;
+ scaleY: number;
+ position: { x: number; y: number };
+ rotate: number;
+ };
+}): ElementBounds {
+ const localCenterX = rect.left + rect.width / 2;
+ const localCenterY = rect.top + rect.height / 2;
+ const scaledCenterX = localCenterX * transform.scaleX;
+ const scaledCenterY = localCenterY * transform.scaleY;
+ const rotationRad = (transform.rotate * Math.PI) / 180;
+ const cos = Math.cos(rotationRad);
+ const sin = Math.sin(rotationRad);
+ return {
+ cx:
+ canvasWidth / 2 +
+ transform.position.x +
+ scaledCenterX * cos -
+ scaledCenterY * sin,
+ cy:
+ canvasHeight / 2 +
+ transform.position.y +
+ scaledCenterX * sin +
+ scaledCenterY * cos,
+ width: rect.width * transform.scaleX,
+ height: rect.height * transform.scaleY,
+ rotation: transform.rotate,
+ };
+}
+
+/**
+ * Bounds policy: bounds reflect base content geometry (text glyphs + background,
+ * sticker/image/video content area) and base transform. Post-effect spill (blur,
+ * glow) and mask-clipped regions are intentionally excluded — handles manipulate
+ * the canonical element geometry, not visual effect output.
+ */
+function getElementBounds({
+ element,
+ canvasSize,
+ mediaAsset,
+ localTime,
+}: {
+ element: TimelineElement;
+ canvasSize: { width: number; height: number };
+ mediaAsset?: MediaAsset | null;
+ localTime: number;
+}): ElementBounds | null {
+ if (element.type === "audio" || element.type === "effect") return null;
+ if ("hidden" in element && element.hidden) return null;
+
+ const { width: canvasWidth, height: canvasHeight } = canvasSize;
+
+ if (element.type === "video" || element.type === "image") {
+ const transform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
+ const sourceWidth = mediaAsset?.width ?? canvasWidth;
+ const sourceHeight = mediaAsset?.height ?? canvasHeight;
+ return getVisualElementBounds({
+ canvasWidth,
+ canvasHeight,
+ sourceWidth,
+ sourceHeight,
+ transform,
+ });
+ }
+
+ if (element.type === "sticker") {
+ const transform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
+ return getVisualElementBounds({
+ canvasWidth,
+ canvasHeight,
+ sourceWidth: element.intrinsicWidth ?? STICKER_INTRINSIC_SIZE_FALLBACK,
+ sourceHeight: element.intrinsicHeight ?? STICKER_INTRINSIC_SIZE_FALLBACK,
+ transform,
+ });
+ }
+
+ if (element.type === "graphic") {
+ const transform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
+ return getVisualElementBounds({
+ canvasWidth,
+ canvasHeight,
+ sourceWidth: DEFAULT_GRAPHIC_SOURCE_SIZE,
+ sourceHeight: DEFAULT_GRAPHIC_SOURCE_SIZE,
+ transform,
+ });
+ }
+
+ if (element.type === "text") {
+ const transform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
+
+ const canvas = document.createElement("canvas");
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return null;
+
+ const measured = measureTextElement({
+ element,
+ canvasHeight,
+ localTime,
+ ctx,
+ });
+
+ return getTransformedRectBounds({
+ canvasWidth,
+ canvasHeight,
+ rect: measured.visualRect,
+ transform,
+ });
+ }
+
+ return null;
+}
+
+export const ROTATION_HANDLE_OFFSET = 24;
+
+export type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
+export type Edge = "right" | "left" | "bottom";
+
+export function getCornerPosition({
+ bounds,
+ corner,
+}: {
+ bounds: ElementBounds;
+ corner: Corner;
+}): { x: number; y: number } {
+ const halfW = bounds.width / 2;
+ const halfH = bounds.height / 2;
+ const angleRad = (bounds.rotation * Math.PI) / 180;
+ const cos = Math.cos(angleRad);
+ const sin = Math.sin(angleRad);
+ const localX =
+ corner === "top-left" || corner === "bottom-left" ? -halfW : halfW;
+ const localY =
+ corner === "top-left" || corner === "top-right" ? -halfH : halfH;
+ return {
+ x: bounds.cx + (localX * cos - localY * sin),
+ y: bounds.cy + (localX * sin + localY * cos),
+ };
+}
+
+export function getEdgeHandlePosition({
+ bounds,
+ edge,
+}: {
+ bounds: ElementBounds;
+ edge: Edge;
+}): { x: number; y: number } {
+ const halfWidth = bounds.width / 2;
+ const halfHeight = bounds.height / 2;
+ const angleRad = (bounds.rotation * Math.PI) / 180;
+ const cos = Math.cos(angleRad);
+ const sin = Math.sin(angleRad);
+ const localX = edge === "right" ? halfWidth : edge === "left" ? -halfWidth : 0;
+ const localY = edge === "bottom" ? halfHeight : 0;
+ return {
+ x: bounds.cx + (localX * cos - localY * sin),
+ y: bounds.cy + (localX * sin + localY * cos),
+ };
+}
+
+export function getVisibleElementsWithBounds({
+ tracks,
+ currentTime,
+ canvasSize,
+ mediaAssets,
+}: {
+ tracks: SceneTracks;
+ currentTime: number;
+ canvasSize: { width: number; height: number };
+ mediaAssets: MediaAsset[];
+}): ElementWithBounds[] {
+ const mediaMap = new Map(mediaAssets.map((m) => [m.id, m]));
+ const orderedTracks = [
+ ...tracks.overlay.filter((track) => !("hidden" in track && track.hidden)),
+ ...(!tracks.main.hidden ? [tracks.main] : []),
+ ].reverse();
+
+ const result: ElementWithBounds[] = [];
+
+ for (const track of orderedTracks) {
+ const elements = track.elements
+ .filter((element) => !("hidden" in element && element.hidden))
+ .filter(
+ (element) =>
+ currentTime >= element.startTime &&
+ currentTime < element.startTime + element.duration,
+ )
+ .slice()
+ .sort((a, b) => {
+ if (a.startTime !== b.startTime) return a.startTime - b.startTime;
+ return a.id.localeCompare(b.id);
+ });
+
+ for (const element of elements) {
+ const localTime = getElementLocalTime({
+ timelineTime: currentTime,
+ elementStartTime: element.startTime,
+ elementDuration: element.duration,
+ });
+ const mediaAsset =
+ element.type === "video" || element.type === "image"
+ ? mediaMap.get(element.mediaId)
+ : undefined;
+ const bounds = getElementBounds({
+ element,
+ canvasSize,
+ mediaAsset,
+ localTime,
+ });
+ if (bounds) {
+ result.push({
+ trackId: track.id,
+ elementId: element.id,
+ element,
+ bounds,
+ });
+ }
+ }
+ }
+
+ return result;
+}
diff --git a/apps/web/src/rendering/NOTES.md b/apps/web/src/rendering/NOTES.md
new file mode 100644
index 00000000..38055c14
--- /dev/null
+++ b/apps/web/src/rendering/NOTES.md
@@ -0,0 +1,27 @@
+# Notes
+
+## `Transform` is misplaced
+
+`Transform` is currently defined in `apps/web/src/rendering/index.ts`. It's
+exported from the rendering domain because rendering happens to be one of its
+consumers — but every other domain that touches scene geometry consumes it too
+(`@/timeline`, `@/preview`, `@/animation/values`, `@/text`, etc.). It's not
+"of" rendering any more than `Vector2` would be "of" math.
+
+`Transform` is closer to a primitive than a domain concept. It's infrastructure:
+a small value type with no behavior, no dependencies, no domain-specific
+invariants. Anything that wants to position something on a 2D canvas needs it.
+
+The codebase should be refactored so it's defined lower-level, rather than
+sitting in a domain. Candidate homes:
+
+- `apps/web/src/primitives/transform.ts` (new dir for these value types)
+- `apps/web/src/geometry/transform.ts` if `Vector2` and friends end up there
+- `apps/web/src/rendering/transform.ts` is acceptable only if `@/rendering`
+becomes a primitives folder rather than a domain (it's borderline today —
+it also exports `BlendMode`, which has the same problem).
+
+Side effect of fixing this: `@/rendering/animation-values.ts` (which exists
+only because `Transform` lives next to it) can move into `@/animation/values.ts`
+alongside the other resolve-at-time helpers, since `Transform` would no longer
+pull in a domain dependency. \ No newline at end of file
diff --git a/apps/web/src/rendering/animation-values.ts b/apps/web/src/rendering/animation-values.ts
new file mode 100644
index 00000000..8e8b4528
--- /dev/null
+++ b/apps/web/src/rendering/animation-values.ts
@@ -0,0 +1,49 @@
+import type { ElementAnimations } from "@/animation/types";
+import { resolveAnimationPathValueAtTime } from "@/animation";
+import type { Transform } from "./index";
+
+export function resolveTransformAtTime({
+ baseTransform,
+ animations,
+ localTime,
+}: {
+ baseTransform: Transform;
+ animations: ElementAnimations | undefined;
+ localTime: number;
+}): Transform {
+ const safeLocalTime = Math.max(0, localTime);
+ return {
+ position: {
+ x: resolveAnimationPathValueAtTime({
+ animations,
+ propertyPath: "transform.positionX",
+ localTime: safeLocalTime,
+ fallbackValue: baseTransform.position.x,
+ }),
+ y: resolveAnimationPathValueAtTime({
+ animations,
+ propertyPath: "transform.positionY",
+ localTime: safeLocalTime,
+ fallbackValue: baseTransform.position.y,
+ }),
+ },
+ scaleX: resolveAnimationPathValueAtTime({
+ animations,
+ propertyPath: "transform.scaleX",
+ localTime: safeLocalTime,
+ fallbackValue: baseTransform.scaleX,
+ }),
+ scaleY: resolveAnimationPathValueAtTime({
+ animations,
+ propertyPath: "transform.scaleY",
+ localTime: safeLocalTime,
+ fallbackValue: baseTransform.scaleY,
+ }),
+ rotate: resolveAnimationPathValueAtTime({
+ animations,
+ propertyPath: "transform.rotate",
+ localTime: safeLocalTime,
+ fallbackValue: baseTransform.rotate,
+ }),
+ };
+}
diff --git a/apps/web/src/rendering/components/blending-tab.tsx b/apps/web/src/rendering/components/blending-tab.tsx
index 3e0db46c..48b1d0e5 100644
--- a/apps/web/src/rendering/components/blending-tab.tsx
+++ b/apps/web/src/rendering/components/blending-tab.tsx
@@ -27,7 +27,7 @@ import { RainDropIcon } from "@hugeicons/core-free-icons";
import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property";
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
-import { resolveOpacityAtTime } from "@/animation";
+import { resolveOpacityAtTime } from "@/animation/values";
import { DEFAULTS } from "@/timeline/defaults";
import { isPropertyAtDefault } from "./transform-tab";
diff --git a/apps/web/src/rendering/components/transform-tab.tsx b/apps/web/src/rendering/components/transform-tab.tsx
index 60d4c7df..f4d5a183 100644
--- a/apps/web/src/rendering/components/transform-tab.tsx
+++ b/apps/web/src/rendering/components/transform-tab.tsx
@@ -20,8 +20,8 @@ import {
import {
getGroupKeyframesAtTime,
hasGroupKeyframeAtTime,
- resolveTransformAtTime,
} from "@/animation";
+import { resolveTransformAtTime } from "@/rendering/animation-values";
import { DEFAULTS } from "@/timeline/defaults";
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
diff --git a/apps/web/src/services/renderer/resolve.ts b/apps/web/src/services/renderer/resolve.ts
index 5495ba3b..809f7103 100644
--- a/apps/web/src/services/renderer/resolve.ts
+++ b/apps/web/src/services/renderer/resolve.ts
@@ -1,11 +1,5 @@
import { mediaTimeToSeconds } from "opencut-wasm";
-import {
- getElementLocalTime,
- resolveColorAtTime,
- resolveGraphicParamsAtTime,
- resolveOpacityAtTime,
- resolveTransformAtTime,
-} from "@/animation";
+import { getElementLocalTime } from "@/animation";
import { resolveEffectParamsAtTime } from "@/animation/effect-param-channel";
import {
buildGaussianBlurPasses,
@@ -14,11 +8,16 @@ import {
import { effectsRegistry, resolveEffectPasses } from "@/effects";
import type { Effect, EffectPass } from "@/effects/types";
import { getSourceTimeAtClipTime } from "@/retime";
-import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/graphics";
+import {
+ DEFAULT_GRAPHIC_SOURCE_SIZE,
+ resolveGraphicElementParamsAtTime,
+} from "@/graphics";
import {
getTextMeasurementContext,
measureTextElement,
} from "@/text/measure-element";
+import { resolveColorAtTime, resolveOpacityAtTime } from "@/animation/values";
+import { resolveTransformAtTime } from "@/rendering/animation-values";
import { videoCache } from "@/services/video-cache/service";
import type { CanvasRenderer } from "./canvas-renderer";
import type { AnyBaseNode } from "./nodes/base-node";
@@ -113,7 +112,8 @@ function resolveEffectPassGroups({
.filter((effect) => effect.enabled)
.map((effect) => {
const resolvedParams = resolveEffectParamsAtTime({
- effect,
+ effectId: effect.id,
+ params: effect.params,
animations,
localTime,
});
@@ -304,7 +304,7 @@ function resolveGraphicNode({
return {
...visualState,
- resolvedParams: resolveGraphicParamsAtTime({
+ resolvedParams: resolveGraphicElementParamsAtTime({
element: node.params,
localTime: visualState.localTime,
}),
diff --git a/apps/web/src/text/components/text-tab.tsx b/apps/web/src/text/components/text-tab.tsx
index bf14efba..0f0b274a 100644
--- a/apps/web/src/text/components/text-tab.tsx
+++ b/apps/web/src/text/components/text-tab.tsx
@@ -30,7 +30,10 @@ import { useKeyframedNumberProperty } from "@/components/editor/panels/propertie
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
import { isPropertyAtDefault } from "@/rendering/components/transform-tab";
-import { resolveColorAtTime, resolveNumberAtTime } from "@/animation";
+import {
+ resolveColorAtTime,
+ resolveNumberAtTime,
+} from "@/animation/values";
import { HugeiconsIcon } from "@hugeicons/react";
import {
MinusSignIcon,
diff --git a/apps/web/src/text/measure-element.ts b/apps/web/src/text/measure-element.ts
index 3a49696f..ec14421b 100644
--- a/apps/web/src/text/measure-element.ts
+++ b/apps/web/src/text/measure-element.ts
@@ -1,7 +1,7 @@
import { CORNER_RADIUS_MIN } from "@/text/background";
-import { resolveNumberAtTime } from "@/animation";
import { DEFAULTS } from "@/timeline/defaults";
import type { TextBackground, TextElement } from "@/timeline";
+import { resolveNumberAtTime } from "@/animation/values";
import {
getTextVisualRect,
} from "./layout";
diff --git a/apps/web/src/animation/property-registry.ts b/apps/web/src/timeline/animation-properties.ts
index aacb9972..31479a58 100644
--- a/apps/web/src/animation/property-registry.ts
+++ b/apps/web/src/timeline/animation-properties.ts
@@ -3,10 +3,15 @@ import type {
AnimationInterpolation,
AnimationPropertyPath,
AnimationValue,
+ ElementAnimations,
+ NumericSpec,
} from "@/animation/types";
-import { parseColorToLinearRgba } from "./binding-values";
-import type { TimelineElement } from "@/timeline";
+import { upsertPathKeyframe } from "@/animation";
+import { parseColorToLinearRgba } from "@/animation/binding-values";
+import { isAnimationPropertyPath } from "@/animation/path";
import { MIN_TRANSFORM_SCALE } from "@/animation/transform";
+import { snapToStep } from "@/utils/math";
+import type { TimelineElement } from "@/timeline";
import {
CORNER_RADIUS_MAX,
CORNER_RADIUS_MIN,
@@ -17,13 +22,6 @@ import {
} from "@/timeline/element-utils";
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
import { DEFAULTS } from "@/timeline/defaults";
-import { snapToStep } from "@/utils/math";
-
-export interface NumericSpec {
- min?: number;
- max?: number;
- step?: number;
-}
export interface AnimationPropertyDefinition {
kind: AnimationBindingKind;
@@ -313,12 +311,6 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
}),
};
-export function isAnimationPropertyPath(
- propertyPath: string,
-): propertyPath is AnimationPropertyPath {
- return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath);
-}
-
export function getAnimationPropertyDefinition({
propertyPath,
}: {
@@ -349,6 +341,7 @@ export function getElementBaseValueForProperty({
if (!definition.supportsElement({ element })) {
return null;
}
+
return definition.getValue({ element });
}
@@ -366,6 +359,7 @@ export function withElementBaseValueForProperty({
if (coercedValue === null || !definition.supportsElement({ element })) {
return element;
}
+
return definition.setValue({ element, value: coercedValue });
}
@@ -388,3 +382,44 @@ export function coerceAnimationValueForProperty({
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
return propertyDefinition.coerceValue({ value });
}
+
+export function upsertElementKeyframe({
+ animations,
+ propertyPath,
+ time,
+ value,
+ interpolation,
+ keyframeId,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+ time: number;
+ value: AnimationValue;
+ interpolation?: AnimationInterpolation;
+ keyframeId?: string;
+}): ElementAnimations | undefined {
+ const coercedValue = coerceAnimationValueForProperty({
+ propertyPath,
+ value,
+ });
+ if (coercedValue === null) {
+ return animations;
+ }
+
+ const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
+ return upsertPathKeyframe({
+ animations,
+ propertyPath,
+ time,
+ value: coercedValue,
+ interpolation,
+ keyframeId,
+ kind: propertyDefinition.kind,
+ defaultInterpolation: propertyDefinition.defaultInterpolation,
+ coerceValue: ({ value: nextValue }) =>
+ coerceAnimationValueForProperty({
+ propertyPath,
+ value: nextValue,
+ }),
+ });
+}
diff --git a/apps/web/src/animation/timeline-snap-points.ts b/apps/web/src/timeline/animation-snap-points.ts
index f7bf549a..f7bf549a 100644
--- a/apps/web/src/animation/timeline-snap-points.ts
+++ b/apps/web/src/timeline/animation-snap-points.ts
diff --git a/apps/web/src/animation/target-resolver.ts b/apps/web/src/timeline/animation-targets.ts
index 32e7917c..f9d53fcc 100644
--- a/apps/web/src/animation/target-resolver.ts
+++ b/apps/web/src/timeline/animation-targets.ts
@@ -3,30 +3,32 @@ import type {
AnimationInterpolation,
AnimationPath,
AnimationValue,
+ NumericSpec,
} from "@/animation/types";
import {
+ coerceAnimationParamValue,
+ getAnimationParamDefaultInterpolation,
+ getAnimationParamNumericRange,
+ getAnimationParamValueKind,
+} from "@/animation/animated-params";
+import {
parseEffectParamPath,
- isEffectParamPath,
} from "@/animation/effect-param-channel";
import {
- isGraphicParamPath,
parseGraphicParamPath,
} from "@/animation/graphic-param-channel";
-import type { ParamDefinition } from "@/params";
import { effectsRegistry, registerDefaultEffects } from "@/effects";
import { getGraphicDefinition } from "@/graphics";
+import type { ParamDefinition } from "@/params";
import type { TimelineElement } from "@/timeline";
import { isVisualElement } from "@/timeline/element-utils";
-import { snapToStep } from "@/utils/math";
+import { isAnimationPropertyPath } from "@/animation/path";
import {
coerceAnimationValueForProperty,
getAnimationPropertyDefinition,
getElementBaseValueForProperty,
- isAnimationPropertyPath,
- type NumericSpec,
withElementBaseValueForProperty,
-} from "./property-registry";
-import { parseColorToLinearRgba } from "./binding-values";
+} from "./animation-properties";
export interface AnimationPathDescriptor {
kind: AnimationBindingKind;
@@ -37,79 +39,16 @@ export interface AnimationPathDescriptor {
setBaseValue: ({ value }: { value: AnimationValue }) => TimelineElement;
}
-export function getParamValueKind({
- param,
-}: {
- param: ParamDefinition;
-}): AnimationBindingKind {
- if (param.type === "number") {
- return "number";
- }
-
- if (param.type === "color") {
- return "color";
- }
-
- return "discrete";
-}
-
-export function getParamDefaultInterpolation({
- param,
-}: {
- param: ParamDefinition;
-}): AnimationInterpolation {
- return param.type === "number" || param.type === "color" ? "linear" : "hold";
-}
-
-function getParamNumericRange({
+// Number/discrete bindings expose a single component named "value"
+// (see binding-values.ts). Multi-component kinds (vector2, color) don't carry
+// numeric ranges yet — revisit when one does.
+function paramNumericRanges({
param,
}: {
param: ParamDefinition;
}): Partial<Record<string, NumericSpec>> | undefined {
- if (param.type !== "number") {
- return undefined;
- }
-
- return {
- value: {
- min: param.min,
- max: param.max,
- step: param.step,
- },
- };
-}
-
-export function coerceAnimationValueForParam({
- param,
- value,
-}: {
- param: ParamDefinition;
- value: AnimationValue;
-}): number | string | boolean | null {
- if (param.type === "number") {
- if (typeof value !== "number" || Number.isNaN(value)) {
- return null;
- }
-
- const steppedValue = snapToStep({ value, step: param.step });
- const minValue = param.min;
- const maxValue = param.max ?? Number.POSITIVE_INFINITY;
- return Math.min(maxValue, Math.max(minValue, steppedValue));
- }
-
- if (param.type === "color") {
- return typeof value === "string" ? value : null;
- }
-
- if (param.type === "boolean") {
- return typeof value === "boolean" ? value : null;
- }
-
- if (typeof value !== "string") {
- return null;
- }
-
- return param.options.some((option) => option.value === value) ? value : null;
+ const range = getAnimationParamNumericRange({ param });
+ return range ? { value: range } : undefined;
}
function buildGraphicParamDescriptor({
@@ -132,13 +71,13 @@ function buildGraphicParamDescriptor({
}
return {
- kind: getParamValueKind({ param }),
- defaultInterpolation: getParamDefaultInterpolation({ param }),
- numericRanges: getParamNumericRange({ param }),
- coerceValue: ({ value }) => coerceAnimationValueForParam({ param, value }),
+ kind: getAnimationParamValueKind({ param }),
+ defaultInterpolation: getAnimationParamDefaultInterpolation({ param }),
+ numericRanges: paramNumericRanges({ param }),
+ coerceValue: ({ value }) => coerceAnimationParamValue({ param, value }),
getBaseValue: () => element.params[param.key] ?? param.default,
setBaseValue: ({ value }) => {
- const coercedValue = coerceAnimationValueForParam({ param, value });
+ const coercedValue = coerceAnimationParamValue({ param, value });
if (coercedValue === null) {
return element;
}
@@ -180,13 +119,13 @@ function buildEffectParamDescriptor({
}
return {
- kind: getParamValueKind({ param }),
- defaultInterpolation: getParamDefaultInterpolation({ param }),
- numericRanges: getParamNumericRange({ param }),
- coerceValue: ({ value }) => coerceAnimationValueForParam({ param, value }),
+ kind: getAnimationParamValueKind({ param }),
+ defaultInterpolation: getAnimationParamDefaultInterpolation({ param }),
+ numericRanges: paramNumericRanges({ param }),
+ coerceValue: ({ value }) => coerceAnimationParamValue({ param, value }),
getBaseValue: () => effect.params[param.key] ?? param.default,
setBaseValue: ({ value }) => {
- const coercedValue = coerceAnimationValueForParam({ param, value });
+ const coercedValue = coerceAnimationParamValue({ param, value });
if (coercedValue === null) {
return element;
}
@@ -210,16 +149,6 @@ function buildEffectParamDescriptor({
};
}
-export function isAnimationPath(
- propertyPath: string,
-): propertyPath is AnimationPath {
- return (
- isAnimationPropertyPath(propertyPath) ||
- isGraphicParamPath(propertyPath) ||
- isEffectParamPath(propertyPath)
- );
-}
-
export function resolveAnimationTarget({
element,
path,
diff --git a/apps/web/src/timeline/audio-state.ts b/apps/web/src/timeline/audio-state.ts
index 4ef559ea..44df5aaf 100644
--- a/apps/web/src/timeline/audio-state.ts
+++ b/apps/web/src/timeline/audio-state.ts
@@ -1,5 +1,5 @@
import { hasKeyframesForPath } from "@/animation/keyframe-query";
-import { resolveNumberAtTime } from "@/animation/resolve";
+import { resolveNumberAtTime } from "@/animation/values";
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "./audio-constants";
import type { TimelineElement } from "./types";
const DEFAULT_STEP_SECONDS = 1 / 60;
diff --git a/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts b/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts
index 1071c6dd..35ee9cca 100644
--- a/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts
+++ b/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts
@@ -19,7 +19,7 @@ import {
import { getBookmarkSnapPoints } from "../snap-source";
import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source";
-import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
+import { getAnimationKeyframeSnapPointsForTimeline } from "@/timeline/animation-snap-points";
import type { Bookmark } from "@/timeline";
export interface BookmarkDragState {
diff --git a/apps/web/src/timeline/components/audio-tab.tsx b/apps/web/src/timeline/components/audio-tab.tsx
index 1dbb3d1a..1e47dcc8 100644
--- a/apps/web/src/timeline/components/audio-tab.tsx
+++ b/apps/web/src/timeline/components/audio-tab.tsx
@@ -1,151 +1,151 @@
-import { Button } from "@/components/ui/button";
-import { NumberField } from "@/components/ui/number-field";
-import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
-import { isSourceAudioSeparated } from "@/timeline/audio-separation";
-import { DEFAULTS } from "@/timeline/defaults";
-import {
- clamp,
- formatNumberForDisplay,
- getFractionDigitsForStep,
- isNearlyEqual,
- snapToStep,
-} from "@/utils/math";
-import type { AudioElement, VideoElement } from "@/timeline";
-import { resolveNumberAtTime } from "@/animation";
-import { useEditor } from "@/editor/use-editor";
-import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
-import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property";
-import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { VolumeHighIcon } from "@hugeicons/core-free-icons";
-import {
- Section,
- SectionContent,
- SectionField,
- SectionFields,
- SectionHeader,
- SectionTitle,
-} from "@/components/section";
-
-const VOLUME_STEP = 0.1;
-const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP });
-
-export function AudioTab({
- element,
- trackId,
-}: {
- element: AudioElement | VideoElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
- startTime: element.startTime,
- duration: element.duration,
- });
- const resolvedVolume = resolveNumberAtTime({
- baseValue: element.volume ?? DEFAULTS.element.volume,
- animations: element.animations,
- propertyPath: "volume",
- localTime,
- });
- const volume = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "volume",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: formatNumberForDisplay({
- value: resolvedVolume,
- fractionDigits: VOLUME_FRACTION_DIGITS,
- }),
- parse: (input) => {
- const parsed = parseFloat(input);
- if (Number.isNaN(parsed)) {
- return null;
- }
-
- return clamp({
- value: snapToStep({ value: parsed, step: VOLUME_STEP }),
- min: VOLUME_DB_MIN,
- max: VOLUME_DB_MAX,
- });
- },
- valueAtPlayhead: resolvedVolume,
- step: VOLUME_STEP,
- buildBaseUpdates: ({ value }) => ({
- volume: value,
- }),
- });
- const isDefault =
- volume.hasAnimatedKeyframes && isPlayheadWithinElementRange
- ? isNearlyEqual({
- leftValue: resolvedVolume,
- rightValue: DEFAULTS.element.volume,
- })
- : (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume;
- const isSeparated =
- element.type === "video" && isSourceAudioSeparated({ element });
-
- return (
- <>
- {isSeparated && (
- <div className="mx-4 mt-4 rounded-md border bg-muted/30 p-3">
- <p className="text-sm">Audio has been separated.</p>
- <Button
- className="mt-3"
- size="sm"
- variant="secondary"
- onClick={() =>
- editor.timeline.toggleSourceAudioSeparation({
- trackId,
- elementId: element.id,
- })
- }
- >
- Recover audio
- </Button>
- </div>
- )}
- <Section collapsible sectionKey={`${element.id}:audio`}>
- <SectionHeader>
- <SectionTitle>Audio</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <SectionFields>
- <SectionField
- label="Volume"
- beforeLabel={
- <KeyframeToggle
- isActive={volume.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle volume keyframe"
- onToggle={volume.toggleKeyframe}
- />
- }
- >
- <NumberField
- icon={<HugeiconsIcon icon={VolumeHighIcon} />}
- value={volume.displayValue}
- onFocus={volume.onFocus}
- onChange={volume.onChange}
- onBlur={volume.onBlur}
- dragSensitivity="slow"
- scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }}
- onScrub={volume.scrubTo}
- onScrubEnd={volume.commitScrub}
- onReset={() =>
- volume.commitValue({
- value: DEFAULTS.element.volume,
- })
- }
- isDefault={isDefault}
- suffix="dB"
- />
- </SectionField>
- </SectionFields>
- </SectionContent>
- </Section>
- </>
- );
-}
+import { Button } from "@/components/ui/button";
+import { NumberField } from "@/components/ui/number-field";
+import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
+import { isSourceAudioSeparated } from "@/timeline/audio-separation";
+import { DEFAULTS } from "@/timeline/defaults";
+import {
+ clamp,
+ formatNumberForDisplay,
+ getFractionDigitsForStep,
+ isNearlyEqual,
+ snapToStep,
+} from "@/utils/math";
+import type { AudioElement, VideoElement } from "@/timeline";
+import { resolveNumberAtTime } from "@/animation/values";
+import { useEditor } from "@/editor/use-editor";
+import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
+import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property";
+import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { VolumeHighIcon } from "@hugeicons/core-free-icons";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+
+const VOLUME_STEP = 0.1;
+const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP });
+
+export function AudioTab({
+ element,
+ trackId,
+}: {
+ element: AudioElement | VideoElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const resolvedVolume = resolveNumberAtTime({
+ baseValue: element.volume ?? DEFAULTS.element.volume,
+ animations: element.animations,
+ propertyPath: "volume",
+ localTime,
+ });
+ const volume = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "volume",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: formatNumberForDisplay({
+ value: resolvedVolume,
+ fractionDigits: VOLUME_FRACTION_DIGITS,
+ }),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) {
+ return null;
+ }
+
+ return clamp({
+ value: snapToStep({ value: parsed, step: VOLUME_STEP }),
+ min: VOLUME_DB_MIN,
+ max: VOLUME_DB_MAX,
+ });
+ },
+ valueAtPlayhead: resolvedVolume,
+ step: VOLUME_STEP,
+ buildBaseUpdates: ({ value }) => ({
+ volume: value,
+ }),
+ });
+ const isDefault =
+ volume.hasAnimatedKeyframes && isPlayheadWithinElementRange
+ ? isNearlyEqual({
+ leftValue: resolvedVolume,
+ rightValue: DEFAULTS.element.volume,
+ })
+ : (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume;
+ const isSeparated =
+ element.type === "video" && isSourceAudioSeparated({ element });
+
+ return (
+ <>
+ {isSeparated && (
+ <div className="mx-4 mt-4 rounded-md border bg-muted/30 p-3">
+ <p className="text-sm">Audio has been separated.</p>
+ <Button
+ className="mt-3"
+ size="sm"
+ variant="secondary"
+ onClick={() =>
+ editor.timeline.toggleSourceAudioSeparation({
+ trackId,
+ elementId: element.id,
+ })
+ }
+ >
+ Recover audio
+ </Button>
+ </div>
+ )}
+ <Section collapsible sectionKey={`${element.id}:audio`}>
+ <SectionHeader>
+ <SectionTitle>Audio</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <SectionFields>
+ <SectionField
+ label="Volume"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={volume.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle volume keyframe"
+ onToggle={volume.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField
+ icon={<HugeiconsIcon icon={VolumeHighIcon} />}
+ value={volume.displayValue}
+ onFocus={volume.onFocus}
+ onChange={volume.onChange}
+ onBlur={volume.onBlur}
+ dragSensitivity="slow"
+ scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }}
+ onScrub={volume.scrubTo}
+ onScrubEnd={volume.commitScrub}
+ onReset={() =>
+ volume.commitValue({
+ value: DEFAULTS.element.volume,
+ })
+ }
+ isDefault={isDefault}
+ suffix="dB"
+ />
+ </SectionField>
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ </>
+ );
+}
diff --git a/apps/web/src/timeline/controllers/playhead-controller.ts b/apps/web/src/timeline/controllers/playhead-controller.ts
index 5260c046..d2b7d82f 100644
--- a/apps/web/src/timeline/controllers/playhead-controller.ts
+++ b/apps/web/src/timeline/controllers/playhead-controller.ts
@@ -8,7 +8,7 @@ import {
} from "@/timeline/snapping";
import { getBookmarkSnapPoints } from "@/timeline/bookmarks/index";
import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
-import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
+import { getAnimationKeyframeSnapPointsForTimeline } from "@/timeline/animation-snap-points";
import {
getCenteredLineLeft,
timelineTimeToPixels,
diff --git a/apps/web/src/timeline/controllers/resize-controller.ts b/apps/web/src/timeline/controllers/resize-controller.ts
index 8677dd15..cdf502a1 100644
--- a/apps/web/src/timeline/controllers/resize-controller.ts
+++ b/apps/web/src/timeline/controllers/resize-controller.ts
@@ -16,7 +16,7 @@ import {
} from "@/timeline/snapping";
import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source";
-import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
+import { getAnimationKeyframeSnapPointsForTimeline } from "@/timeline/animation-snap-points";
import {
isRetimableElement,
type SceneTracks,
diff --git a/apps/web/src/timeline/group-move/snap.ts b/apps/web/src/timeline/group-move/snap.ts
index 32c0a2f1..7f431e6e 100644
--- a/apps/web/src/timeline/group-move/snap.ts
+++ b/apps/web/src/timeline/group-move/snap.ts
@@ -7,7 +7,7 @@ import {
} from "@/timeline/snapping";
import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source";
-import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
+import { getAnimationKeyframeSnapPointsForTimeline } from "@/timeline/animation-snap-points";
import type { MoveGroup } from "./types";
export function snapGroupEdges({