From 2ee0a44735d2b930348e9774909638b2c1909738 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 29 Apr 2026 01:28:50 +0200 Subject: [PATCH] refactor: unify element params --- .../__tests__/animated-params.test.ts | 70 +-- apps/web/src/animation/animated-params.ts | 83 ---- apps/web/src/animation/index.ts | 12 +- apps/web/src/animation/keyframes.ts | 47 -- apps/web/src/animation/property-registry.ts | 425 +++++------------- apps/web/src/animation/types.ts | 8 +- .../timeline/element/insert-element.ts | 2 +- .../components/element-params-tab.tsx | 154 +++++++ .../components/property-param-field.tsx | 38 +- .../hooks/use-keyframed-color-property.ts | 96 ---- .../hooks/use-keyframed-number-property.ts | 175 -------- .../hooks/use-keyframed-param-property.ts | 32 +- .../editor/panels/properties/registry.tsx | 67 ++- .../web/src/core/managers/timeline-manager.ts | 5 +- apps/web/src/masks/definitions/text.ts | 14 +- apps/web/src/media/audio.ts | 13 +- apps/web/src/params/index.ts | 92 +++- apps/web/src/params/registry.ts | 396 +++++++++++++++- .../preview/components/text-edit-overlay.tsx | 45 +- .../preview-interaction-controller.ts | 19 +- .../transform-handle-controller.ts | 87 ++-- apps/web/src/preview/element-bounds.ts | 9 +- .../src/rendering/components/blending-tab.tsx | 0 .../rendering/components/transform-tab.tsx | 0 apps/web/src/rendering/index.ts | 70 +++ .../src/services/renderer/nodes/text-node.ts | 5 +- apps/web/src/services/renderer/resolve.ts | 9 +- .../src/services/renderer/scene-builder.ts | 32 +- .../migrations/__tests__/v28-to-v29.test.ts | 125 ++++++ .../src/services/storage/migrations/index.ts | 4 +- .../storage/migrations/transformers/index.ts | 1 + .../migrations/transformers/v28-to-v29.ts | 243 ++++++++++ .../services/storage/migrations/v28-to-v29.ts | 14 + .../subtitles/build-subtitle-text-element.ts | 80 ++-- apps/web/src/subtitles/types.ts | 16 +- apps/web/src/text/components/assets-view.tsx | 2 +- apps/web/src/text/components/text-tab.tsx | 0 apps/web/src/text/layout.ts | 9 +- apps/web/src/text/measure-element.ts | 199 +++++++- apps/web/src/text/primitives.ts | 2 +- .../__tests__/update-pipeline.test.ts | 10 +- apps/web/src/timeline/animation-targets.ts | 221 ++++----- .../src/timeline/audio-separation/index.ts | 9 +- apps/web/src/timeline/audio-state.ts | 21 +- .../web/src/timeline/components/audio-tab.tsx | 151 ------- .../timeline/components/audio-volume-line.tsx | 13 +- .../timeline/components/timeline-element.tsx | 8 +- .../controllers/drag-drop-controller.ts | 5 +- apps/web/src/timeline/defaults.ts | 40 +- apps/web/src/timeline/element-utils.ts | 106 ++--- .../placement/__tests__/resolve.test.ts | 53 ++- apps/web/src/timeline/types.ts | 37 +- apps/web/src/timeline/update-pipeline.ts | 9 +- 53 files changed, 2021 insertions(+), 1362 deletions(-) delete mode 100644 apps/web/src/animation/animated-params.ts create mode 100644 apps/web/src/components/editor/panels/properties/components/element-params-tab.tsx delete mode 100644 apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts delete mode 100644 apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts delete mode 100644 apps/web/src/rendering/components/blending-tab.tsx delete mode 100644 apps/web/src/rendering/components/transform-tab.tsx create mode 100644 apps/web/src/services/storage/migrations/__tests__/v28-to-v29.test.ts create mode 100644 apps/web/src/services/storage/migrations/transformers/v28-to-v29.ts create mode 100644 apps/web/src/services/storage/migrations/v28-to-v29.ts delete mode 100644 apps/web/src/text/components/text-tab.tsx delete mode 100644 apps/web/src/timeline/components/audio-tab.tsx diff --git a/apps/web/src/animation/__tests__/animated-params.test.ts b/apps/web/src/animation/__tests__/animated-params.test.ts index 7b9d312d..a47b0382 100644 --- a/apps/web/src/animation/__tests__/animated-params.test.ts +++ b/apps/web/src/animation/__tests__/animated-params.test.ts @@ -1,15 +1,15 @@ import { describe, expect, test } from "bun:test"; import { - coerceAnimationParamValue, - getAnimationParamDefaultInterpolation, - getAnimationParamNumericRange, - getAnimationParamValueKind, -} from "@/animation/animated-params"; + coerceParamValue, + getParamDefaultInterpolation, + getParamNumericRange, + getParamValueKind, +} from "@/params"; describe("animated params", () => { test("snaps and clamps number params", () => { expect( - coerceAnimationParamValue({ + coerceParamValue({ param: { key: "intensity", label: "Intensity", @@ -24,7 +24,7 @@ describe("animated params", () => { ).toBe(0.5); expect( - coerceAnimationParamValue({ + coerceParamValue({ param: { key: "intensity", label: "Intensity", @@ -49,14 +49,14 @@ describe("animated params", () => { 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(); + expect(coerceParamValue({ param, value: Number.NaN })).toBeNull(); + expect(coerceParamValue({ param, value: "0.5" })).toBeNull(); + expect(coerceParamValue({ param, value: true })).toBeNull(); }); test("passthrough with step <= 0 guard", () => { expect( - coerceAnimationParamValue({ + coerceParamValue({ param: { key: "x", label: "X", @@ -81,13 +81,13 @@ describe("animated params", () => { { value: "multiply", label: "Multiply" }, ], }; - expect(coerceAnimationParamValue({ param, value: "normal" })).toBe("normal"); - expect(coerceAnimationParamValue({ param, value: "multiply" })).toBe("multiply"); + expect(coerceParamValue({ param, value: "normal" })).toBe("normal"); + expect(coerceParamValue({ param, value: "multiply" })).toBe("multiply"); }); test("rejects select values outside the allowed options", () => { expect( - coerceAnimationParamValue({ + coerceParamValue({ param: { key: "blend", label: "Blend", @@ -111,9 +111,9 @@ describe("animated params", () => { 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(); + expect(coerceParamValue({ param, value: 42 })).toBeNull(); + expect(coerceParamValue({ param, value: null })).toBeNull(); + expect(coerceParamValue({ param, value: undefined })).toBeNull(); }); test("boolean params accept booleans and reject other types", () => { @@ -123,10 +123,10 @@ describe("animated params", () => { 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(); + expect(coerceParamValue({ param, value: true })).toBe(true); + expect(coerceParamValue({ param, value: false })).toBe(false); + expect(coerceParamValue({ param, value: 1 })).toBeNull(); + expect(coerceParamValue({ param, value: "true" })).toBeNull(); }); test("color params accept strings and reject other types", () => { @@ -136,14 +136,14 @@ describe("animated params", () => { 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(); + expect(coerceParamValue({ param, value: "#ff0000" })).toBe("#ff0000"); + expect(coerceParamValue({ param, value: 0xff0000 })).toBeNull(); + expect(coerceParamValue({ param, value: null })).toBeNull(); }); test("getAnimationParamValueKind maps param type to binding kind", () => { expect( - getAnimationParamValueKind({ + getParamValueKind({ param: { key: "n", label: "N", @@ -155,17 +155,17 @@ describe("animated params", () => { }), ).toBe("number"); expect( - getAnimationParamValueKind({ + getParamValueKind({ param: { key: "c", label: "C", type: "color", default: "#fff" }, }), ).toBe("color"); expect( - getAnimationParamValueKind({ + getParamValueKind({ param: { key: "b", label: "B", type: "boolean", default: false }, }), ).toBe("discrete"); expect( - getAnimationParamValueKind({ + getParamValueKind({ param: { key: "s", label: "S", @@ -179,7 +179,7 @@ describe("animated params", () => { test("getAnimationParamDefaultInterpolation is linear for continuous, hold for discrete", () => { expect( - getAnimationParamDefaultInterpolation({ + getParamDefaultInterpolation({ param: { key: "n", label: "N", @@ -191,17 +191,17 @@ describe("animated params", () => { }), ).toBe("linear"); expect( - getAnimationParamDefaultInterpolation({ + getParamDefaultInterpolation({ param: { key: "c", label: "C", type: "color", default: "#fff" }, }), ).toBe("linear"); expect( - getAnimationParamDefaultInterpolation({ + getParamDefaultInterpolation({ param: { key: "b", label: "B", type: "boolean", default: false }, }), ).toBe("hold"); expect( - getAnimationParamDefaultInterpolation({ + getParamDefaultInterpolation({ param: { key: "s", label: "S", @@ -215,7 +215,7 @@ describe("animated params", () => { test("getAnimationParamNumericRange returns spec for number params, undefined otherwise", () => { expect( - getAnimationParamNumericRange({ + getParamNumericRange({ param: { key: "intensity", label: "Intensity", @@ -228,12 +228,12 @@ describe("animated params", () => { }), ).toEqual({ min: 0, max: 1, step: 0.1 }); expect( - getAnimationParamNumericRange({ + getParamNumericRange({ param: { key: "c", label: "C", type: "color", default: "#fff" }, }), ).toBeUndefined(); expect( - getAnimationParamNumericRange({ + getParamNumericRange({ 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 deleted file mode 100644 index bc27b15d..00000000 --- a/apps/web/src/animation/animated-params.ts +++ /dev/null @@ -1,83 +0,0 @@ -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/index.ts b/apps/web/src/animation/index.ts index 33e637c3..bed3b796 100644 --- a/apps/web/src/animation/index.ts +++ b/apps/web/src/animation/index.ts @@ -16,7 +16,6 @@ export { setChannel, splitAnimationsAtTime, updateScalarKeyframeCurve, - upsertElementKeyframe, upsertPathKeyframe, } from "./keyframes"; @@ -75,13 +74,4 @@ export { isAnimationPropertyPath, } from "./path"; -export { - coerceAnimationValueForProperty, - getAnimationPropertyDefinition, - getDefaultInterpolationForProperty, - getElementBaseValueForProperty, - supportsAnimationProperty, - type AnimationPropertyDefinition, - type NumericSpec, - withElementBaseValueForProperty, -} from "./property-registry"; +export type { NumericSpec } from "./types"; diff --git a/apps/web/src/animation/keyframes.ts b/apps/web/src/animation/keyframes.ts index 70f9b5db..d14329c1 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, @@ -20,7 +19,6 @@ import { decomposeAnimationValue, } from "./binding-values"; import { - getBezierPoint, getDefaultLeftHandle, getDefaultRightHandle, solveBezierProgressForTime, @@ -30,10 +28,6 @@ import { getScalarSegmentInterpolation, normalizeChannel, } from "./interpolation"; -import { - coerceAnimationValueForProperty, - getAnimationPropertyDefinition, -} from "./property-registry"; import { type MediaTime, roundMediaTime, @@ -533,47 +527,6 @@ export function upsertPathKeyframe({ }); } -export function upsertElementKeyframe({ - animations, - propertyPath, - time, - value, - interpolation, - keyframeId, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; - time: MediaTime; - 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/property-registry.ts b/apps/web/src/animation/property-registry.ts index bca9d82e..5ba45204 100644 --- a/apps/web/src/animation/property-registry.ts +++ b/apps/web/src/animation/property-registry.ts @@ -3,27 +3,22 @@ import type { AnimationInterpolation, AnimationPropertyPath, AnimationValue, + NumericSpec, } from "@/animation/types"; -import { parseColorToLinearRgba } from "./binding-values"; -import type { TimelineElement } from "@/timeline"; -import { MIN_TRANSFORM_SCALE } from "@/animation/transform"; import { - CORNER_RADIUS_MAX, - CORNER_RADIUS_MIN, -} from "@/text/background"; + coerceParamValue, + getParamDefaultInterpolation, + getParamNumericRange, + getParamValueKind, + type ParamDefinition, +} from "@/params"; import { - canElementHaveAudio, - isVisualElement, -} 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; -} + getBuiltInElementParams, + getElementParam, + readElementParamValue, + writeElementParamValue, +} from "@/params/registry"; +import type { ElementType, TimelineElement } from "@/timeline"; export interface AnimationPropertyDefinition { kind: AnimationBindingKind; @@ -32,10 +27,6 @@ export interface AnimationPropertyDefinition { supportsElement: ({ element }: { element: TimelineElement }) => boolean; getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null; coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null; - // Apply `value` to `element` for this property. Coerces the value through - // `coerceValue` and verifies element support; returns `element` unchanged - // if either fails. Cannot be bypassed — there is no kind-narrow `setValue` - // on the public surface, so callers can't apply an unvalidated value. applyValue: ({ element, value, @@ -45,323 +36,98 @@ export interface AnimationPropertyDefinition { }) => TimelineElement; } -function applyNumericSpec({ - value, - numericRange, +function getFallbackParam({ + propertyPath, }: { - value: number; - numericRange: NumericSpec | undefined; -}): number { - if (!numericRange) { - return value; + propertyPath: AnimationPropertyPath; +}): ParamDefinition | null { + const elementTypes: ElementType[] = [ + "video", + "image", + "text", + "sticker", + "graphic", + "audio", + ]; + for (const type of elementTypes) { + const param = + getBuiltInElementParams({ type }).find( + (candidate) => candidate.key === propertyPath, + ) ?? null; + if (param) { + return param; + } } - - const steppedValue = - numericRange.step != null - ? snapToStep({ value, step: numericRange.step }) - : value; - const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY; - const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY; - return Math.min(maxValue, Math.max(minValue, steppedValue)); + return null; } -function coerceNumberValue({ - value, - numericRange, +function buildDefinition({ + propertyPath, + element, }: { - value: AnimationValue; - numericRange?: NumericSpec; -}): number | null { - if (typeof value !== "number" || Number.isNaN(value)) { + propertyPath: AnimationPropertyPath; + element?: TimelineElement; +}): AnimationPropertyDefinition | null { + const param = element + ? getElementParam({ element, key: propertyPath }) + : getFallbackParam({ propertyPath }); + if (!param || param.keyframable === false) { return null; } - return applyNumericSpec({ value, numericRange }); -} + const range = getParamNumericRange({ param }); -function coerceColorValue({ - value, -}: { - value: AnimationValue; -}): string | null { - return typeof value === "string" && parseColorToLinearRgba({ color: value }) - ? value - : null; -} - -function createNumberPropertyDefinition({ - numericRange, - supportsElement, - getValue, - setValue, -}: { - numericRange?: NumericSpec; - supportsElement: AnimationPropertyDefinition["supportsElement"]; - getValue: AnimationPropertyDefinition["getValue"]; - setValue: ({ - element, - value, - }: { - element: TimelineElement; - value: number; - }) => TimelineElement; -}): AnimationPropertyDefinition { return { - kind: "number", - defaultInterpolation: "linear", - numericRanges: numericRange ? { value: numericRange } : undefined, - supportsElement, - getValue, - coerceValue: ({ value }) => coerceNumberValue({ value, numericRange }), - applyValue: ({ element, value }) => { - if (!supportsElement({ element })) { - return element; + kind: getParamValueKind({ param }), + defaultInterpolation: getParamDefaultInterpolation({ param }), + numericRanges: range ? { value: range } : undefined, + supportsElement: ({ element: candidate }) => + getElementParam({ element: candidate, key: propertyPath }) !== null, + getValue: ({ element: candidate }) => + readElementParamValue({ element: candidate, param }), + coerceValue: ({ value }) => coerceParamValue({ param, value }), + applyValue: ({ element: candidate, value }) => { + const targetParam = getElementParam({ + element: candidate, + key: propertyPath, + }); + if (!targetParam) { + return candidate; } - const coerced = coerceNumberValue({ value, numericRange }); - if (coerced === null) { - return element; + const coercedValue = coerceParamValue({ + param: targetParam, + value, + }); + if (coercedValue === null) { + return candidate; } - return setValue({ element, value: coerced }); + return writeElementParamValue({ + element: candidate, + param: targetParam, + value: coercedValue, + }); }, }; } -function createColorPropertyDefinition({ - supportsElement, - getValue, - setValue, -}: { - supportsElement: AnimationPropertyDefinition["supportsElement"]; - getValue: AnimationPropertyDefinition["getValue"]; - setValue: ({ - element, - value, - }: { - element: TimelineElement; - value: string; - }) => TimelineElement; -}): AnimationPropertyDefinition { - return { - kind: "color", - defaultInterpolation: "linear", - supportsElement, - getValue, - coerceValue: ({ value }) => coerceColorValue({ value }), - applyValue: ({ element, value }) => { - if (!supportsElement({ element })) { - return element; - } - const coerced = coerceColorValue({ value }); - if (coerced === null) { - return element; - } - return setValue({ element, value: coerced }); - }, - }; -} - -const ANIMATION_PROPERTY_REGISTRY: Record< - AnimationPropertyPath, - AnimationPropertyDefinition -> = { - "transform.positionX": createNumberPropertyDefinition({ - numericRange: { step: 1 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.position.x : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { - ...element.transform, - position: { ...element.transform.position, x: value }, - }, - } - : element, - }), - "transform.positionY": createNumberPropertyDefinition({ - numericRange: { step: 1 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.position.y : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { - ...element.transform, - position: { ...element.transform.position, y: value }, - }, - } - : element, - }), - "transform.scaleX": createNumberPropertyDefinition({ - numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.scaleX : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { ...element.transform, scaleX: value }, - } - : element, - }), - "transform.scaleY": createNumberPropertyDefinition({ - numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.scaleY : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { ...element.transform, scaleY: value }, - } - : element, - }), - "transform.rotate": createNumberPropertyDefinition({ - numericRange: { min: -360, max: 360, step: 1 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.rotate : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { ...element.transform, rotate: value }, - } - : element, - }), - opacity: createNumberPropertyDefinition({ - numericRange: { min: 0, max: 1, step: 0.01 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.opacity : null, - setValue: ({ element, value }) => - isVisualElement(element) ? { ...element, opacity: value } : element, - }), - volume: createNumberPropertyDefinition({ - numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 }, - supportsElement: ({ element }) => canElementHaveAudio(element), - getValue: ({ element }) => - canElementHaveAudio(element) ? element.volume ?? 0 : null, - setValue: ({ element, value }) => - canElementHaveAudio(element) ? { ...element, volume: value } : element, - }), - color: createColorPropertyDefinition({ - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => (element.type === "text" ? element.color : null), - setValue: ({ element, value }) => - element.type === "text" ? { ...element, color: value } : element, - }), - "background.color": createColorPropertyDefinition({ - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" ? element.background.color : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, color: value }, - } - : element, - }), - "background.paddingX": createNumberPropertyDefinition({ - numericRange: { min: 0, step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.paddingX ?? DEFAULTS.text.background.paddingX) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, paddingX: value }, - } - : element, - }), - "background.paddingY": createNumberPropertyDefinition({ - numericRange: { min: 0, step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.paddingY ?? DEFAULTS.text.background.paddingY) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, paddingY: value }, - } - : element, - }), - "background.offsetX": createNumberPropertyDefinition({ - numericRange: { step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.offsetX ?? DEFAULTS.text.background.offsetX) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, offsetX: value }, - } - : element, - }), - "background.offsetY": createNumberPropertyDefinition({ - numericRange: { step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.offsetY ?? DEFAULTS.text.background.offsetY) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, offsetY: value }, - } - : element, - }), - "background.cornerRadius": createNumberPropertyDefinition({ - numericRange: { - min: CORNER_RADIUS_MIN, - max: CORNER_RADIUS_MAX, - step: 1, - }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.cornerRadius ?? CORNER_RADIUS_MIN) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, cornerRadius: value }, - } - : element, - }), -}; - export function isAnimationPropertyPath( propertyPath: string, ): propertyPath is AnimationPropertyPath { - return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath); + return !propertyPath.startsWith("params.") && !propertyPath.startsWith("effects."); } export function getAnimationPropertyDefinition({ propertyPath, + element, }: { propertyPath: AnimationPropertyPath; + element?: TimelineElement; }): AnimationPropertyDefinition { - return ANIMATION_PROPERTY_REGISTRY[propertyPath]; + const definition = buildDefinition({ propertyPath, element }); + if (!definition) { + throw new Error(`Unknown animation property for element: ${propertyPath}`); + } + return definition; } export function supportsAnimationProperty({ @@ -371,8 +137,7 @@ export function supportsAnimationProperty({ element: TimelineElement; propertyPath: AnimationPropertyPath; }): boolean { - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return propertyDefinition.supportsElement({ element }); + return getElementParam({ element, key: propertyPath }) !== null; } export function getElementBaseValueForProperty({ @@ -382,11 +147,11 @@ export function getElementBaseValueForProperty({ element: TimelineElement; propertyPath: AnimationPropertyPath; }): AnimationValue | null { - const definition = getAnimationPropertyDefinition({ propertyPath }); - if (!definition.supportsElement({ element })) { + const param = getElementParam({ element, key: propertyPath }); + if (!param) { return null; } - return definition.getValue({ element }); + return readElementParamValue({ element, param }); } export function withElementBaseValueForProperty({ @@ -398,26 +163,38 @@ export function withElementBaseValueForProperty({ propertyPath: AnimationPropertyPath; value: AnimationValue; }): TimelineElement { - const definition = getAnimationPropertyDefinition({ propertyPath }); - return definition.applyValue({ element, value }); + const param = getElementParam({ element, key: propertyPath }); + if (!param) { + return element; + } + const coercedValue = coerceParamValue({ param, value }); + if (coercedValue === null) { + return element; + } + return writeElementParamValue({ element, param, value: coercedValue }); } export function getDefaultInterpolationForProperty({ propertyPath, + element, }: { propertyPath: AnimationPropertyPath; + element?: TimelineElement; }): AnimationInterpolation { - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return propertyDefinition.defaultInterpolation; + return getAnimationPropertyDefinition({ propertyPath, element }) + .defaultInterpolation; } export function coerceAnimationValueForProperty({ propertyPath, value, + element, }: { propertyPath: AnimationPropertyPath; value: AnimationValue; + element?: TimelineElement; }): AnimationValue | null { - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return propertyDefinition.coerceValue({ value }); + return getAnimationPropertyDefinition({ propertyPath, element }).coerceValue({ + value, + }); } diff --git a/apps/web/src/animation/types.ts b/apps/web/src/animation/types.ts index cfecfada..08fced74 100644 --- a/apps/web/src/animation/types.ts +++ b/apps/web/src/animation/types.ts @@ -1,4 +1,3 @@ -import type { ParamValues } from "@/params"; import type { MediaTime } from "@/wasm"; export const ANIMATION_PROPERTY_PATHS = [ @@ -21,10 +20,7 @@ export const ANIMATION_PROPERTY_PATHS = [ export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number]; export type GraphicParamPath = `params.${string}`; export type EffectParamPath = `effects.${string}.params.${string}`; -export type AnimationPath = - | AnimationPropertyPath - | GraphicParamPath - | EffectParamPath; +export type AnimationPath = string; export const ANIMATION_PROPERTY_GROUPS = { "transform.scale": ["transform.scaleX", "transform.scaleY"], @@ -63,7 +59,7 @@ export type AnimationValueForPath = ? AnimationPropertyValueMap[TPath] : TPath extends GraphicParamPath | EffectParamPath ? DynamicAnimationPathValue - : never; + : DynamicAnimationPathValue; export type AnimationNumericPropertyPath = { [K in AnimationPropertyPath]: AnimationValueForPath extends number ? K : never; }[AnimationPropertyPath]; diff --git a/apps/web/src/commands/timeline/element/insert-element.ts b/apps/web/src/commands/timeline/element/insert-element.ts index 2110d67b..dfde0ffe 100644 --- a/apps/web/src/commands/timeline/element/insert-element.ts +++ b/apps/web/src/commands/timeline/element/insert-element.ts @@ -189,7 +189,7 @@ export class InsertElementCommand extends Command { } } - if (element.type === "text" && !element.content) { + if (element.type === "text" && !element.params.content) { console.error("Text element must have content"); return false; } diff --git a/apps/web/src/components/editor/panels/properties/components/element-params-tab.tsx b/apps/web/src/components/editor/panels/properties/components/element-params-tab.tsx new file mode 100644 index 00000000..2befcef4 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/components/element-params-tab.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { resolveAnimationPathValueAtTime } from "@/animation"; +import { Section, SectionContent, SectionFields } from "@/components/section"; +import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead"; +import { useKeyframedParamProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-param-property"; +import { PropertyParamField } from "@/components/editor/panels/properties/components/property-param-field"; +import type { ParamValue, ParamValues } from "@/params"; +import { + getElementParams, + readElementParamValue, + writeElementParamValue, + type ElementParamDefinition, +} from "@/params/registry"; +import type { TimelineElement } from "@/timeline"; +import type { MediaTime } from "@/wasm"; + +export function ElementParamsTab({ + element, + trackId, + paramKeys, + sectionKey, +}: { + element: TimelineElement; + trackId: string; + paramKeys?: readonly string[]; + sectionKey: string; +}) { + const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ + startTime: element.startTime, + duration: element.duration, + }); + const params = getElementParams({ element }).filter( + (param) => !paramKeys || paramKeys.includes(param.key), + ); + const baseValues = buildValues({ element, params }); + + return ( +
+ + + {params + .filter((param) => isVisible({ param, values: baseValues })) + .map((param) => ( + + ))} + + +
+ ); +} + +function ElementParamField({ + element, + trackId, + param, + baseValue, + localTime, + isPlayheadWithinElementRange, +}: { + element: TimelineElement; + trackId: string; + param: ElementParamDefinition; + baseValue: ParamValue; + localTime: MediaTime; + isPlayheadWithinElementRange: boolean; +}) { + const resolvedValue = resolveAnimationPathValueAtTime({ + animations: element.animations, + propertyPath: param.key, + localTime, + fallbackValue: baseValue, + }); + const animatedParam = useKeyframedParamProperty({ + param, + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: param.key, + localTime, + isPlayheadWithinElementRange, + resolvedValue, + buildBaseUpdates: ({ value }) => + writeElementParamValue({ element, param, value }), + }); + + return ( + + ); +} + +function buildValues({ + element, + params, +}: { + element: TimelineElement; + params: readonly ElementParamDefinition[]; +}): ParamValues { + const values: ParamValues = {}; + for (const param of params) { + const value = readElementParamValue({ element, param }); + if (value !== null) { + values[param.key] = value; + } + } + return values; +} + +function isVisible({ + param, + values, +}: { + param: ElementParamDefinition; + values: ParamValues; +}): boolean { + return (param.dependencies ?? []).every((dependency) => + areParamValuesEqual({ + left: values[dependency.param], + right: dependency.equals, + }), + ); +} + +function areParamValuesEqual({ + left, + right, +}: { + left: ParamValue | undefined; + right: ParamValue; +}): boolean { + return left === right; +} diff --git a/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx b/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx index c9a9c22e..66225903 100644 --- a/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx +++ b/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx @@ -1,6 +1,10 @@ "use client"; -import type { ParamDefinition, NumberParamDefinition } from "@/params"; +import type { + ParamDefinition, + NumberParamDefinition, + ParamValue, +} from "@/params"; import { formatNumberForDisplay, getFractionDigitsForStep, @@ -19,6 +23,7 @@ import { } from "@/components/ui/select"; import { usePropertyDraft } from "../hooks/use-property-draft"; import { KeyframeToggle } from "./keyframe-toggle"; +import { Textarea } from "@/components/ui/textarea"; export function PropertyParamField({ param, @@ -28,8 +33,8 @@ export function PropertyParamField({ keyframe, }: { param: ParamDefinition; - value: number | string | boolean; - onPreview: (value: number | string | boolean) => void; + value: ParamValue; + onPreview: (value: ParamValue) => void; onCommit: () => void; keyframe?: { isActive: boolean; @@ -41,7 +46,7 @@ export function PropertyParamField({ void; + value: ParamValue; + onPreview: (value: ParamValue) => void; onCommit: () => void; }) { if (param.type === "number") { @@ -131,6 +136,27 @@ function ParamInput({ ); } + if (param.type === "text") { + return ( +