summaryrefslogtreecommitdiff
path: root/apps/web
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-29 01:28:50 +0200
committerMaze Winther <mazewinther@gmail.com>2026-05-02 03:27:23 +0200
commit2ee0a44735d2b930348e9774909638b2c1909738 (patch)
treee73e60097338ed411739f3b04808dad86f7bb192 /apps/web
parenta9b9cf6bf50f08723896659634f2e6afc17bcd42 (diff)
downloadopencut-2ee0a44735d2b930348e9774909638b2c1909738.tar.gz
refactor: unify element params
Diffstat (limited to 'apps/web')
-rw-r--r--apps/web/src/animation/__tests__/animated-params.test.ts70
-rw-r--r--apps/web/src/animation/animated-params.ts83
-rw-r--r--apps/web/src/animation/index.ts12
-rw-r--r--apps/web/src/animation/keyframes.ts47
-rw-r--r--apps/web/src/animation/property-registry.ts425
-rw-r--r--apps/web/src/animation/types.ts8
-rw-r--r--apps/web/src/commands/timeline/element/insert-element.ts2
-rw-r--r--apps/web/src/components/editor/panels/properties/components/element-params-tab.tsx154
-rw-r--r--apps/web/src/components/editor/panels/properties/components/property-param-field.tsx38
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts96
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts175
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts32
-rw-r--r--apps/web/src/components/editor/panels/properties/registry.tsx67
-rw-r--r--apps/web/src/core/managers/timeline-manager.ts5
-rw-r--r--apps/web/src/masks/definitions/text.ts14
-rw-r--r--apps/web/src/media/audio.ts13
-rw-r--r--apps/web/src/params/index.ts92
-rw-r--r--apps/web/src/params/registry.ts396
-rw-r--r--apps/web/src/preview/components/text-edit-overlay.tsx45
-rw-r--r--apps/web/src/preview/controllers/preview-interaction-controller.ts19
-rw-r--r--apps/web/src/preview/controllers/transform-handle-controller.ts87
-rw-r--r--apps/web/src/preview/element-bounds.ts9
-rw-r--r--apps/web/src/rendering/components/blending-tab.tsx0
-rw-r--r--apps/web/src/rendering/components/transform-tab.tsx0
-rw-r--r--apps/web/src/rendering/index.ts70
-rw-r--r--apps/web/src/services/renderer/nodes/text-node.ts5
-rw-r--r--apps/web/src/services/renderer/resolve.ts9
-rw-r--r--apps/web/src/services/renderer/scene-builder.ts32
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v28-to-v29.test.ts125
-rw-r--r--apps/web/src/services/storage/migrations/index.ts4
-rw-r--r--apps/web/src/services/storage/migrations/transformers/index.ts1
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v28-to-v29.ts243
-rw-r--r--apps/web/src/services/storage/migrations/v28-to-v29.ts14
-rw-r--r--apps/web/src/subtitles/build-subtitle-text-element.ts80
-rw-r--r--apps/web/src/subtitles/types.ts16
-rw-r--r--apps/web/src/text/components/assets-view.tsx2
-rw-r--r--apps/web/src/text/components/text-tab.tsx0
-rw-r--r--apps/web/src/text/layout.ts9
-rw-r--r--apps/web/src/text/measure-element.ts199
-rw-r--r--apps/web/src/text/primitives.ts2
-rw-r--r--apps/web/src/timeline/__tests__/update-pipeline.test.ts10
-rw-r--r--apps/web/src/timeline/animation-targets.ts217
-rw-r--r--apps/web/src/timeline/audio-separation/index.ts9
-rw-r--r--apps/web/src/timeline/audio-state.ts21
-rw-r--r--apps/web/src/timeline/components/audio-tab.tsx151
-rw-r--r--apps/web/src/timeline/components/audio-volume-line.tsx13
-rw-r--r--apps/web/src/timeline/components/timeline-element.tsx8
-rw-r--r--apps/web/src/timeline/controllers/drag-drop-controller.ts5
-rw-r--r--apps/web/src/timeline/defaults.ts40
-rw-r--r--apps/web/src/timeline/element-utils.ts106
-rw-r--r--apps/web/src/timeline/placement/__tests__/resolve.test.ts53
-rw-r--r--apps/web/src/timeline/types.ts37
-rw-r--r--apps/web/src/timeline/update-pipeline.ts9
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[],
);