diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-29 01:28:50 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-05-02 03:27:23 +0200 |
| commit | 2ee0a44735d2b930348e9774909638b2c1909738 (patch) | |
| tree | e73e60097338ed411739f3b04808dad86f7bb192 /apps/web | |
| parent | a9b9cf6bf50f08723896659634f2e6afc17bcd42 (diff) | |
| download | opencut-2ee0a44735d2b930348e9774909638b2c1909738.tar.gz | |
refactor: unify element params
Diffstat (limited to 'apps/web')
53 files changed, 2019 insertions, 1360 deletions
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, @@ -31,10 +29,6 @@ import { normalizeChannel, } from "./interpolation"; import { - coerceAnimationValueForProperty, - getAnimationPropertyDefinition, -} from "./property-registry"; -import { type MediaTime, roundMediaTime, subMediaTime, @@ -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 }); -} - -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; - } - const coerced = coerceNumberValue({ value, numericRange }); - if (coerced === null) { - return element; - } - return setValue({ element, value: coerced }); - }, - }; -} + const range = getParamNumericRange({ param }); -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; + 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 = coerceColorValue({ value }); - 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, + }); }, }; } -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<TPath extends AnimationPath> = ? AnimationPropertyValueMap[TPath] : TPath extends GraphicParamPath | EffectParamPath ? DynamicAnimationPathValue - : never; + : DynamicAnimationPathValue; export type AnimationNumericPropertyPath = { [K in AnimationPropertyPath]: AnimationValueForPath<K> 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 ( + <Section sectionKey={`${element.id}:${sectionKey}`}> + <SectionContent className="pt-4"> + <SectionFields> + {params + .filter((param) => isVisible({ param, values: baseValues })) + .map((param) => ( + <ElementParamField + key={param.key} + element={element} + trackId={trackId} + param={param} + baseValue={baseValues[param.key] ?? param.default} + localTime={localTime} + isPlayheadWithinElementRange={isPlayheadWithinElementRange} + /> + ))} + </SectionFields> + </SectionContent> + </Section> + ); +} + +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 ( + <PropertyParamField + param={param} + value={resolvedValue} + onPreview={animatedParam.onPreview} + onCommit={animatedParam.onCommit} + keyframe={ + param.keyframable === false + ? undefined + : { + isActive: animatedParam.isKeyframedAtTime, + isDisabled: !isPlayheadWithinElementRange, + onToggle: animatedParam.toggleKeyframe, + } + } + /> + ); +} + +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({ <SectionField label={param.label} beforeLabel={ - keyframe ? ( + keyframe && param.keyframable !== false ? ( <KeyframeToggle isActive={keyframe.isActive} isDisabled={keyframe.isDisabled} @@ -68,8 +73,8 @@ function ParamInput({ onCommit, }: { param: ParamDefinition; - value: number | string | boolean; - onPreview: (value: number | string | boolean) => void; + value: ParamValue; + onPreview: (value: ParamValue) => void; onCommit: () => void; }) { if (param.type === "number") { @@ -131,6 +136,27 @@ function ParamInput({ ); } + if (param.type === "text") { + return ( + <Textarea + value={String(value)} + onChange={(event) => onPreview(event.currentTarget.value)} + onBlur={onCommit} + /> + ); + } + + if (param.type === "font") { + return ( + <input + className="border-input bg-accent h-9 w-full rounded-md border px-3 text-sm outline-none" + value={String(value)} + onChange={(event) => onPreview(event.currentTarget.value)} + onBlur={onCommit} + /> + ); + } + return null; } 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 deleted file mode 100644 index 91d29fd7..00000000 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts +++ /dev/null @@ -1,96 +0,0 @@ -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 type { MediaTime } from "@/wasm"; - -export function useKeyframedColorProperty({ - trackId, - elementId, - animations, - propertyPath, - localTime, - isPlayheadWithinElementRange, - resolvedColor, - buildBaseUpdates, -}: { - trackId: string; - elementId: string; - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; - localTime: MediaTime; - 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 deleted file mode 100644 index 610c2253..00000000 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts +++ /dev/null @@ -1,175 +0,0 @@ -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"; -import type { MediaTime } from "@/wasm"; - -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: MediaTime; - 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 4f4b9cf8..dc1ad9ca 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 @@ -8,14 +8,15 @@ import { upsertPathKeyframe, } from "@/animation"; import type { + AnimationPath, ElementAnimations, } from "@/animation/types"; import { - coerceAnimationParamValue, - getAnimationParamDefaultInterpolation, - getAnimationParamValueKind, -} from "@/animation/animated-params"; -import type { ParamDefinition } from "@/params"; + coerceParamValue, + getParamDefaultInterpolation, + getParamValueKind, + type ParamDefinition, +} from "@/params"; import type { TimelineElement } from "@/timeline"; import type { MediaTime } from "@/wasm"; @@ -33,6 +34,7 @@ export function useKeyframedParamProperty({ trackId, elementId, animations, + propertyPath, localTime, isPlayheadWithinElementRange, resolvedValue, @@ -42,6 +44,7 @@ export function useKeyframedParamProperty({ trackId: string; elementId: string; animations: ElementAnimations | undefined; + propertyPath?: AnimationPath; localTime: MediaTime; isPlayheadWithinElementRange: boolean; resolvedValue: number | string | boolean; @@ -52,15 +55,16 @@ export function useKeyframedParamProperty({ }) => Partial<TimelineElement>; }): KeyframedParamPropertyResult { const editor = useEditor(); - const propertyPath = buildGraphicParamPath({ paramKey: param.key }); + const resolvedPropertyPath = + propertyPath ?? buildGraphicParamPath({ paramKey: param.key }); const hasAnimatedKeyframes = hasKeyframesForPath({ animations, - propertyPath, + propertyPath: resolvedPropertyPath, }); const keyframeAtTime = isPlayheadWithinElementRange ? getKeyframeAtTime({ animations, - propertyPath, + propertyPath: resolvedPropertyPath, time: localTime, }) : null; @@ -79,15 +83,15 @@ export function useKeyframedParamProperty({ updates: { animations: upsertPathKeyframe({ animations, - propertyPath, + propertyPath: resolvedPropertyPath, time: localTime, value, - kind: getAnimationParamValueKind({ param }), - defaultInterpolation: getAnimationParamDefaultInterpolation({ + kind: getParamValueKind({ param }), + defaultInterpolation: getParamDefaultInterpolation({ param, }), coerceValue: ({ value: nextValue }) => - coerceAnimationParamValue({ + coerceParamValue({ param, value: nextValue, }), @@ -121,7 +125,7 @@ export function useKeyframedParamProperty({ { trackId, elementId, - propertyPath, + propertyPath: resolvedPropertyPath, keyframeId: keyframeIdAtTime, }, ], @@ -134,7 +138,7 @@ export function useKeyframedParamProperty({ { trackId, elementId, - propertyPath, + propertyPath: resolvedPropertyPath, time: localTime, value: resolvedValue, }, diff --git a/apps/web/src/components/editor/panels/properties/registry.tsx b/apps/web/src/components/editor/panels/properties/registry.tsx index f9e40ab3..1eda6575 100644 --- a/apps/web/src/components/editor/panels/properties/registry.tsx +++ b/apps/web/src/components/editor/panels/properties/registry.tsx @@ -22,16 +22,43 @@ import { MagicWand05Icon, DashboardSpeed02Icon, } from "@hugeicons/core-free-icons"; -import { TransformTab } from "@/rendering/components/transform-tab"; -import { BlendingTab } from "@/rendering/components/blending-tab"; -import { AudioTab } from "@/timeline/components/audio-tab"; -import { TextTab } from "@/text/components/text-tab"; +import { ElementParamsTab } from "./components/element-params-tab"; import { ClipEffectsTab, StandaloneEffectTab } from "@/effects/components/effects-tab"; import { MasksTab } from "@/masks/components/masks-tab"; import { SpeedTab } from "@/speed/components/speed-tab"; import { GraphicTab } from "@/graphics/components/graphic-tab"; import { OcShapesIcon } from "@/components/icons"; +const TRANSFORM_PARAM_KEYS = [ + "transform.positionX", + "transform.positionY", + "transform.scaleX", + "transform.scaleY", + "transform.rotate", +] as const; + +const BLENDING_PARAM_KEYS = ["opacity", "blendMode"] as const; +const AUDIO_PARAM_KEYS = ["volume", "muted"] as const; +const TEXT_PARAM_KEYS = [ + "content", + "fontFamily", + "fontSize", + "color", + "textAlign", + "fontWeight", + "fontStyle", + "textDecoration", + "letterSpacing", + "lineHeight", + "background.enabled", + "background.color", + "background.cornerRadius", + "background.paddingX", + "background.paddingY", + "background.offsetX", + "background.offsetY", +] as const; + export type TabContentProps = { trackId: string; }; @@ -58,7 +85,12 @@ function buildTransformTab({ label: "Transform", icon: <HugeiconsIcon icon={ArrowExpandIcon} size={16} />, content: ({ trackId }) => ( - <TransformTab element={element} trackId={trackId} /> + <ElementParamsTab + element={element} + trackId={trackId} + paramKeys={TRANSFORM_PARAM_KEYS} + sectionKey="transform" + /> ), }; } @@ -73,7 +105,12 @@ function buildBlendingTab({ label: "Blending", icon: <HugeiconsIcon icon={RainDropIcon} size={16} />, content: ({ trackId }) => ( - <BlendingTab element={element} trackId={trackId} /> + <ElementParamsTab + element={element} + trackId={trackId} + paramKeys={BLENDING_PARAM_KEYS} + sectionKey="blending" + /> ), }; } @@ -87,7 +124,14 @@ function buildAudioTab({ id: "audio", label: "Audio", icon: <HugeiconsIcon icon={MusicNote03Icon} size={16} />, - content: ({ trackId }) => <AudioTab element={element} trackId={trackId} />, + content: ({ trackId }) => ( + <ElementParamsTab + element={element} + trackId={trackId} + paramKeys={AUDIO_PARAM_KEYS} + sectionKey="audio" + /> + ), }; } @@ -137,7 +181,14 @@ function buildTextTab({ element }: { element: TextElement }): PropertiesTabDef { id: "text", label: "Text", icon: <HugeiconsIcon icon={TextFontIcon} size={16} />, - content: ({ trackId }) => <TextTab element={element} trackId={trackId} />, + content: ({ trackId }) => ( + <ElementParamsTab + element={element} + trackId={trackId} + paramKeys={TEXT_PARAM_KEYS} + sectionKey="text" + /> + ), }; } diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts index 91a4eeef..5531f437 100644 --- a/apps/web/src/core/managers/timeline-manager.ts +++ b/apps/web/src/core/managers/timeline-manager.ts @@ -16,6 +16,7 @@ import { canElementBeHidden, canElementHaveAudio, } from "@/timeline/element-utils"; +import { isElementMuted } from "@/timeline/audio-state"; import type { AnimationPath, AnimationInterpolation, @@ -843,7 +844,7 @@ export class TimelineManager { }): void { const shouldMute = elements.some(({ trackId, elementId }) => { const element = this.getElementByRef({ trackId, elementId }); - return element && canElementHaveAudio(element) && !element.muted; + return element && canElementHaveAudio(element) && !isElementMuted({ element }); }); const nextUpdates = elements.flatMap(({ trackId, elementId }) => { @@ -856,7 +857,7 @@ export class TimelineManager { { trackId, elementId, - patch: { muted: shouldMute }, + patch: { params: { muted: shouldMute } }, }, ]; }); diff --git a/apps/web/src/masks/definitions/text.ts b/apps/web/src/masks/definitions/text.ts index c285f1c8..c65564d1 100644 --- a/apps/web/src/masks/definitions/text.ts +++ b/apps/web/src/masks/definitions/text.ts @@ -35,7 +35,7 @@ const PERCENTAGE_DISPLAY = { step: 1, } as const; -const TEXT_MASK_ALIGNMENT = DEFAULTS.text.element.textAlign; +const TEXT_MASK_ALIGNMENT = "center"; const TEXT_MASK_PARAMS: ParamDefinition<keyof TextMaskParams & string>[] = [ { @@ -60,7 +60,7 @@ const TEXT_MASK_PARAMS: ParamDefinition<keyof TextMaskParams & string>[] = [ key: "fontSize", label: "Size", type: "number", - default: DEFAULTS.text.element.fontSize, + default: 15, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE, step: 1, @@ -351,11 +351,11 @@ export const textMaskDefinition: MaskDefinition<TextMaskParams> = { strokeWidth: 0, strokeAlign: "center", content: "Mask", - fontSize: DEFAULTS.text.element.fontSize, - fontFamily: DEFAULTS.text.element.fontFamily, - fontWeight: DEFAULTS.text.element.fontWeight, - fontStyle: DEFAULTS.text.element.fontStyle, - textDecoration: DEFAULTS.text.element.textDecoration, + fontSize: 15, + fontFamily: "Arial", + fontWeight: "normal", + fontStyle: "normal", + textDecoration: "none", letterSpacing: DEFAULTS.text.letterSpacing, lineHeight: DEFAULTS.text.lineHeight, centerX: 0, diff --git a/apps/web/src/media/audio.ts b/apps/web/src/media/audio.ts index 42756d49..7abaf90e 100644 --- a/apps/web/src/media/audio.ts +++ b/apps/web/src/media/audio.ts @@ -11,6 +11,7 @@ import { applyAudioMasteringToBuffer } from "@/media/audio-mastering"; import type { AudioCapableElement } from "@/timeline/audio-state"; import { hasAnimatedVolume, + isElementMuted, resolveEffectiveAudioGain, } from "@/timeline/audio-state"; import { doesElementHaveEnabledAudio } from "@/timeline/audio-separation"; @@ -128,7 +129,7 @@ export function timelineHasAudio({ mediaAssets: MediaAsset[]; }): boolean { return collectAudibleCandidates({ tracks, mediaAssets }).some( - ({ element }) => element.muted !== true, + ({ element }) => !isElementMuted({ element }), ); } @@ -168,7 +169,7 @@ export async function collectAudioElements({ trackMuted: false, localTime: 0, }), - muted: element.muted === true, + muted: isElementMuted({ element }), retime: element.retime, }; }), @@ -197,7 +198,7 @@ export async function collectAudioElements({ trackMuted: false, localTime: 0, }), - muted: element.muted ?? false, + muted: isElementMuted({ element }), retime: element.retime, }; }), @@ -501,7 +502,7 @@ export async function collectAudioMixSources({ for (const element of track.elements) { if (!canElementHaveAudio(element)) continue; - if (element.muted === true) continue; + if (isElementMuted({ element })) continue; const mediaAsset = hasMediaId(element) ? (mediaMap.get(element.mediaId) ?? null) : null; @@ -570,9 +571,7 @@ export async function collectAudioClips({ : null; if (!doesElementHaveEnabledAudio({ element, mediaAsset })) continue; - const isElementMuted = - "muted" in element ? (element.muted ?? false) : false; - const muted = isTrackMuted || isElementMuted; + const muted = isTrackMuted || isElementMuted({ element }); const volume = resolveEffectiveAudioGain({ element, trackMuted: isTrackMuted, diff --git a/apps/web/src/params/index.ts b/apps/web/src/params/index.ts index 23085407..bb57a1c4 100644 --- a/apps/web/src/params/index.ts +++ b/apps/web/src/params/index.ts @@ -1,4 +1,7 @@ -export type ParamValues = Record<string, number | string | boolean>; +import { snapToStep } from "@/utils/math"; + +export type ParamValue = number | string | boolean; +export type ParamValues = Record<string, ParamValue>; export type ParamGroup = "stroke"; @@ -6,6 +9,8 @@ interface BaseParamDefinition<TKey extends string = string> { key: TKey; label: string; group?: ParamGroup; + keyframable?: boolean; + dependencies?: Array<{ param: string; equals: ParamValue }>; } export interface NumberParamDefinition<TKey extends string = string> @@ -42,9 +47,92 @@ export interface SelectParamDefinition<TKey extends string = string> options: Array<{ value: string; label: string }>; } +export interface TextParamDefinition<TKey extends string = string> + extends BaseParamDefinition<TKey> { + type: "text"; + default: string; +} + +export interface FontParamDefinition<TKey extends string = string> + extends BaseParamDefinition<TKey> { + type: "font"; + default: string; +} + export type ParamDefinition<TKey extends string = string> = | NumberParamDefinition<TKey> | BooleanParamDefinition<TKey> | ColorParamDefinition<TKey> - | SelectParamDefinition<TKey>; + | SelectParamDefinition<TKey> + | TextParamDefinition<TKey> + | FontParamDefinition<TKey>; + +export function getParamValueKind({ + param, +}: { + param: ParamDefinition; +}): "number" | "color" | "discrete" { + if (param.type === "number") { + return "number"; + } + if (param.type === "color") { + return "color"; + } + return "discrete"; +} +export function getParamDefaultInterpolation({ + param, +}: { + param: ParamDefinition; +}): "linear" | "hold" { + return param.type === "number" || param.type === "color" ? "linear" : "hold"; +} + +export function getParamNumericRange({ + param, +}: { + param: ParamDefinition; +}): { min?: number; max?: number; step?: number } | undefined { + if (param.type !== "number") { + return undefined; + } + + return { + min: param.min, + max: param.max, + step: param.step, + }; +} + +export function coerceParamValue({ + param, + value, +}: { + param: ParamDefinition; + value: unknown; +}): ParamValue | null { + if (param.type === "number") { + if (typeof value !== "number" || Number.isNaN(value)) { + return null; + } + + const steppedValue = snapToStep({ value, step: param.step }); + const maxValue = param.max ?? Number.POSITIVE_INFINITY; + return Math.min(maxValue, Math.max(param.min, steppedValue)); + } + + if (param.type === "boolean") { + return typeof value === "boolean" ? value : null; + } + + if (param.type === "color" || param.type === "text" || param.type === "font") { + return typeof value === "string" ? value : null; + } + + if (typeof value !== "string") { + return null; + } + + return param.options.some((option) => option.value === value) ? value : null; +} diff --git a/apps/web/src/params/registry.ts b/apps/web/src/params/registry.ts index 3ce483e4..9e363c88 100644 --- a/apps/web/src/params/registry.ts +++ b/apps/web/src/params/registry.ts @@ -1,7 +1,35 @@ -import type { ParamDefinition, ParamValues } from "@/params"; +import type { + ParamDefinition, + ParamValue, + ParamValues, +} from "@/params"; +import { MIN_TRANSFORM_SCALE } from "@/animation/transform"; +import type { BlendMode } from "@/rendering"; +import type { + ElementType, + TimelineElement, +} from "@/timeline"; +import { DEFAULTS } from "@/timeline/defaults"; +import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants"; +import { + CORNER_RADIUS_MAX, + CORNER_RADIUS_MIN, +} from "@/text/background"; + +export type ElementParamDefinition<TKey extends string = string> = + ParamDefinition<TKey> & { + read?: ({ element }: { element: TimelineElement }) => ParamValue | null; + write?: ({ + element, + value, + }: { + element: TimelineElement; + value: ParamValue; + }) => TimelineElement; + }; export function buildDefaultParamValues( - params: ParamDefinition[], + params: readonly ParamDefinition[], ): ParamValues { const values: ParamValues = {}; for (const param of params) { @@ -44,3 +72,367 @@ export class DefinitionRegistry<TKey extends string, TDefinition> { return Array.from(this.definitions.values()); } } + +const BLEND_MODE_OPTIONS: Array<{ value: BlendMode; label: string }> = [ + { value: "normal", label: "Normal" }, + { value: "darken", label: "Darken" }, + { value: "multiply", label: "Multiply" }, + { value: "color-burn", label: "Color Burn" }, + { value: "lighten", label: "Lighten" }, + { value: "screen", label: "Screen" }, + { value: "plus-lighter", label: "Plus Lighter" }, + { value: "color-dodge", label: "Color Dodge" }, + { value: "overlay", label: "Overlay" }, + { value: "soft-light", label: "Soft Light" }, + { value: "hard-light", label: "Hard Light" }, + { value: "difference", label: "Difference" }, + { value: "exclusion", label: "Exclusion" }, + { value: "hue", label: "Hue" }, + { value: "saturation", label: "Saturation" }, + { value: "color", label: "Color" }, + { value: "luminosity", label: "Luminosity" }, +]; + +const visualElementParams: ElementParamDefinition[] = [ + { + key: "transform.positionX", + label: "Position X", + type: "number", + default: DEFAULTS.element.transform.position.x, + min: -100_000, + step: 1, + }, + { + key: "transform.positionY", + label: "Position Y", + type: "number", + default: DEFAULTS.element.transform.position.y, + min: -100_000, + step: 1, + }, + { + key: "transform.scaleX", + label: "Scale X", + type: "number", + default: DEFAULTS.element.transform.scaleX, + min: MIN_TRANSFORM_SCALE, + step: 0.01, + }, + { + key: "transform.scaleY", + label: "Scale Y", + type: "number", + default: DEFAULTS.element.transform.scaleY, + min: MIN_TRANSFORM_SCALE, + step: 0.01, + }, + { + key: "transform.rotate", + label: "Rotate", + type: "number", + default: DEFAULTS.element.transform.rotate, + min: -360, + max: 360, + step: 1, + }, + { + key: "opacity", + label: "Opacity", + type: "number", + default: DEFAULTS.element.opacity, + min: 0, + max: 1, + step: 0.01, + }, + { + key: "blendMode", + label: "Blend Mode", + type: "select", + default: DEFAULTS.element.blendMode, + keyframable: false, + options: BLEND_MODE_OPTIONS, + }, +]; + +const audioElementParams: ElementParamDefinition[] = [ + { + key: "volume", + label: "Volume", + type: "number", + default: DEFAULTS.element.volume, + min: VOLUME_DB_MIN, + max: VOLUME_DB_MAX, + step: 0.01, + }, + { + key: "muted", + label: "Muted", + type: "boolean", + default: false, + keyframable: false, + }, +]; + +const textElementParams: ElementParamDefinition[] = [ + { + key: "content", + label: "Content", + type: "text", + default: "Default text", + keyframable: false, + }, + { + key: "fontFamily", + label: "Font Family", + type: "font", + default: "Arial", + keyframable: false, + }, + { + key: "fontSize", + label: "Font Size", + type: "number", + default: 15, + min: 1, + step: 1, + }, + { + key: "color", + label: "Color", + type: "color", + default: "#ffffff", + }, + { + key: "textAlign", + label: "Text Align", + type: "select", + default: "center", + keyframable: false, + options: [ + { value: "left", label: "Left" }, + { value: "center", label: "Center" }, + { value: "right", label: "Right" }, + ], + }, + { + key: "fontWeight", + label: "Font Weight", + type: "select", + default: "normal", + keyframable: false, + options: [ + { value: "normal", label: "Normal" }, + { value: "bold", label: "Bold" }, + ], + }, + { + key: "fontStyle", + label: "Font Style", + type: "select", + default: "normal", + keyframable: false, + options: [ + { value: "normal", label: "Normal" }, + { value: "italic", label: "Italic" }, + ], + }, + { + key: "textDecoration", + label: "Text Decoration", + type: "select", + default: "none", + keyframable: false, + options: [ + { value: "none", label: "None" }, + { value: "underline", label: "Underline" }, + { value: "line-through", label: "Line Through" }, + ], + }, + { + key: "letterSpacing", + label: "Letter Spacing", + type: "number", + default: DEFAULTS.text.letterSpacing, + min: -100, + step: 0.1, + }, + { + key: "lineHeight", + label: "Line Height", + type: "number", + default: DEFAULTS.text.lineHeight, + min: 0.1, + step: 0.1, + }, + { + key: "background.enabled", + label: "Background Enabled", + type: "boolean", + default: DEFAULTS.text.background.enabled, + keyframable: false, + }, + { + key: "background.color", + label: "Background Color", + type: "color", + default: DEFAULTS.text.background.color, + dependencies: [{ param: "background.enabled", equals: true }], + }, + { + key: "background.cornerRadius", + label: "Background Radius", + type: "number", + default: DEFAULTS.text.background.cornerRadius, + min: CORNER_RADIUS_MIN, + max: CORNER_RADIUS_MAX, + step: 1, + dependencies: [{ param: "background.enabled", equals: true }], + }, + { + key: "background.paddingX", + label: "Background Padding X", + type: "number", + default: DEFAULTS.text.background.paddingX, + min: 0, + step: 1, + dependencies: [{ param: "background.enabled", equals: true }], + }, + { + key: "background.paddingY", + label: "Background Padding Y", + type: "number", + default: DEFAULTS.text.background.paddingY, + min: 0, + step: 1, + dependencies: [{ param: "background.enabled", equals: true }], + }, + { + key: "background.offsetX", + label: "Background Offset X", + type: "number", + default: DEFAULTS.text.background.offsetX, + min: -100_000, + step: 1, + dependencies: [{ param: "background.enabled", equals: true }], + }, + { + key: "background.offsetY", + label: "Background Offset Y", + type: "number", + default: DEFAULTS.text.background.offsetY, + min: -100_000, + step: 1, + dependencies: [{ param: "background.enabled", equals: true }], + }, +]; + +export const elementParamRegistry = new DefinitionRegistry< + ElementType, + readonly ElementParamDefinition[] +>("element params"); + +elementParamRegistry.register({ + key: "video", + definition: [...visualElementParams, ...audioElementParams], +}); +elementParamRegistry.register({ key: "image", definition: visualElementParams }); +elementParamRegistry.register({ + key: "text", + definition: [...textElementParams, ...visualElementParams], +}); +elementParamRegistry.register({ + key: "sticker", + definition: visualElementParams, +}); +elementParamRegistry.register({ + key: "graphic", + definition: visualElementParams, +}); +elementParamRegistry.register({ key: "audio", definition: audioElementParams }); +elementParamRegistry.register({ key: "effect", definition: [] }); + +export function getElementParams({ + element, +}: { + element: TimelineElement; +}): readonly ElementParamDefinition[] { + return elementParamRegistry.has(element.type) + ? elementParamRegistry.get(element.type) + : []; +} + +export function getBuiltInElementParams({ + type, +}: { + type: ElementType; +}): readonly ElementParamDefinition[] { + return elementParamRegistry.has(type) ? elementParamRegistry.get(type) : []; +} + +export function getElementParam({ + element, + key, +}: { + element: TimelineElement; + key: string; +}): ElementParamDefinition | null { + return ( + getElementParams({ element }).find((param) => param.key === key) ?? null + ); +} + +export function readElementParamValue({ + element, + param, +}: { + element: TimelineElement; + param: ElementParamDefinition; +}): ParamValue | null { + if (param.read) { + return param.read({ element }); + } + if ("params" in element) { + return element.params[param.key] ?? param.default; + } + return null; +} + +export function writeElementParamValue({ + element, + param, + value, +}: { + element: TimelineElement; + param: ElementParamDefinition; + value: ParamValue; +}): TimelineElement { + if (param.write) { + return param.write({ element, value }); + } + if ("params" in element) { + return { + ...element, + params: { + ...element.params, + [param.key]: value, + }, + }; + } + return element; +} + +export function buildElementParamValues({ + element, +}: { + element: TimelineElement; +}): ParamValues { + const values: ParamValues = {}; + for (const param of getElementParams({ element })) { + const value = readElementParamValue({ element, param }); + if (value !== null) { + values[param.key] = value; + } + } + return values; +} + diff --git a/apps/web/src/preview/components/text-edit-overlay.tsx b/apps/web/src/preview/components/text-edit-overlay.tsx index dd6364d6..c06d50ed 100644 --- a/apps/web/src/preview/components/text-edit-overlay.tsx +++ b/apps/web/src/preview/components/text-edit-overlay.tsx @@ -9,7 +9,12 @@ import { getElementLocalTime, } from "@/animation"; import { resolveTransformAtTime } from "@/rendering/animation-values"; +import { buildTransformFromParams } from "@/rendering"; import { resolveTextLayout } from "@/text/primitives"; +import { + buildTextBackgroundFromElement, + buildTextLayoutParamsFromElement, +} from "@/text/measure-element"; export function TextEditOverlay({ trackId, @@ -42,7 +47,7 @@ export function TextEditOverlay({ if (!div) return; const text = div.innerText; editor.timeline.previewElements({ - updates: [{ trackId, elementId, updates: { content: text } }], + updates: [{ trackId, elementId, updates: { params: { content: text } } }], }); }, [editor.timeline, trackId, elementId]); @@ -69,7 +74,7 @@ export function TextEditOverlay({ elementDuration: element.duration, }); const transform = resolveTransformAtTime({ - baseTransform: element.transform, + baseTransform: buildTransformFromParams({ params: element.params }), animations: element.animations, localTime, }); @@ -80,26 +85,17 @@ export function TextEditOverlay({ }); const { x: displayScaleX } = viewport.getDisplayScale(); + const textParams = buildTextLayoutParamsFromElement({ element }); const resolvedTextLayout = resolveTextLayout({ - text: { - content: element.content, - fontSize: element.fontSize, - fontFamily: element.fontFamily, - fontWeight: element.fontWeight, - fontStyle: element.fontStyle, - textAlign: element.textAlign, - textDecoration: element.textDecoration, - letterSpacing: element.letterSpacing, - lineHeight: element.lineHeight, - }, + text: textParams, canvasHeight: canvasSize.height, }); - const lineHeight = element.lineHeight ?? DEFAULTS.text.lineHeight; - const canvasLetterSpacing = element.letterSpacing ?? 0; + const lineHeight = textParams.lineHeight ?? DEFAULTS.text.lineHeight; + const canvasLetterSpacing = textParams.letterSpacing ?? 0; const lineHeightPx = resolvedTextLayout.lineHeightPx; - const bg = element.background; + const bg = buildTextBackgroundFromElement({ element }); const shouldShowBackground = bg.enabled && bg.color && bg.color !== "transparent"; const fontSizeRatio = resolvedTextLayout.fontSizeRatio; @@ -130,17 +126,20 @@ export function TextEditOverlay({ className="cursor-text select-text outline-none whitespace-pre" style={{ fontSize: resolvedTextLayout.scaledFontSize, - fontFamily: element.fontFamily, - fontWeight: element.fontWeight === "bold" ? "bold" : "normal", - fontStyle: element.fontStyle === "italic" ? "italic" : "normal", - textAlign: element.textAlign, + fontFamily: textParams.fontFamily, + fontWeight: textParams.fontWeight === "bold" ? "bold" : "normal", + fontStyle: textParams.fontStyle === "italic" ? "italic" : "normal", + textAlign: textParams.textAlign, letterSpacing: `${canvasLetterSpacing}px`, lineHeight, color: "transparent", - caretColor: element.color, + caretColor: + typeof element.params.color === "string" + ? element.params.color + : "#ffffff", backgroundColor: shouldShowBackground ? bg.color : "transparent", minHeight: lineHeightPx, - textDecoration: element.textDecoration ?? "none", + textDecoration: textParams.textDecoration ?? "none", padding: shouldShowBackground ? `${canvasPaddingY}px ${canvasPaddingX}px` : 0, @@ -150,7 +149,7 @@ export function TextEditOverlay({ onBlur={onCommit} onKeyDown={(event) => handleKeyDown({ event })} > - {element.content || ""} + {textParams.content} </div> </div> ); diff --git a/apps/web/src/preview/controllers/preview-interaction-controller.ts b/apps/web/src/preview/controllers/preview-interaction-controller.ts index 00573662..118b48ae 100644 --- a/apps/web/src/preview/controllers/preview-interaction-controller.ts +++ b/apps/web/src/preview/controllers/preview-interaction-controller.ts @@ -18,7 +18,8 @@ import { type SnapLine, } from "@/preview/preview-snap"; import type { TCanvasSize } from "@/project/types"; -import type { Transform } from "@/rendering"; +import type { ParamValues } from "@/params"; +import { buildTransformFromParams, type Transform } from "@/rendering"; import { isVisualElement } from "@/timeline/element-utils"; import type { ElementRef, @@ -51,6 +52,7 @@ interface DragElementSnapshot { readonly trackId: string; readonly elementId: string; readonly initialTransform: Transform; + readonly initialParams: ParamValues; } interface DraggingGesture extends CapturedPointerState { @@ -218,7 +220,8 @@ function toDragElementSnapshots({ .map(({ track, element }) => ({ trackId: track.id, elementId: element.id, - initialTransform: element.transform, + initialTransform: buildTransformFromParams({ params: element.params }), + initialParams: element.params, })); } @@ -565,16 +568,14 @@ export class PreviewInteractionController { snappedPosition.y - firstElement.initialTransform.position.y; this.deps.timeline.previewElements( - drag.elements.map(({ trackId, elementId, initialTransform }) => ({ + drag.elements.map(({ trackId, elementId, initialTransform, initialParams }) => ({ trackId, elementId, updates: { - transform: { - ...initialTransform, - position: { - x: initialTransform.position.x + deltaSnappedX, - y: initialTransform.position.y + deltaSnappedY, - }, + params: { + ...initialParams, + "transform.positionX": initialTransform.position.x + deltaSnappedX, + "transform.positionY": initialTransform.position.y + deltaSnappedY, }, }, })), diff --git a/apps/web/src/preview/controllers/transform-handle-controller.ts b/apps/web/src/preview/controllers/transform-handle-controller.ts index 67f677f6..d9b5ef1f 100644 --- a/apps/web/src/preview/controllers/transform-handle-controller.ts +++ b/apps/web/src/preview/controllers/transform-handle-controller.ts @@ -23,7 +23,8 @@ import { setChannel, } from "@/animation"; import type { ElementAnimations } from "@/animation/types"; -import type { Transform } from "@/rendering"; +import type { ParamValues } from "@/params"; +import { buildTransformFromParams, type Transform } from "@/rendering"; import { resolveTransformAtTime } from "@/rendering/animation-values"; import type { ElementRef, @@ -47,6 +48,7 @@ interface CornerScaleSession extends CapturedPointerState { readonly trackId: string; readonly elementId: string; readonly initialTransform: Transform; + readonly initialParams: ParamValues; readonly initialDistance: number; readonly initialBoundsCx: number; readonly initialBoundsCy: number; @@ -62,6 +64,7 @@ interface EdgeScaleSession extends CapturedPointerState { readonly trackId: string; readonly elementId: string; readonly initialTransform: Transform; + readonly initialParams: ParamValues; readonly initialBoundsCx: number; readonly initialBoundsCy: number; readonly baseWidth: number; @@ -76,6 +79,7 @@ interface RotationSession extends CapturedPointerState { readonly trackId: string; readonly elementId: string; readonly initialTransform: Transform; + readonly initialParams: ParamValues; readonly initialAngle: number; readonly initialBoundsCx: number; readonly initialBoundsCy: number; @@ -369,6 +373,7 @@ export class TransformHandleController { trackId: context.trackId, elementId: context.elementId, initialTransform: context.resolvedTransform, + initialParams: context.element.params, initialDistance: getCornerDistance({ bounds: context.bounds, corner, @@ -410,6 +415,7 @@ export class TransformHandleController { trackId: context.trackId, elementId: context.elementId, initialTransform: context.resolvedTransform, + initialParams: context.element.params, initialAngle, initialBoundsCx: context.bounds.cx, initialBoundsCy: context.bounds.cy, @@ -447,6 +453,7 @@ export class TransformHandleController { trackId: context.trackId, elementId: context.elementId, initialTransform: context.resolvedTransform, + initialParams: context.element.params, initialBoundsCx: context.bounds.cx, initialBoundsCy: context.bounds.cy, baseWidth: context.bounds.width / context.resolvedTransform.scaleX, @@ -561,7 +568,9 @@ export class TransformHandleController { element: selectedWithBounds.element, bounds: selectedWithBounds.bounds, resolvedTransform: resolveTransformAtTime({ - baseTransform: selectedWithBounds.element.transform, + baseTransform: buildTransformFromParams({ + params: selectedWithBounds.element.params, + }), animations: selectedWithBounds.element.animations, localTime, }), @@ -608,15 +617,18 @@ export class TransformHandleController { trackId: session.trackId, elementId: session.elementId, updates: { - transform: { - ...session.initialTransform, - scaleX: clampScaleNonZero( - session.initialTransform.scaleX * snappedScale, - ), - scaleY: clampScaleNonZero( - session.initialTransform.scaleY * snappedScale, - ), - }, + params: buildParamsWithTransform({ + params: session.initialParams, + transform: { + ...session.initialTransform, + scaleX: clampScaleNonZero( + session.initialTransform.scaleX * snappedScale, + ), + scaleY: clampScaleNonZero( + session.initialTransform.scaleY * snappedScale, + ), + }, + }), ...(session.shouldClearScaleAnimation && { animations: session.animationsWithoutScale, }), @@ -699,17 +711,20 @@ export class TransformHandleController { trackId: session.trackId, elementId: session.elementId, updates: { - transform: { - ...session.initialTransform, - scaleX: - session.edge === "right" || session.edge === "left" - ? xSnap.snappedScale - : session.initialTransform.scaleX, - scaleY: - session.edge === "bottom" - ? ySnap.snappedScale - : session.initialTransform.scaleY, - }, + params: buildParamsWithTransform({ + params: session.initialParams, + transform: { + ...session.initialTransform, + scaleX: + session.edge === "right" || session.edge === "left" + ? xSnap.snappedScale + : session.initialTransform.scaleX, + scaleY: + session.edge === "bottom" + ? ySnap.snappedScale + : session.initialTransform.scaleY, + }, + }), ...(session.shouldClearScaleAnimation && { animations: session.animationsWithoutScale, }), @@ -742,12 +757,32 @@ export class TransformHandleController { trackId: session.trackId, elementId: session.elementId, updates: { - transform: { - ...session.initialTransform, - rotate: snappedRotation, - }, + params: buildParamsWithTransform({ + params: session.initialParams, + transform: { + ...session.initialTransform, + rotate: snappedRotation, + }, + }), }, }, ]); } } + +function buildParamsWithTransform({ + params, + transform, +}: { + params: ParamValues; + transform: Transform; +}): ParamValues { + return { + ...params, + "transform.positionX": transform.position.x, + "transform.positionY": transform.position.y, + "transform.scaleX": transform.scaleX, + "transform.scaleY": transform.scaleY, + "transform.rotate": transform.rotate, + }; +} diff --git a/apps/web/src/preview/element-bounds.ts b/apps/web/src/preview/element-bounds.ts index 0afc6e4e..181fdf1f 100644 --- a/apps/web/src/preview/element-bounds.ts +++ b/apps/web/src/preview/element-bounds.ts @@ -7,6 +7,7 @@ import { getElementLocalTime, } from "@/animation"; import { resolveTransformAtTime } from "@/rendering/animation-values"; +import { buildTransformFromParams } from "@/rendering"; export interface ElementBounds { cx: number; @@ -123,7 +124,7 @@ function getElementBounds({ if (element.type === "video" || element.type === "image") { const transform = resolveTransformAtTime({ - baseTransform: element.transform, + baseTransform: buildTransformFromParams({ params: element.params }), animations: element.animations, localTime, }); @@ -140,7 +141,7 @@ function getElementBounds({ if (element.type === "sticker") { const transform = resolveTransformAtTime({ - baseTransform: element.transform, + baseTransform: buildTransformFromParams({ params: element.params }), animations: element.animations, localTime, }); @@ -155,7 +156,7 @@ function getElementBounds({ if (element.type === "graphic") { const transform = resolveTransformAtTime({ - baseTransform: element.transform, + baseTransform: buildTransformFromParams({ params: element.params }), animations: element.animations, localTime, }); @@ -170,7 +171,7 @@ function getElementBounds({ if (element.type === "text") { const transform = resolveTransformAtTime({ - baseTransform: element.transform, + baseTransform: buildTransformFromParams({ params: element.params }), animations: element.animations, localTime, }); diff --git a/apps/web/src/rendering/components/blending-tab.tsx b/apps/web/src/rendering/components/blending-tab.tsx deleted file mode 100644 index e69de29b..00000000 --- a/apps/web/src/rendering/components/blending-tab.tsx +++ /dev/null diff --git a/apps/web/src/rendering/components/transform-tab.tsx b/apps/web/src/rendering/components/transform-tab.tsx deleted file mode 100644 index e69de29b..00000000 --- a/apps/web/src/rendering/components/transform-tab.tsx +++ /dev/null diff --git a/apps/web/src/rendering/index.ts b/apps/web/src/rendering/index.ts index c5555371..21e66447 100644 --- a/apps/web/src/rendering/index.ts +++ b/apps/web/src/rendering/index.ts @@ -1,3 +1,5 @@ +import type { ParamValues } from "@/params";
+
export interface Transform {
scaleX: number;
scaleY: number;
@@ -26,3 +28,71 @@ export type BlendMode = | "saturation"
| "color"
| "luminosity";
+
+export function buildTransformFromParams({
+ params,
+}: {
+ params: ParamValues;
+}): Transform {
+ return {
+ scaleX: readNumberParam({ params, key: "transform.scaleX", fallback: 1 }),
+ scaleY: readNumberParam({ params, key: "transform.scaleY", fallback: 1 }),
+ position: {
+ x: readNumberParam({ params, key: "transform.positionX", fallback: 0 }),
+ y: readNumberParam({ params, key: "transform.positionY", fallback: 0 }),
+ },
+ rotate: readNumberParam({ params, key: "transform.rotate", fallback: 0 }),
+ };
+}
+
+export function readOpacityFromParams({
+ params,
+}: {
+ params: ParamValues;
+}): number {
+ return readNumberParam({ params, key: "opacity", fallback: 1 });
+}
+
+export function readBlendModeFromParams({
+ params,
+}: {
+ params: ParamValues;
+}): BlendMode {
+ const value = params.blendMode;
+ return typeof value === "string" && isBlendMode(value) ? value : "normal";
+}
+
+function readNumberParam({
+ params,
+ key,
+ fallback,
+}: {
+ params: ParamValues;
+ key: string;
+ fallback: number;
+}): number {
+ const value = params[key];
+ return typeof value === "number" ? value : fallback;
+}
+
+function isBlendMode(value: string): value is BlendMode {
+ return (
+ value === "normal" ||
+ value === "darken" ||
+ value === "multiply" ||
+ value === "color-burn" ||
+ value === "lighten" ||
+ value === "screen" ||
+ value === "plus-lighter" ||
+ value === "color-dodge" ||
+ value === "overlay" ||
+ value === "soft-light" ||
+ value === "hard-light" ||
+ value === "difference" ||
+ value === "exclusion" ||
+ value === "hue" ||
+ value === "saturation" ||
+ value === "color" ||
+ value === "luminosity"
+ );
+}
diff --git a/apps/web/src/services/renderer/nodes/text-node.ts b/apps/web/src/services/renderer/nodes/text-node.ts index 7bdf135c..6635a031 100644 --- a/apps/web/src/services/renderer/nodes/text-node.ts +++ b/apps/web/src/services/renderer/nodes/text-node.ts @@ -1,11 +1,14 @@ import { BaseNode } from "./base-node"; import type { TextElement } from "@/timeline"; import type { EffectPass } from "@/effects/types"; -import type { Transform } from "@/rendering"; +import type { BlendMode, Transform } from "@/rendering"; import { drawMeasuredTextLayout } from "@/text/primitives"; import type { MeasuredTextElement } from "@/text/measure-element"; export type TextNodeParams = TextElement & { + transform: Transform; + opacity: number; + blendMode?: BlendMode; canvasCenter: { x: number; y: number }; canvasHeight: number; textBaseline?: CanvasTextBaseline; diff --git a/apps/web/src/services/renderer/resolve.ts b/apps/web/src/services/renderer/resolve.ts index 4637df8d..5335de2d 100644 --- a/apps/web/src/services/renderer/resolve.ts +++ b/apps/web/src/services/renderer/resolve.ts @@ -13,6 +13,7 @@ import { resolveGraphicElementParamsAtTime, } from "@/graphics"; import { + buildTextBackgroundFromElement, getTextMeasurementContext, measureTextElement, } from "@/text/measure-element"; @@ -330,6 +331,7 @@ function resolveTextNode({ elementStartTime: node.params.startTime, elementDuration: node.params.duration, }); + const background = buildTextBackgroundFromElement({ element: node.params }); return { transform: resolveTransformAtTime({ @@ -343,13 +345,16 @@ function resolveTextNode({ localTime, }), textColor: resolveColorAtTime({ - baseColor: node.params.color, + baseColor: + typeof node.params.params.color === "string" + ? node.params.params.color + : "#ffffff", animations: node.params.animations, propertyPath: "color", localTime, }), backgroundColor: resolveColorAtTime({ - baseColor: node.params.background.color, + baseColor: background.color, animations: node.params.animations, propertyPath: "background.color", localTime, diff --git a/apps/web/src/services/renderer/scene-builder.ts b/apps/web/src/services/renderer/scene-builder.ts index e79c62d4..431b8269 100644 --- a/apps/web/src/services/renderer/scene-builder.ts +++ b/apps/web/src/services/renderer/scene-builder.ts @@ -12,6 +12,11 @@ import { EffectLayerNode } from "./nodes/effect-layer-node"; import type { AnyBaseNode } from "./nodes/base-node"; import type { TBackground, TCanvasSize } from "@/project/types"; import { DEFAULT_BACKGROUND_BLUR_INTENSITY } from "@/background/blur"; +import { + buildTransformFromParams, + readBlendModeFromParams, + readOpacityFromParams, +} from "@/rendering"; const PREVIEW_MAX_IMAGE_SIZE = 2048; @@ -71,10 +76,10 @@ function buildTrackNodes({ trimStart: element.trimStart, trimEnd: element.trimEnd, retime: element.retime, - transform: element.transform, + transform: buildTransformFromParams({ params: element.params }), animations: element.animations, - opacity: element.opacity, - blendMode: element.blendMode, + opacity: readOpacityFromParams({ params: element.params }), + blendMode: readBlendModeFromParams({ params: element.params }), effects: element.effects ?? [], masks: element.masks ?? [], }), @@ -88,10 +93,10 @@ function buildTrackNodes({ timeOffset: element.startTime, trimStart: element.trimStart, trimEnd: element.trimEnd, - transform: element.transform, + transform: buildTransformFromParams({ params: element.params }), animations: element.animations, - opacity: element.opacity, - blendMode: element.blendMode, + opacity: readOpacityFromParams({ params: element.params }), + blendMode: readBlendModeFromParams({ params: element.params }), effects: element.effects ?? [], masks: element.masks ?? [], ...(isPreview && { @@ -106,6 +111,9 @@ function buildTrackNodes({ nodes.push( new TextNode({ ...element, + transform: buildTransformFromParams({ params: element.params }), + opacity: readOpacityFromParams({ params: element.params }), + blendMode: readBlendModeFromParams({ params: element.params }), canvasCenter: { x: canvasSize.width / 2, y: canvasSize.height / 2 }, canvasHeight: canvasSize.height, textBaseline: "middle", @@ -124,10 +132,10 @@ function buildTrackNodes({ timeOffset: element.startTime, trimStart: element.trimStart, trimEnd: element.trimEnd, - transform: element.transform, + transform: buildTransformFromParams({ params: element.params }), animations: element.animations, - opacity: element.opacity, - blendMode: element.blendMode, + opacity: readOpacityFromParams({ params: element.params }), + blendMode: readBlendModeFromParams({ params: element.params }), effects: element.effects ?? [], }), ); @@ -142,10 +150,10 @@ function buildTrackNodes({ timeOffset: element.startTime, trimStart: element.trimStart, trimEnd: element.trimEnd, - transform: element.transform, + transform: buildTransformFromParams({ params: element.params }), animations: element.animations, - opacity: element.opacity, - blendMode: element.blendMode, + opacity: readOpacityFromParams({ params: element.params }), + blendMode: readBlendModeFromParams({ params: element.params }), effects: element.effects ?? [], masks: element.masks ?? [], }), diff --git a/apps/web/src/services/storage/migrations/__tests__/v28-to-v29.test.ts b/apps/web/src/services/storage/migrations/__tests__/v28-to-v29.test.ts new file mode 100644 index 00000000..dc76b6fd --- /dev/null +++ b/apps/web/src/services/storage/migrations/__tests__/v28-to-v29.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from "bun:test"; +import { transformProjectV28ToV29 } from "../transformers/v28-to-v29"; +import { asRecord, asRecordArray } from "./helpers"; + +describe("V28 to V29 Migration", () => { + test("moves built-in element fields into params", () => { + const result = transformProjectV28ToV29({ + project: { + id: "project-v28-builtins", + version: 28, + scenes: [ + { + id: "scene-1", + tracks: { + main: { + id: "main", + type: "video", + elements: [ + { + id: "video-1", + type: "video", + name: "Video", + transform: { + position: { x: 12, y: -4 }, + scaleX: 1.25, + scaleY: 0.75, + rotate: 9, + }, + opacity: 0.5, + blendMode: "multiply", + volume: -6, + muted: true, + }, + ], + }, + overlay: [ + { + id: "text-track", + type: "text", + elements: [ + { + id: "text-1", + type: "text", + name: "Text", + content: "Hello", + fontSize: 20, + fontFamily: "Inter", + color: "#eeeeee", + textAlign: "left", + fontWeight: "bold", + fontStyle: "italic", + textDecoration: "underline", + letterSpacing: 1.5, + lineHeight: 1.4, + background: { + enabled: true, + color: "#111111", + paddingX: 10, + paddingY: 12, + }, + transform: { + position: { x: 1, y: 2 }, + scaleX: 1, + scaleY: 1, + rotate: 0, + }, + opacity: 1, + }, + ], + }, + ], + audio: [], + }, + }, + ], + }, + }); + + expect(result.skipped).toBe(false); + expect(result.project.version).toBe(29); + + const scene = asRecordArray(result.project.scenes)[0]; + const tracks = asRecord(scene.tracks); + const main = asRecord(tracks.main); + const video = asRecordArray(main.elements)[0]; + expect(video.transform).toBeUndefined(); + expect(video.opacity).toBeUndefined(); + expect(video.volume).toBeUndefined(); + expect(video.muted).toBeUndefined(); + expect(video.params).toEqual({ + "transform.positionX": 12, + "transform.positionY": -4, + "transform.scaleX": 1.25, + "transform.scaleY": 0.75, + "transform.rotate": 9, + opacity: 0.5, + blendMode: "multiply", + volume: -6, + muted: true, + }); + + const overlay = asRecordArray(tracks.overlay)[0]; + const text = asRecordArray(asRecord(overlay).elements)[0]; + expect(text.content).toBeUndefined(); + expect(text.background).toBeUndefined(); + expect(text.params).toMatchObject({ + content: "Hello", + fontSize: 20, + fontFamily: "Inter", + color: "#eeeeee", + textAlign: "left", + fontWeight: "bold", + fontStyle: "italic", + textDecoration: "underline", + letterSpacing: 1.5, + lineHeight: 1.4, + "background.enabled": true, + "background.color": "#111111", + "background.paddingX": 10, + "background.paddingY": 12, + "transform.positionX": 1, + "transform.positionY": 2, + }); + }); +}); diff --git a/apps/web/src/services/storage/migrations/index.ts b/apps/web/src/services/storage/migrations/index.ts index 150da7c4..2e2c83bc 100644 --- a/apps/web/src/services/storage/migrations/index.ts +++ b/apps/web/src/services/storage/migrations/index.ts @@ -27,10 +27,11 @@ import { V24toV25Migration } from "./v24-to-v25"; import { V25toV26Migration } from "./v25-to-v26"; import { V26toV27Migration } from "./v26-to-v27"; import { V27toV28Migration } from "./v27-to-v28"; +import { V28toV29Migration } from "./v28-to-v29"; export { runStorageMigrations } from "./runner"; export type { MigrationProgress } from "./runner"; -export const CURRENT_PROJECT_VERSION = 28; +export const CURRENT_PROJECT_VERSION = 29; export const migrations = [ new V0toV1Migration(), @@ -61,4 +62,5 @@ export const migrations = [ new V25toV26Migration(), new V26toV27Migration(), new V27toV28Migration(), + new V28toV29Migration(), ]; diff --git a/apps/web/src/services/storage/migrations/transformers/index.ts b/apps/web/src/services/storage/migrations/transformers/index.ts index c1ae894f..ace19354 100644 --- a/apps/web/src/services/storage/migrations/transformers/index.ts +++ b/apps/web/src/services/storage/migrations/transformers/index.ts @@ -4,4 +4,5 @@ export { transformProjectV2ToV3 } from "./v2-to-v3"; export { transformProjectV3ToV4 } from "./v3-to-v4"; export { transformProjectV4ToV5 } from "./v4-to-v5"; export { transformProjectV17ToV18 } from "./v17-to-v18"; +export { transformProjectV28ToV29 } from "./v28-to-v29"; export type { MigrationResult, ProjectRecord } from "./types"; diff --git a/apps/web/src/services/storage/migrations/transformers/v28-to-v29.ts b/apps/web/src/services/storage/migrations/transformers/v28-to-v29.ts new file mode 100644 index 00000000..c7f68a34 --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v28-to-v29.ts @@ -0,0 +1,243 @@ +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +export function transformProjectV28ToV29({ + project, +}: { + project: ProjectRecord; +}): MigrationResult<ProjectRecord> { + if (!getProjectId({ project })) { + return { project, skipped: true, reason: "no project id" }; + } + + const version = project.version; + if (typeof version !== "number") { + return { project, skipped: true, reason: "invalid version" }; + } + if (version >= 29) { + return { project, skipped: true, reason: "already v29" }; + } + if (version !== 28) { + return { project, skipped: true, reason: "not v28" }; + } + + return { + project: { + ...migrateProject({ project }), + version: 29, + }, + skipped: false, + }; +} + +function migrateProject({ project }: { project: ProjectRecord }): ProjectRecord { + const nextProject = { ...project }; + if (Array.isArray(project.scenes)) { + nextProject.scenes = project.scenes.map((scene) => migrateScene({ scene })); + } + return nextProject; +} + +function migrateScene({ scene }: { scene: unknown }): unknown { + if (!isRecord(scene)) { + return scene; + } + + const nextScene = { ...scene }; + if (isRecord(scene.tracks)) { + nextScene.tracks = migrateTracks({ tracks: scene.tracks }); + } + return nextScene; +} + +function migrateTracks({ tracks }: { tracks: ProjectRecord }): ProjectRecord { + const nextTracks = { ...tracks }; + if (isRecord(tracks.main)) { + nextTracks.main = migrateTrack({ track: tracks.main }); + } + if (Array.isArray(tracks.overlay)) { + nextTracks.overlay = tracks.overlay.map((track) => migrateTrack({ track })); + } + if (Array.isArray(tracks.audio)) { + nextTracks.audio = tracks.audio.map((track) => migrateTrack({ track })); + } + return nextTracks; +} + +function migrateTrack({ track }: { track: unknown }): unknown { + if (!isRecord(track) || !Array.isArray(track.elements)) { + return track; + } + + return { + ...track, + elements: track.elements.map((element) => migrateElement({ element })), + }; +} + +function migrateElement({ element }: { element: unknown }): unknown { + if (!isRecord(element)) { + return element; + } + + const nextElement = { ...element }; + const params: ProjectRecord = isRecord(element.params) + ? { ...element.params } + : {}; + + copyTransformParams({ source: element, params }); + copyPrimitiveParam({ source: element, params, sourceKey: "opacity", paramKey: "opacity" }); + copyPrimitiveParam({ + source: element, + params, + sourceKey: "blendMode", + paramKey: "blendMode", + }); + + if (element.type === "audio" || element.type === "video") { + copyPrimitiveParam({ + source: element, + params, + sourceKey: "volume", + paramKey: "volume", + }); + copyPrimitiveParam({ + source: element, + params, + sourceKey: "muted", + paramKey: "muted", + }); + } + + if (element.type === "text") { + copyTextParams({ source: element, params }); + } + + nextElement.params = params; + delete nextElement.transform; + delete nextElement.opacity; + delete nextElement.blendMode; + delete nextElement.volume; + delete nextElement.muted; + delete nextElement.content; + delete nextElement.fontSize; + delete nextElement.fontFamily; + delete nextElement.color; + delete nextElement.background; + delete nextElement.textAlign; + delete nextElement.fontWeight; + delete nextElement.fontStyle; + delete nextElement.textDecoration; + delete nextElement.letterSpacing; + delete nextElement.lineHeight; + + return nextElement; +} + +function copyTransformParams({ + source, + params, +}: { + source: ProjectRecord; + params: ProjectRecord; +}): void { + if (!isRecord(source.transform)) { + return; + } + if (isRecord(source.transform.position)) { + copyPrimitiveParam({ + source: source.transform.position, + params, + sourceKey: "x", + paramKey: "transform.positionX", + }); + copyPrimitiveParam({ + source: source.transform.position, + params, + sourceKey: "y", + paramKey: "transform.positionY", + }); + } + copyPrimitiveParam({ + source: source.transform, + params, + sourceKey: "scaleX", + paramKey: "transform.scaleX", + }); + copyPrimitiveParam({ + source: source.transform, + params, + sourceKey: "scaleY", + paramKey: "transform.scaleY", + }); + copyPrimitiveParam({ + source: source.transform, + params, + sourceKey: "rotate", + paramKey: "transform.rotate", + }); +} + +function copyTextParams({ + source, + params, +}: { + source: ProjectRecord; + params: ProjectRecord; +}): void { + for (const key of [ + "content", + "fontSize", + "fontFamily", + "color", + "textAlign", + "fontWeight", + "fontStyle", + "textDecoration", + "letterSpacing", + "lineHeight", + ]) { + copyPrimitiveParam({ source, params, sourceKey: key, paramKey: key }); + } + + if (!isRecord(source.background)) { + return; + } + for (const key of [ + "enabled", + "color", + "cornerRadius", + "paddingX", + "paddingY", + "offsetX", + "offsetY", + ]) { + copyPrimitiveParam({ + source: source.background, + params, + sourceKey: key, + paramKey: `background.${key}`, + }); + } +} + +function copyPrimitiveParam({ + source, + params, + sourceKey, + paramKey, +}: { + source: ProjectRecord; + params: ProjectRecord; + sourceKey: string; + paramKey: string; +}): void { + const value = source[sourceKey]; + if ( + typeof value === "number" || + typeof value === "string" || + typeof value === "boolean" + ) { + params[paramKey] = value; + } +} diff --git a/apps/web/src/services/storage/migrations/v28-to-v29.ts b/apps/web/src/services/storage/migrations/v28-to-v29.ts new file mode 100644 index 00000000..a2e75ec0 --- /dev/null +++ b/apps/web/src/services/storage/migrations/v28-to-v29.ts @@ -0,0 +1,14 @@ +import { StorageMigration, type StorageMigrationRunArgs } from "./base"; +import type { MigrationResult, ProjectRecord } from "./transformers/types"; +import { transformProjectV28ToV29 } from "./transformers/v28-to-v29"; + +export class V28toV29Migration extends StorageMigration { + from = 28; + to = 29; + + async run({ + project, + }: StorageMigrationRunArgs): Promise<MigrationResult<ProjectRecord>> { + return transformProjectV28ToV29({ project }); + } +} diff --git a/apps/web/src/subtitles/build-subtitle-text-element.ts b/apps/web/src/subtitles/build-subtitle-text-element.ts index 4e0bdb85..c2d59f08 100644 --- a/apps/web/src/subtitles/build-subtitle-text-element.ts +++ b/apps/web/src/subtitles/build-subtitle-text-element.ts @@ -6,7 +6,13 @@ import { } from "@/text/layout"; import { DEFAULTS } from "@/timeline/defaults"; import { mediaTimeFromSeconds } from "@/wasm"; -import type { CreateTextElement } from "@/timeline"; +import type { CreateTextElement, TextBackground } from "@/timeline"; +import type { + TextAlign, + TextDecoration, + TextFontStyle, + TextFontWeight, +} from "@/text/primitives"; import type { SubtitleCue, SubtitleStyleOverrides } from "./types"; const SUBTITLE_MAX_WIDTH_RATIO = 0.8; @@ -89,8 +95,8 @@ function measureWrappedTextBlock({ ctx: CanvasRenderingContext2D; content: string; canvasHeight: number; - textAlign: CreateTextElement["textAlign"]; - background: CreateTextElement["background"]; + textAlign: TextAlign; + background: TextBackground; fontSize: number; lineHeight: number; }) { @@ -107,7 +113,7 @@ function measureWrappedTextBlock({ textAlign, block, background, - fontSizeRatio: fontSize / DEFAULTS.text.element.fontSize, + fontSizeRatio: fontSize / 15, }); return { @@ -124,13 +130,13 @@ function resolveSubtitleStyle({ fontFamily: string; fontSize: number; color: string; - textAlign: CreateTextElement["textAlign"]; - fontWeight: CreateTextElement["fontWeight"]; - fontStyle: CreateTextElement["fontStyle"]; - textDecoration: CreateTextElement["textDecoration"]; + textAlign: TextAlign; + fontWeight: TextFontWeight; + fontStyle: TextFontStyle; + textDecoration: TextDecoration; letterSpacing: number; lineHeight: number; - background: CreateTextElement["background"]; + background: TextBackground; placement: NonNullable<SubtitleStyleOverrides["placement"]>; } { const fontSize = @@ -139,18 +145,17 @@ function resolveSubtitleStyle({ : (style?.fontSize ?? SUBTITLE_FONT_SIZE); return { - fontFamily: style?.fontFamily ?? DEFAULTS.text.element.fontFamily, + fontFamily: style?.fontFamily ?? "Arial", fontSize, - color: style?.color ?? DEFAULTS.text.element.color, + color: style?.color ?? "#ffffff", textAlign: style?.textAlign ?? "center", fontWeight: style?.fontWeight ?? "bold", - fontStyle: style?.fontStyle ?? DEFAULTS.text.element.fontStyle, - textDecoration: - style?.textDecoration ?? DEFAULTS.text.element.textDecoration, + fontStyle: style?.fontStyle ?? "normal", + textDecoration: style?.textDecoration ?? "none", letterSpacing: style?.letterSpacing ?? DEFAULTS.text.letterSpacing, lineHeight: style?.lineHeight ?? DEFAULTS.text.lineHeight, background: { - ...DEFAULTS.text.element.background, + ...DEFAULTS.text.background, enabled: false, ...(style?.background ?? {}), }, @@ -188,7 +193,7 @@ function resolvePositionX({ visualRect, }: { canvasWidth: number; - textAlign: CreateTextElement["textAlign"]; + textAlign: TextAlign; placement: ReturnType<typeof resolveSubtitleStyle>["placement"]; visualRect: { left: number; width: number }; }): number { @@ -309,25 +314,34 @@ export function buildSubtitleTextElement({ return { ...DEFAULTS.text.element, name: `Caption ${index + 1}`, - content, duration: mediaTimeFromSeconds({ seconds: caption.duration }), startTime: mediaTimeFromSeconds({ seconds: caption.startTime }), - fontSize: style.fontSize, - fontFamily: style.fontFamily, - color: style.color, - textAlign: style.textAlign, - fontWeight: style.fontWeight, - fontStyle: style.fontStyle, - textDecoration: style.textDecoration, - letterSpacing: style.letterSpacing, - lineHeight: style.lineHeight, - background: style.background, - transform: { - ...DEFAULTS.text.element.transform, - position: { - x: positionX, - y: positionY, - }, + params: { + ...DEFAULTS.text.element.params, + content, + fontSize: style.fontSize, + fontFamily: style.fontFamily, + color: style.color, + textAlign: style.textAlign, + fontWeight: style.fontWeight, + fontStyle: style.fontStyle, + textDecoration: style.textDecoration, + letterSpacing: style.letterSpacing, + lineHeight: style.lineHeight, + "background.enabled": style.background.enabled, + "background.color": style.background.color, + "background.cornerRadius": + style.background.cornerRadius ?? DEFAULTS.text.background.cornerRadius, + "background.paddingX": + style.background.paddingX ?? DEFAULTS.text.background.paddingX, + "background.paddingY": + style.background.paddingY ?? DEFAULTS.text.background.paddingY, + "background.offsetX": + style.background.offsetX ?? DEFAULTS.text.background.offsetX, + "background.offsetY": + style.background.offsetY ?? DEFAULTS.text.background.offsetY, + "transform.positionX": positionX, + "transform.positionY": positionY, }, }; } diff --git a/apps/web/src/subtitles/types.ts b/apps/web/src/subtitles/types.ts index e4f5db3c..a2de9ab7 100644 --- a/apps/web/src/subtitles/types.ts +++ b/apps/web/src/subtitles/types.ts @@ -1,4 +1,10 @@ -import type { TextBackground, TextElement } from "@/timeline"; +import type { TextBackground } from "@/timeline"; +import type { + TextAlign, + TextDecoration, + TextFontStyle, + TextFontWeight, +} from "@/text/primitives"; import type { CaptionChunk } from "@/transcription/types"; export interface SubtitlePlacementStyle { @@ -26,10 +32,10 @@ export interface SubtitleStyleOverrides { color?: string; background?: Pick<TextBackground, "enabled" | "color"> & Partial<Omit<TextBackground, "enabled" | "color">>; - textAlign?: TextElement["textAlign"]; - fontWeight?: TextElement["fontWeight"]; - fontStyle?: TextElement["fontStyle"]; - textDecoration?: TextElement["textDecoration"]; + textAlign?: TextAlign; + fontWeight?: TextFontWeight; + fontStyle?: TextFontStyle; + textDecoration?: TextDecoration; letterSpacing?: number; lineHeight?: number; placement?: SubtitlePlacementStyle; diff --git a/apps/web/src/text/components/assets-view.tsx b/apps/web/src/text/components/assets-view.tsx index 82a8e46c..036bc898 100644 --- a/apps/web/src/text/components/assets-view.tsx +++ b/apps/web/src/text/components/assets-view.tsx @@ -36,7 +36,7 @@ export function TextView() { id: "temp-text-id", type: DEFAULTS.text.element.type, name: DEFAULTS.text.element.name, - content: DEFAULTS.text.element.content, + content: "Default text", }} aspectRatio={1} onAddToTimeline={handleAddToTimeline} diff --git a/apps/web/src/text/components/text-tab.tsx b/apps/web/src/text/components/text-tab.tsx deleted file mode 100644 index e69de29b..00000000 --- a/apps/web/src/text/components/text-tab.tsx +++ /dev/null diff --git a/apps/web/src/text/layout.ts b/apps/web/src/text/layout.ts index aabf5ca9..65e17291 100644 --- a/apps/web/src/text/layout.ts +++ b/apps/web/src/text/layout.ts @@ -1,5 +1,6 @@ -import type { TextBackground, TextElement } from "@/timeline"; +import type { TextBackground } from "@/timeline"; import { DEFAULTS } from "@/timeline/defaults"; +import type { TextAlign } from "@/text/primitives"; type TextRect = { left: number; @@ -78,7 +79,7 @@ function getTextRect({ textAlign, block, }: { - textAlign: TextElement["textAlign"]; + textAlign: TextAlign; block: TextBlockMeasurement; }): TextRect { const textAlignToLeft: Record<typeof textAlign, number> = { @@ -114,7 +115,7 @@ export function getTextBackgroundRect({ background, fontSizeRatio = 1, }: { - textAlign: TextElement["textAlign"]; + textAlign: TextAlign; block: TextBlockMeasurement; background: TextBackground; fontSizeRatio?: number; @@ -145,7 +146,7 @@ export function getTextVisualRect({ background, fontSizeRatio = 1, }: { - textAlign: TextElement["textAlign"]; + textAlign: TextAlign; block: TextBlockMeasurement; background: TextBackground; fontSizeRatio?: number; diff --git a/apps/web/src/text/measure-element.ts b/apps/web/src/text/measure-element.ts index ec14421b..d89cd2f8 100644 --- a/apps/web/src/text/measure-element.ts +++ b/apps/web/src/text/measure-element.ts @@ -8,6 +8,11 @@ import { import { measureTextLayout, type MeasuredTextLayout, + type TextAlign, + type TextDecoration, + type TextFontStyle, + type TextFontWeight, + type TextLayoutParams, } from "./primitives"; export interface ResolvedTextBackground extends TextBackground { @@ -67,23 +72,14 @@ export function measureTextElement({ localTime: number; ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; }): MeasuredTextElement { + const text = buildTextLayoutParamsFromElement({ element }); const measuredLayout = measureTextLayout({ - text: { - content: element.content, - fontSize: element.fontSize, - fontFamily: element.fontFamily, - fontWeight: element.fontWeight, - fontStyle: element.fontStyle, - textAlign: element.textAlign, - textDecoration: element.textDecoration, - letterSpacing: element.letterSpacing, - lineHeight: element.lineHeight, - }, + text, canvasHeight, ctx, }); - const bg = element.background; + const bg = buildTextBackgroundFromElement({ element }); const resolvedBackground: ResolvedTextBackground = { ...bg, paddingX: resolveNumberAtTime({ @@ -119,7 +115,7 @@ export function measureTextElement({ }; const visualRect = getTextVisualRect({ - textAlign: element.textAlign, + textAlign: text.textAlign, block: measuredLayout.block, background: resolvedBackground, fontSizeRatio: measuredLayout.fontSizeRatio, @@ -131,3 +127,180 @@ export function measureTextElement({ visualRect, }; } + +export function buildTextLayoutParamsFromElement({ + element, +}: { + element: TextElement; +}): TextLayoutParams { + return { + content: readStringParam({ + params: element.params, + key: "content", + fallback: "Default text", + }), + fontSize: readNumberParam({ + params: element.params, + key: "fontSize", + fallback: 15, + }), + fontFamily: readStringParam({ + params: element.params, + key: "fontFamily", + fallback: "Arial", + }), + fontWeight: readFontWeight({ + value: element.params.fontWeight, + fallback: "normal", + }), + fontStyle: readFontStyle({ + value: element.params.fontStyle, + fallback: "normal", + }), + textAlign: readTextAlign({ + value: element.params.textAlign, + fallback: "center", + }), + textDecoration: readTextDecoration({ + value: element.params.textDecoration, + fallback: "none", + }), + letterSpacing: readNumberParam({ + params: element.params, + key: "letterSpacing", + fallback: DEFAULTS.text.letterSpacing, + }), + lineHeight: readNumberParam({ + params: element.params, + key: "lineHeight", + fallback: DEFAULTS.text.lineHeight, + }), + }; +} + +export function buildTextBackgroundFromElement({ + element, +}: { + element: TextElement; +}): TextBackground { + return { + enabled: readBooleanParam({ + params: element.params, + key: "background.enabled", + fallback: DEFAULTS.text.background.enabled, + }), + color: readStringParam({ + params: element.params, + key: "background.color", + fallback: DEFAULTS.text.background.color, + }), + cornerRadius: readNumberParam({ + params: element.params, + key: "background.cornerRadius", + fallback: DEFAULTS.text.background.cornerRadius, + }), + paddingX: readNumberParam({ + params: element.params, + key: "background.paddingX", + fallback: DEFAULTS.text.background.paddingX, + }), + paddingY: readNumberParam({ + params: element.params, + key: "background.paddingY", + fallback: DEFAULTS.text.background.paddingY, + }), + offsetX: readNumberParam({ + params: element.params, + key: "background.offsetX", + fallback: DEFAULTS.text.background.offsetX, + }), + offsetY: readNumberParam({ + params: element.params, + key: "background.offsetY", + fallback: DEFAULTS.text.background.offsetY, + }), + }; +} + +function readStringParam({ + params, + key, + fallback, +}: { + params: TextElement["params"]; + key: string; + fallback: string; +}): string { + const value = params[key]; + return typeof value === "string" ? value : fallback; +} + +function readNumberParam({ + params, + key, + fallback, +}: { + params: TextElement["params"]; + key: string; + fallback: number; +}): number { + const value = params[key]; + return typeof value === "number" ? value : fallback; +} + +function readBooleanParam({ + params, + key, + fallback, +}: { + params: TextElement["params"]; + key: string; + fallback: boolean; +}): boolean { + const value = params[key]; + return typeof value === "boolean" ? value : fallback; +} + +function readTextAlign({ + value, + fallback, +}: { + value: unknown; + fallback: TextAlign; +}): TextAlign { + return value === "left" || value === "center" || value === "right" + ? value + : fallback; +} + +function readFontWeight({ + value, + fallback, +}: { + value: unknown; + fallback: TextFontWeight; +}): TextFontWeight { + return value === "bold" || value === "normal" ? value : fallback; +} + +function readFontStyle({ + value, + fallback, +}: { + value: unknown; + fallback: TextFontStyle; +}): TextFontStyle { + return value === "italic" || value === "normal" ? value : fallback; +} + +function readTextDecoration({ + value, + fallback, +}: { + value: unknown; + fallback: TextDecoration; +}): TextDecoration { + return value === "none" || value === "underline" || value === "line-through" + ? value + : fallback; +} diff --git a/apps/web/src/text/primitives.ts b/apps/web/src/text/primitives.ts index 9a9262a4..b2eefa95 100644 --- a/apps/web/src/text/primitives.ts +++ b/apps/web/src/text/primitives.ts @@ -85,7 +85,7 @@ export function resolveTextLayout({ const letterSpacing = text.letterSpacing ?? DEFAULTS.text.letterSpacing; const lineHeightPx = scaledFontSize * (text.lineHeight ?? DEFAULTS.text.lineHeight); - const fontSizeRatio = text.fontSize / DEFAULTS.text.element.fontSize; + const fontSizeRatio = text.fontSize / 15; return { scaledFontSize, diff --git a/apps/web/src/timeline/__tests__/update-pipeline.test.ts b/apps/web/src/timeline/__tests__/update-pipeline.test.ts index e6d63bd6..40a0e645 100644 --- a/apps/web/src/timeline/__tests__/update-pipeline.test.ts +++ b/apps/web/src/timeline/__tests__/update-pipeline.test.ts @@ -23,8 +23,14 @@ function buildVideoElement(overrides: Partial<VideoElement> = {}): VideoElement trimStart: ZERO_MEDIA_TIME, trimEnd: ZERO_MEDIA_TIME, mediaId: "media-1", - transform: buildTransform(), - opacity: 1, + params: { + "transform.positionX": 0, + "transform.positionY": 0, + "transform.scaleX": 1, + "transform.scaleY": 1, + "transform.rotate": 0, + opacity: 1, + }, ...overrides, }; } diff --git a/apps/web/src/timeline/animation-targets.ts b/apps/web/src/timeline/animation-targets.ts index fe683f92..8cccc78f 100644 --- a/apps/web/src/timeline/animation-targets.ts +++ b/apps/web/src/timeline/animation-targets.ts @@ -6,12 +6,6 @@ import type { NumericSpec, } from "@/animation/types"; import { - coerceAnimationParamValue, - getAnimationParamDefaultInterpolation, - getAnimationParamNumericRange, - getAnimationParamValueKind, -} from "@/animation/animated-params"; -import { parseEffectParamPath, } from "@/animation/effect-param-channel"; import { @@ -19,16 +13,22 @@ import { } from "@/animation/graphic-param-channel"; import { effectsRegistry, registerDefaultEffects } from "@/effects"; import { getGraphicDefinition } from "@/graphics"; -import type { ParamDefinition } from "@/params"; +import { + coerceParamValue, + getParamDefaultInterpolation, + getParamNumericRange, + getParamValueKind, + type ParamDefinition, + type ParamValues, +} from "@/params"; +import { + getElementParam, + readElementParamValue, + writeElementParamValue, + type ElementParamDefinition, +} from "@/params/registry"; import type { TimelineElement } from "@/timeline"; import { isVisualElement } from "@/timeline/element-utils"; -import { isAnimationPropertyPath } from "@/animation/path"; -import { - coerceAnimationValueForProperty, - getAnimationPropertyDefinition, - getElementBaseValueForProperty, - withElementBaseValueForProperty, -} from "@/animation"; export interface AnimationPathDescriptor { kind: AnimationBindingKind; @@ -47,52 +47,115 @@ function paramNumericRanges({ }: { param: ParamDefinition; }): Partial<Record<string, NumericSpec>> | undefined { - const range = getAnimationParamNumericRange({ param }); + const range = getParamNumericRange({ param }); return range ? { value: range } : undefined; } -function buildGraphicParamDescriptor({ +function buildParamDescriptor({ + param, + baseParams, + setParams, +}: { + param: ParamDefinition; + baseParams: ParamValues; + setParams: (params: ParamValues) => TimelineElement; +}): AnimationPathDescriptor | null { + if (param.keyframable === false) { + return null; + } + + return { + kind: getParamValueKind({ param }), + defaultInterpolation: getParamDefaultInterpolation({ param }), + numericRanges: paramNumericRanges({ param }), + coerceValue: ({ value }) => coerceParamValue({ param, value }), + getBaseValue: () => baseParams[param.key] ?? param.default, + setBaseValue: ({ value }) => { + const coercedValue = coerceParamValue({ param, value }); + if (coercedValue === null) { + return setParams(baseParams); + } + + return setParams({ + ...baseParams, + [param.key]: coercedValue, + }); + }, + }; +} + +function buildElementParamDescriptor({ element, paramKey, }: { element: TimelineElement; paramKey: string; }): AnimationPathDescriptor | null { - if (element.type !== "graphic") { + const param = getElementParam({ element, key: paramKey }); + if (!param) { return null; } - const definition = getGraphicDefinition({ - definitionId: element.definitionId, - }); - const param = definition.params.find((candidate) => candidate.key === paramKey); - if (!param) { + return buildTimelineElementParamDescriptor({ element, param }); +} + +function buildTimelineElementParamDescriptor({ + element, + param, +}: { + element: TimelineElement; + param: ElementParamDefinition; +}): AnimationPathDescriptor | null { + if (param.keyframable === false) { return null; } return { - kind: getAnimationParamValueKind({ param }), - defaultInterpolation: getAnimationParamDefaultInterpolation({ param }), + kind: getParamValueKind({ param }), + defaultInterpolation: getParamDefaultInterpolation({ param }), numericRanges: paramNumericRanges({ param }), - coerceValue: ({ value }) => coerceAnimationParamValue({ param, value }), - getBaseValue: () => element.params[param.key] ?? param.default, + coerceValue: ({ value }) => coerceParamValue({ param, value }), + getBaseValue: () => readElementParamValue({ element, param }), setBaseValue: ({ value }) => { - const coercedValue = coerceAnimationParamValue({ param, value }); + const coercedValue = coerceParamValue({ param, value }); if (coercedValue === null) { return element; } - return { - ...element, - params: { - ...element.params, - [param.key]: coercedValue, - }, - }; + return writeElementParamValue({ element, param, value: coercedValue }); }, }; } +function buildGraphicParamDescriptor({ + element, + paramKey, +}: { + element: TimelineElement; + paramKey: string; +}): AnimationPathDescriptor | null { + if (element.type !== "graphic") { + return null; + } + + const definition = getGraphicDefinition({ + definitionId: element.definitionId, + }); + const param = definition.params.find((candidate) => candidate.key === paramKey); + if (!param) { + return null; + } + + return buildParamDescriptor({ + param, + baseParams: element.params, + setParams: (params) => ({ + ...element, + params, + }), + }); +} + function buildEffectParamDescriptor({ element, effectId, @@ -118,35 +181,22 @@ function buildEffectParamDescriptor({ return null; } - return { - 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 = coerceAnimationParamValue({ param, value }); - if (coercedValue === null) { - return element; - } - - return { - ...element, - effects: - element.effects?.map((candidate) => - candidate.id !== effectId - ? candidate - : { - ...candidate, - params: { - ...candidate.params, - [param.key]: coercedValue, - }, - }, - ) ?? element.effects, - }; - }, - }; + return buildParamDescriptor({ + param, + baseParams: effect.params, + setParams: (params) => ({ + ...element, + effects: + element.effects?.map((candidate) => + candidate.id !== effectId + ? candidate + : { + ...candidate, + params, + }, + ) ?? element.effects, + }), + }); } export function resolveAnimationTarget({ @@ -156,41 +206,12 @@ export function resolveAnimationTarget({ element: TimelineElement; path: AnimationPath; }): AnimationPathDescriptor | null { - if (isAnimationPropertyPath(path)) { - const propertyDefinition = getAnimationPropertyDefinition({ - propertyPath: path, - }); - if (!propertyDefinition.supportsElement({ element })) { - return null; - } - - return { - kind: propertyDefinition.kind, - defaultInterpolation: propertyDefinition.defaultInterpolation, - numericRanges: propertyDefinition.numericRanges, - coerceValue: ({ value }) => - coerceAnimationValueForProperty({ - propertyPath: path, - value, - }), - getBaseValue: () => - getElementBaseValueForProperty({ - element, - propertyPath: path, - }), - setBaseValue: ({ value }) => { - const coercedValue = propertyDefinition.coerceValue({ value }); - if (coercedValue === null) { - return element; - } - - return withElementBaseValueForProperty({ - element, - propertyPath: path, - value: coercedValue, - }); - }, - }; + const elementParamTarget = buildElementParamDescriptor({ + element, + paramKey: path, + }); + if (elementParamTarget) { + return elementParamTarget; } const graphicParamTarget = parseGraphicParamPath({ propertyPath: path }); diff --git a/apps/web/src/timeline/audio-separation/index.ts b/apps/web/src/timeline/audio-separation/index.ts index beb488de..dd56df5f 100644 --- a/apps/web/src/timeline/audio-separation/index.ts +++ b/apps/web/src/timeline/audio-separation/index.ts @@ -87,8 +87,13 @@ export function buildSeparatedAudioElement({ trimStart: sourceElement.trimStart, trimEnd: sourceElement.trimEnd, sourceDuration: sourceElement.sourceDuration, - volume: sourceElement.volume ?? DEFAULTS.element.volume, - muted: sourceElement.muted ?? false, + params: { + volume: + typeof sourceElement.params.volume === "number" + ? sourceElement.params.volume + : DEFAULTS.element.volume, + muted: sourceElement.params.muted === true, + }, retime: sourceElement.retime ? { rate: sourceElement.retime.rate, diff --git a/apps/web/src/timeline/audio-state.ts b/apps/web/src/timeline/audio-state.ts index 44df5aaf..6c8f3044 100644 --- a/apps/web/src/timeline/audio-state.ts +++ b/apps/web/src/timeline/audio-state.ts @@ -21,6 +21,23 @@ export function dBToLinear(db: number): number { return 10 ** (clampDb(db) / 20); } +export function getElementVolume({ + element, +}: { + element: AudioCapableElement; +}): number { + const value = element.params.volume; + return typeof value === "number" ? value : 0; +} + +export function isElementMuted({ + element, +}: { + element: AudioCapableElement; +}): boolean { + return element.params.muted === true; +} + export function hasAnimatedVolume({ element, }: { @@ -43,12 +60,12 @@ export function resolveEffectiveAudioGain({ trackMuted?: boolean; localTime: number; }): number { - if (trackMuted || element.muted === true) { + if (trackMuted || isElementMuted({ element })) { return 0; } const resolvedDb = resolveNumberAtTime({ - baseValue: element.volume ?? 0, + baseValue: getElementVolume({ element }), animations: element.animations, propertyPath: "volume", localTime: Math.round(localTime * TICKS_PER_SECOND), diff --git a/apps/web/src/timeline/components/audio-tab.tsx b/apps/web/src/timeline/components/audio-tab.tsx deleted file mode 100644 index 1e47dcc8..00000000 --- a/apps/web/src/timeline/components/audio-tab.tsx +++ /dev/null @@ -1,151 +0,0 @@ -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/components/audio-volume-line.tsx b/apps/web/src/timeline/components/audio-volume-line.tsx index ac2fd32a..659bc5dc 100644 --- a/apps/web/src/timeline/components/audio-volume-line.tsx +++ b/apps/web/src/timeline/components/audio-volume-line.tsx @@ -3,13 +3,12 @@ import { useCallback, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useEditor } from "@/editor/use-editor"; -import { DEFAULTS } from "@/timeline/defaults"; import { getDbFromLinePos, getLinePosFromDb, } from "@/timeline/audio-display"; import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants"; -import { hasAnimatedVolume } from "@/timeline/audio-state"; +import { getElementVolume, hasAnimatedVolume } from "@/timeline/audio-state"; import type { AudioElement } from "@/timeline/types"; import { clamp, @@ -60,10 +59,8 @@ export function AudioVolumeLine({ const editor = useEditor(); const surfaceRef = useRef<HTMLDivElement>(null); const activePointerIdRef = useRef<number | null>(null); - const startVolumeRef = useRef(element.volume ?? DEFAULTS.element.volume); - const lastPreviewVolumeRef = useRef( - element.volume ?? DEFAULTS.element.volume, - ); + const startVolumeRef = useRef(getElementVolume({ element })); + const lastPreviewVolumeRef = useRef(getElementVolume({ element })); const hasChangedRef = useRef(false); const [isDragging, setIsDragging] = useState(false); const [tooltipClientPos, setTooltipClientPos] = useState<{ @@ -72,7 +69,7 @@ export function AudioVolumeLine({ } | null>(null); const hasAnimatedEnvelope = hasAnimatedVolume({ element }); - const currentVolume = element.volume ?? DEFAULTS.element.volume; + const currentVolume = getElementVolume({ element }); const lineTop = `${getLinePosFromDb({ db: currentVolume })}%`; const volumeLabel = `${formatNumberForDisplay({ @@ -96,7 +93,7 @@ export function AudioVolumeLine({ { trackId, elementId: element.id, - updates: { volume: nextVolume }, + updates: { params: { volume: nextVolume } }, }, ], }); diff --git a/apps/web/src/timeline/components/timeline-element.tsx b/apps/web/src/timeline/components/timeline-element.tsx index be8409f3..74f026dc 100644 --- a/apps/web/src/timeline/components/timeline-element.tsx +++ b/apps/web/src/timeline/components/timeline-element.tsx @@ -47,7 +47,7 @@ import { getSourceAudioActionLabel, isSourceAudioSeparated, } from "@/timeline/audio-separation"; -import { buildWaveformGainSamples } from "@/timeline/audio-state"; +import { buildWaveformGainSamples, isElementMuted } from "@/timeline/audio-state"; import { getTimelinePixelsPerSecond } from "@/timeline"; import { buildWaveformSourceKey } from "@/media/waveform-summary"; import { addMediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm"; @@ -335,7 +335,7 @@ export function TimelineElement({ } }; - const isMuted = canElementHaveAudio(element) && element.muted === true; + const isMuted = canElementHaveAudio(element) && isElementMuted({ element }); const canToggleCurrentSourceAudio = selectedElements.length === 1 && isCurrentElementSelected && @@ -908,7 +908,9 @@ function TextElementContent({ }) { return ( <div className="flex size-full items-center justify-start pl-2"> - <span className="truncate text-xs text-white">{element.content}</span> + <span className="truncate text-xs text-white"> + {typeof element.params.content === "string" ? element.params.content : ""} + </span> </div> ); } diff --git a/apps/web/src/timeline/controllers/drag-drop-controller.ts b/apps/web/src/timeline/controllers/drag-drop-controller.ts index e0624a74..a419101f 100644 --- a/apps/web/src/timeline/controllers/drag-drop-controller.ts +++ b/apps/web/src/timeline/controllers/drag-drop-controller.ts @@ -385,7 +385,10 @@ export class DragDropController { dragData: Extract<TimelineDragData, { type: "text" }>; }): void { const element = buildTextElement({ - raw: { name: dragData.name ?? "", content: dragData.content ?? "" }, + raw: { + name: dragData.name ?? "", + params: { content: dragData.content ?? "" }, + }, startTime: target.xPosition, }); this.insertAtTarget({ element, target, trackType: "text" }); diff --git a/apps/web/src/timeline/defaults.ts b/apps/web/src/timeline/defaults.ts index 3b47b4f4..d32fb649 100644 --- a/apps/web/src/timeline/defaults.ts +++ b/apps/web/src/timeline/defaults.ts @@ -31,26 +31,36 @@ const defaultTextBackground = { const defaultTextElement: Omit<TextElement, "id"> = { type: "text", name: "Text", - content: "Default text", - fontSize: 15, - fontFamily: "Arial", - color: "#ffffff", - background: { ...defaultTextBackground }, - textAlign: "center", - fontWeight: "normal", - fontStyle: "normal", - textDecoration: "none", - letterSpacing: defaultTextLetterSpacing, - lineHeight: defaultTextLineHeight, duration: DEFAULT_NEW_ELEMENT_DURATION, startTime: ZERO_MEDIA_TIME, trimStart: ZERO_MEDIA_TIME, trimEnd: ZERO_MEDIA_TIME, - transform: { - ...defaultTransform, - position: { ...defaultTransform.position }, + params: { + content: "Default text", + fontSize: 15, + fontFamily: "Arial", + color: "#ffffff", + textAlign: "center", + fontWeight: "normal", + fontStyle: "normal", + textDecoration: "none", + letterSpacing: defaultTextLetterSpacing, + lineHeight: defaultTextLineHeight, + "background.enabled": defaultTextBackground.enabled, + "background.color": defaultTextBackground.color, + "background.cornerRadius": defaultTextBackground.cornerRadius, + "background.paddingX": defaultTextBackground.paddingX, + "background.paddingY": defaultTextBackground.paddingY, + "background.offsetX": defaultTextBackground.offsetX, + "background.offsetY": defaultTextBackground.offsetY, + "transform.positionX": defaultTransform.position.x, + "transform.positionY": defaultTransform.position.y, + "transform.scaleX": defaultTransform.scaleX, + "transform.scaleY": defaultTransform.scaleY, + "transform.rotate": defaultTransform.rotate, + opacity: defaultOpacity, + blendMode: defaultBlendMode, }, - opacity: defaultOpacity, }; const defaultTimelineViewState: TTimelineViewState = { diff --git a/apps/web/src/timeline/element-utils.ts b/apps/web/src/timeline/element-utils.ts index cddbf6fd..10c759d9 100644 --- a/apps/web/src/timeline/element-utils.ts +++ b/apps/web/src/timeline/element-utils.ts @@ -11,7 +11,6 @@ import { type CreateStickerElement, type CreateUploadAudioElement, type CreateLibraryAudioElement, - type TextBackground, type TextElement, type SceneTracks, type TimelineElement, @@ -28,6 +27,10 @@ import type { MediaType } from "@/media/types"; import { buildDefaultEffectInstance } from "@/effects"; import { buildDefaultGraphicInstance } from "@/graphics"; import type { ParamValues } from "@/params"; +import { + buildDefaultParamValues, + getBuiltInElementParams, +} from "@/params/registry"; import { capitalizeFirstLetter } from "@/utils/string"; import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; @@ -87,20 +90,12 @@ export function requiresMediaId({ ); } -function buildTextBackground( - raw: Partial<TextBackground> | undefined, -): TextBackground { - const color = raw?.color ?? DEFAULTS.text.element.background.color; - const enabled = raw?.enabled ?? color !== "transparent"; - return { - enabled, - color, - cornerRadius: raw?.cornerRadius, - paddingX: raw?.paddingX, - paddingY: raw?.paddingY, - offsetX: raw?.offsetX, - offsetY: raw?.offsetY, - }; +function buildDefaultElementParams({ + type, +}: { + type: TimelineElement["type"]; +}): ParamValues { + return buildDefaultParamValues(getBuiltInElementParams({ type })); } export function buildTextElement({ @@ -115,24 +110,14 @@ export function buildTextElement({ return { type: "text", name: t.name ?? DEFAULTS.text.element.name, - content: t.content ?? DEFAULTS.text.element.content, duration: t.duration ?? DEFAULT_NEW_ELEMENT_DURATION, startTime, trimStart: ZERO_MEDIA_TIME, trimEnd: ZERO_MEDIA_TIME, - fontSize: t.fontSize ?? DEFAULTS.text.element.fontSize, - fontFamily: t.fontFamily ?? DEFAULTS.text.element.fontFamily, - color: t.color ?? DEFAULTS.text.element.color, - background: buildTextBackground(t.background), - textAlign: t.textAlign ?? DEFAULTS.text.element.textAlign, - fontWeight: t.fontWeight ?? DEFAULTS.text.element.fontWeight, - fontStyle: t.fontStyle ?? DEFAULTS.text.element.fontStyle, - textDecoration: t.textDecoration ?? DEFAULTS.text.element.textDecoration, - letterSpacing: t.letterSpacing ?? DEFAULTS.text.element.letterSpacing, - lineHeight: t.lineHeight ?? DEFAULTS.text.element.lineHeight, - transform: t.transform ?? DEFAULTS.text.element.transform, - opacity: t.opacity ?? DEFAULTS.text.element.opacity, - blendMode: t.blendMode ?? DEFAULTS.element.blendMode, + params: { + ...buildDefaultElementParams({ type: "text" }), + ...(t.params ?? {}), + }, }; } @@ -183,12 +168,7 @@ export function buildStickerElement({ startTime, trimStart: ZERO_MEDIA_TIME, trimEnd: ZERO_MEDIA_TIME, - transform: { - ...DEFAULTS.element.transform, - position: { ...DEFAULTS.element.transform.position }, - }, - opacity: DEFAULTS.element.opacity, - blendMode: DEFAULTS.element.blendMode, + params: buildDefaultElementParams({ type: "sticker" }), }; } @@ -208,20 +188,36 @@ export function buildGraphicElement({ type: "graphic", name: name ?? capitalizeFirstLetter({ string: instance.definitionId }), definitionId: instance.definitionId, - params: { ...instance.params, ...(params ?? {}) } as ParamValues, + params: mergeParamValues({ + base: { + ...buildDefaultElementParams({ type: "graphic" }), + ...instance.params, + }, + overrides: params, + }), duration: DEFAULT_NEW_ELEMENT_DURATION, startTime, trimStart: ZERO_MEDIA_TIME, trimEnd: ZERO_MEDIA_TIME, - transform: { - ...DEFAULTS.element.transform, - position: { ...DEFAULTS.element.transform.position }, - }, - opacity: DEFAULTS.element.opacity, - blendMode: DEFAULTS.element.blendMode, }; } +function mergeParamValues({ + base, + overrides, +}: { + base: ParamValues; + overrides?: Partial<ParamValues>; +}): ParamValues { + const result: ParamValues = { ...base }; + for (const [key, value] of Object.entries(overrides ?? {})) { + if (value !== undefined) { + result[key] = value; + } + } + return result; +} + function buildVideoElement({ mediaId, name, @@ -242,16 +238,9 @@ function buildVideoElement({ trimStart: ZERO_MEDIA_TIME, trimEnd: ZERO_MEDIA_TIME, sourceDuration: duration, - muted: false, isSourceAudioEnabled: true, hidden: false, - transform: { - ...DEFAULTS.element.transform, - position: { ...DEFAULTS.element.transform.position }, - }, - opacity: DEFAULTS.element.opacity, - blendMode: DEFAULTS.element.blendMode, - volume: DEFAULTS.element.volume, + params: buildDefaultElementParams({ type: "video" }), }; } @@ -275,12 +264,7 @@ function buildImageElement({ trimStart: ZERO_MEDIA_TIME, trimEnd: ZERO_MEDIA_TIME, hidden: false, - transform: { - ...DEFAULTS.element.transform, - position: { ...DEFAULTS.element.transform.position }, - }, - opacity: DEFAULTS.element.opacity, - blendMode: DEFAULTS.element.blendMode, + params: buildDefaultElementParams({ type: "image" }), }; } @@ -307,8 +291,7 @@ function buildUploadAudioElement({ trimStart: ZERO_MEDIA_TIME, trimEnd: ZERO_MEDIA_TIME, sourceDuration: duration, - volume: DEFAULTS.element.volume, - muted: false, + params: buildDefaultElementParams({ type: "audio" }), }; if (buffer) { element.buffer = buffer; @@ -370,8 +353,7 @@ export function buildLibraryAudioElement({ trimStart: ZERO_MEDIA_TIME, trimEnd: ZERO_MEDIA_TIME, sourceDuration: duration, - volume: DEFAULTS.element.volume, - muted: false, + params: buildDefaultElementParams({ type: "audio" }), }; if (buffer) { element.buffer = buffer; @@ -411,8 +393,8 @@ export function getElementFontFamilies({ const families = new Set<string>(); for (const track of [...tracks.overlay, tracks.main, ...tracks.audio]) { for (const element of track.elements) { - if (element.type === "text" && element.fontFamily) { - families.add(element.fontFamily); + if (element.type === "text" && typeof element.params.fontFamily === "string") { + families.add(element.params.fontFamily); } if ("masks" in element) { for (const mask of element.masks ?? []) { diff --git a/apps/web/src/timeline/placement/__tests__/resolve.test.ts b/apps/web/src/timeline/placement/__tests__/resolve.test.ts index 44f0a1fe..202c9eb8 100644 --- a/apps/web/src/timeline/placement/__tests__/resolve.test.ts +++ b/apps/web/src/timeline/placement/__tests__/resolve.test.ts @@ -72,7 +72,7 @@ function buildElement({ duration: mediaTime({ ticks: duration }), trimStart: ZERO_MEDIA_TIME, trimEnd: ZERO_MEDIA_TIME, - volume: 1, + params: { volume: 1, muted: false }, sourceType: "upload", mediaId: `media-${id}`, } satisfies AudioElement; @@ -86,9 +86,14 @@ function buildElement({ trimStart: ZERO_MEDIA_TIME, trimEnd: ZERO_MEDIA_TIME, definitionId: `graphic-${id}`, - params: {}, - transform: buildTransform(), - opacity: 1, + params: { + "transform.positionX": 0, + "transform.positionY": 0, + "transform.scaleX": 1, + "transform.scaleY": 1, + "transform.rotate": 0, + opacity: 1, + }, } satisfies GraphicElement; case "text": return { @@ -99,20 +104,24 @@ function buildElement({ duration: mediaTime({ ticks: duration }), trimStart: ZERO_MEDIA_TIME, trimEnd: ZERO_MEDIA_TIME, - content: id, - fontSize: 32, - fontFamily: "sans-serif", - color: "#ffffff", - background: { - enabled: false, - color: "#000000", + params: { + content: id, + fontSize: 32, + fontFamily: "sans-serif", + color: "#ffffff", + "background.enabled": false, + "background.color": "#000000", + textAlign: "left", + fontWeight: "normal", + fontStyle: "normal", + textDecoration: "none", + "transform.positionX": 0, + "transform.positionY": 0, + "transform.scaleX": 1, + "transform.scaleY": 1, + "transform.rotate": 0, + opacity: 1, }, - textAlign: "left", - fontWeight: "normal", - fontStyle: "normal", - textDecoration: "none", - transform: buildTransform(), - opacity: 1, } satisfies TextElement; case "video": return { @@ -124,8 +133,14 @@ function buildElement({ trimStart: ZERO_MEDIA_TIME, trimEnd: ZERO_MEDIA_TIME, mediaId: `media-${id}`, - transform: buildTransform(), - opacity: 1, + params: { + "transform.positionX": 0, + "transform.positionY": 0, + "transform.scaleX": 1, + "transform.scaleY": 1, + "transform.rotate": 0, + opacity: 1, + }, } satisfies VideoElement; } diff --git a/apps/web/src/timeline/types.ts b/apps/web/src/timeline/types.ts index e84a4f4e..c009bc16 100644 --- a/apps/web/src/timeline/types.ts +++ b/apps/web/src/timeline/types.ts @@ -2,7 +2,6 @@ import type { ElementAnimations } from "@/animation/types"; import type { Effect } from "@/effects/types"; import type { Mask } from "@/masks/types"; import type { ParamValues } from "@/params"; -import type { BlendMode, Transform } from "@/rendering"; import type { MediaTime } from "@/wasm"; export type ElementRef = { @@ -87,8 +86,6 @@ export interface RetimeConfig { interface BaseAudioElement extends BaseTimelineElement { type: "audio"; - volume: number; - muted?: boolean; buffer?: AudioBuffer; retime?: RetimeConfig; } @@ -114,6 +111,7 @@ interface BaseTimelineElement { trimEnd: MediaTime; sourceDuration?: MediaTime; animations?: ElementAnimations; + params: ParamValues; } export interface VideoElement extends BaseTimelineElement { @@ -124,9 +122,6 @@ export interface VideoElement extends BaseTimelineElement { isSourceAudioEnabled?: boolean; hidden?: boolean; retime?: RetimeConfig; - transform: Transform; - opacity: number; - blendMode?: BlendMode; effects?: Effect[]; masks?: Mask[]; } @@ -135,9 +130,6 @@ export interface ImageElement extends BaseTimelineElement { type: "image"; mediaId: string; hidden?: boolean; - transform: Transform; - opacity: number; - blendMode?: BlendMode; effects?: Effect[]; masks?: Mask[]; } @@ -154,21 +146,7 @@ export interface TextBackground { export interface TextElement extends BaseTimelineElement { type: "text"; - content: string; - fontSize: number; - fontFamily: string; - color: string; - background: TextBackground; - textAlign: "left" | "center" | "right"; - fontWeight: "normal" | "bold"; - fontStyle: "normal" | "italic"; - textDecoration: "none" | "underline" | "line-through"; - letterSpacing?: number; - lineHeight?: number; hidden?: boolean; - transform: Transform; - opacity: number; - blendMode?: BlendMode; effects?: Effect[]; } @@ -179,20 +157,13 @@ export interface StickerElement extends BaseTimelineElement { intrinsicWidth?: number; intrinsicHeight?: number; hidden?: boolean; - transform: Transform; - opacity: number; - blendMode?: BlendMode; effects?: Effect[]; } export interface GraphicElement extends BaseTimelineElement { type: "graphic"; definitionId: string; - params: ParamValues; hidden?: boolean; - transform: Transform; - opacity: number; - blendMode?: BlendMode; effects?: Effect[]; masks?: Mask[]; } @@ -200,13 +171,9 @@ export interface GraphicElement extends BaseTimelineElement { export interface EffectElement extends BaseTimelineElement { type: "effect"; effectType: string; - params: ParamValues; } -export type ElementUpdatePatch = - | { transform: Transform } - | { opacity: number } - | { volume: number }; +export type ElementUpdatePatch = { params?: Partial<ParamValues> }; export type TimelineElement = | AudioElement diff --git a/apps/web/src/timeline/update-pipeline.ts b/apps/web/src/timeline/update-pipeline.ts index 7ab129e9..590a8f34 100644 --- a/apps/web/src/timeline/update-pipeline.ts +++ b/apps/web/src/timeline/update-pipeline.ts @@ -141,7 +141,14 @@ export function applyElementUpdate({ patch: Partial<TimelineElement>; context: ElementUpdateContext; }): TimelineElement { - let nextElement = { ...element, ...patch } as TimelineElement; + let nextElement = { + ...element, + ...patch, + params: { + ...element.params, + ...(patch.params ?? {}), + }, + } as TimelineElement; const changedFields = new Set( Object.keys(patch) as ElementUpdateField[], ); |
