summaryrefslogtreecommitdiff
path: root/apps/web/src/params
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/params')
-rw-r--r--apps/web/src/params/__tests__/channel-layout.test.ts10
-rw-r--r--apps/web/src/params/index.ts392
-rw-r--r--apps/web/src/params/registry.ts478
3 files changed, 790 insertions, 90 deletions
diff --git a/apps/web/src/params/__tests__/channel-layout.test.ts b/apps/web/src/params/__tests__/channel-layout.test.ts
new file mode 100644
index 00000000..e058418d
--- /dev/null
+++ b/apps/web/src/params/__tests__/channel-layout.test.ts
@@ -0,0 +1,10 @@
+import { describe, expect, test } from "bun:test";
+import { formatLinearRgba } from "@/params";
+
+describe("channel layout", () => {
+ test("formats linear animated colors as hex", () => {
+ expect(formatLinearRgba({ color: { r: 1, g: 0, b: 0, a: 1 } })).toBe(
+ "#ff0000",
+ );
+ });
+});
diff --git a/apps/web/src/params/index.ts b/apps/web/src/params/index.ts
index 633bfbd0..fa2550f8 100644
--- a/apps/web/src/params/index.ts
+++ b/apps/web/src/params/index.ts
@@ -1,50 +1,342 @@
-export type ParamValues = Record<string, number | string | boolean>;
-
-export type ParamGroup = "stroke";
-
-interface BaseParamDefinition<TKey extends string = string> {
- key: TKey;
- label: string;
- group?: ParamGroup;
-}
-
-export interface NumberParamDefinition<TKey extends string = string>
- extends BaseParamDefinition<TKey> {
- type: "number";
- default: number;
- min: number;
- max?: number;
- step: number;
- /** When set, min/max/step are in display space. display = stored * displayMultiplier. */
- displayMultiplier?: number;
- /** Show as percentage of max. min/max/step/default stay in stored space. */
- unit?: "percent";
- /** Short label shown as the scrub handle icon in the number field (e.g. "W", "R"). */
- shortLabel?: string;
-}
-
-export interface BooleanParamDefinition<TKey extends string = string>
- extends BaseParamDefinition<TKey> {
- type: "boolean";
- default: boolean;
-}
-
-export interface ColorParamDefinition<TKey extends string = string>
- extends BaseParamDefinition<TKey> {
- type: "color";
- default: string;
-}
-
-export interface SelectParamDefinition<TKey extends string = string>
- extends BaseParamDefinition<TKey> {
- type: "select";
- default: string;
- options: Array<{ value: string; label: string }>;
-}
-
-export type ParamDefinition<TKey extends string = string> =
- | NumberParamDefinition<TKey>
- | BooleanParamDefinition<TKey>
- | ColorParamDefinition<TKey>
- | SelectParamDefinition<TKey>;
-
+import { converter, formatHex, formatHex8, parse } from "culori";
+import { clamp, snapToStep } from "@/utils/math";
+
+export type ParamValue = number | string | boolean;
+export type ParamValues = Record<string, ParamValue>;
+
+export type ParamGroup = "stroke";
+
+export type ChannelValueKind = "scalar" | "discrete";
+export type ChannelEasingMode = "independent" | "shared";
+
+export interface LinearRgba {
+ r: number;
+ g: number;
+ b: number;
+ a: number;
+}
+
+export interface ChannelComponentDefinition<TKey extends string = string> {
+ key: TKey;
+ valueKind: ChannelValueKind;
+ defaultInterpolation: "linear" | "hold";
+}
+
+export interface LeafChannelLayout<TValue extends ParamValue = ParamValue> {
+ kind: "leaf";
+ component: ChannelComponentDefinition<"value">;
+ easingMode: "independent";
+ decompose: (value: TValue) => { value: TValue };
+ compose: (components: { value?: TValue }) => TValue | null;
+}
+
+export interface CompositeChannelLayout<
+ TValue extends ParamValue = ParamValue,
+ TComponents extends object = Record<string, ParamValue>,
+> {
+ kind: "composite";
+ components: Array<ChannelComponentDefinition<keyof TComponents & string>>;
+ easingMode: ChannelEasingMode;
+ decompose: (value: TValue) => TComponents | null;
+ compose: (components: Partial<TComponents>) => TValue | null;
+}
+
+export type ChannelLayout<
+ TValue extends ParamValue = ParamValue,
+ TComponents extends object = Record<string, ParamValue>,
+> = LeafChannelLayout<TValue> | CompositeChannelLayout<TValue, TComponents>;
+
+export type ParamChannelLayout =
+ | LeafChannelLayout<number>
+ | LeafChannelLayout<boolean>
+ | LeafChannelLayout<string>
+ | CompositeChannelLayout<string, LinearRgba>;
+
+const toRgb = converter("rgb");
+
+function srgbToLinear({ value }: { value: number }): number {
+ return value <= 0.04045
+ ? value / 12.92
+ : Math.pow((value + 0.055) / 1.055, 2.4);
+}
+
+function linearToSrgb({ value }: { value: number }): number {
+ const clamped = clamp({ value, min: 0, max: 1 });
+ return clamped <= 0.0031308
+ ? clamped * 12.92
+ : 1.055 * Math.pow(clamped, 1 / 2.4) - 0.055;
+}
+
+export function parseColorToLinearRgba({
+ color,
+}: {
+ color: string;
+}): LinearRgba | null {
+ const parsed = parse(color);
+ const rgb = parsed ? toRgb(parsed) : null;
+ if (!rgb) {
+ return null;
+ }
+
+ return {
+ r: srgbToLinear({ value: rgb.r ?? 0 }),
+ g: srgbToLinear({ value: rgb.g ?? 0 }),
+ b: srgbToLinear({ value: rgb.b ?? 0 }),
+ a: clamp({ value: rgb.alpha ?? 1, min: 0, max: 1 }),
+ };
+}
+
+export function formatLinearRgba({
+ color,
+}: {
+ color: LinearRgba;
+}): string {
+ const rgb: {
+ mode: "rgb";
+ r: number;
+ g: number;
+ b: number;
+ alpha: number;
+ } = {
+ mode: "rgb",
+ r: linearToSrgb({ value: color.r }),
+ g: linearToSrgb({ value: color.g }),
+ b: linearToSrgb({ value: color.b }),
+ alpha: clamp({ value: color.a, min: 0, max: 1 }),
+ };
+ return rgb.alpha < 1 ? formatHex8(rgb) : formatHex(rgb);
+}
+
+function createLeafChannelLayout<TValue extends ParamValue>({
+ valueKind,
+ defaultInterpolation,
+}: {
+ valueKind: ChannelValueKind;
+ defaultInterpolation: "linear" | "hold";
+}): LeafChannelLayout<TValue> {
+ return {
+ kind: "leaf",
+ component: {
+ key: "value",
+ valueKind,
+ defaultInterpolation,
+ },
+ easingMode: "independent",
+ decompose: (value) => ({ value }),
+ compose: ({ value }) => value ?? null,
+ };
+}
+
+export const NUMBER_CHANNEL_LAYOUT: LeafChannelLayout<number> =
+ createLeafChannelLayout<number>({
+ valueKind: "scalar",
+ defaultInterpolation: "linear",
+ });
+
+export const BOOLEAN_CHANNEL_LAYOUT: LeafChannelLayout<boolean> =
+ createLeafChannelLayout<boolean>({
+ valueKind: "discrete",
+ defaultInterpolation: "hold",
+ });
+
+export const STRING_CHANNEL_LAYOUT: LeafChannelLayout<string> =
+ createLeafChannelLayout<string>({
+ valueKind: "discrete",
+ defaultInterpolation: "hold",
+ });
+
+const colorComponent = (
+ key: keyof LinearRgba,
+): ChannelComponentDefinition<keyof LinearRgba> => ({
+ key,
+ valueKind: "scalar",
+ defaultInterpolation: "linear",
+});
+
+export const COLOR_CHANNEL_LAYOUT: CompositeChannelLayout<string, LinearRgba> = {
+ kind: "composite",
+ components: [
+ colorComponent("r"),
+ colorComponent("g"),
+ colorComponent("b"),
+ colorComponent("a"),
+ ],
+ easingMode: "shared",
+ decompose: (value) => parseColorToLinearRgba({ color: value }),
+ compose: ({ r, g, b, a }) =>
+ typeof r === "number" &&
+ typeof g === "number" &&
+ typeof b === "number" &&
+ typeof a === "number"
+ ? formatLinearRgba({ color: { r, g, b, a } })
+ : null,
+};
+
+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>
+ extends BaseParamDefinition<TKey> {
+ type: "number";
+ default: number;
+ channels?: LeafChannelLayout<number>;
+ min: number;
+ max?: number;
+ step: number;
+ /** When set, min/max/step are in display space. display = stored * displayMultiplier. */
+ displayMultiplier?: number;
+ /** Show as percentage of max. min/max/step/default stay in stored space. */
+ unit?: "percent";
+ /** Short label shown as the scrub handle icon in the number field (e.g. "W", "R"). */
+ shortLabel?: string;
+}
+
+export interface BooleanParamDefinition<TKey extends string = string>
+ extends BaseParamDefinition<TKey> {
+ type: "boolean";
+ default: boolean;
+ channels?: LeafChannelLayout<boolean>;
+}
+
+export interface ColorParamDefinition<TKey extends string = string>
+ extends BaseParamDefinition<TKey> {
+ type: "color";
+ default: string;
+ channels?: ChannelLayout<string, LinearRgba>;
+}
+
+export interface SelectParamDefinition<TKey extends string = string>
+ extends BaseParamDefinition<TKey> {
+ type: "select";
+ default: string;
+ channels?: LeafChannelLayout<string>;
+ options: Array<{ value: string; label: string }>;
+}
+
+export interface TextParamDefinition<TKey extends string = string>
+ extends BaseParamDefinition<TKey> {
+ type: "text";
+ default: string;
+ channels?: LeafChannelLayout<string>;
+}
+
+export interface FontParamDefinition<TKey extends string = string>
+ extends BaseParamDefinition<TKey> {
+ type: "font";
+ default: string;
+ channels?: LeafChannelLayout<string>;
+}
+
+export type ParamDefinition<TKey extends string = string> =
+ | NumberParamDefinition<TKey>
+ | BooleanParamDefinition<TKey>
+ | ColorParamDefinition<TKey>
+ | SelectParamDefinition<TKey>
+ | TextParamDefinition<TKey>
+ | FontParamDefinition<TKey>;
+
+export function getParamChannelLayout({
+ param,
+}: {
+ param: ParamDefinition;
+}): ParamChannelLayout {
+ switch (param.type) {
+ case "number":
+ return param.channels ?? NUMBER_CHANNEL_LAYOUT;
+ case "boolean":
+ return param.channels ?? BOOLEAN_CHANNEL_LAYOUT;
+ case "color":
+ return param.channels ?? COLOR_CHANNEL_LAYOUT;
+ case "select":
+ case "text":
+ case "font":
+ return param.channels ?? STRING_CHANNEL_LAYOUT;
+ default: {
+ const exhaustive: never = param;
+ return exhaustive;
+ }
+ }
+}
+
+export function getParamValueKind({
+ param,
+}: {
+ param: ParamDefinition;
+}): "number" | "color" | "discrete" {
+ const layout = getParamChannelLayout({ param });
+ if (layout.kind === "composite") {
+ return "color";
+ }
+ if (layout.component.valueKind === "scalar") {
+ return "number";
+ }
+ return "discrete";
+}
+
+export function getParamDefaultInterpolation({
+ param,
+}: {
+ param: ParamDefinition;
+}): "linear" | "hold" {
+ const layout = getParamChannelLayout({ param });
+ if (layout.kind === "leaf") {
+ return layout.component.defaultInterpolation;
+ }
+ return layout.components[0]?.defaultInterpolation ?? "linear";
+}
+
+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 {
+ switch (param.type) {
+ case "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));
+ }
+ case "boolean":
+ return typeof value === "boolean" ? value : null;
+ case "color":
+ case "text":
+ case "font":
+ return typeof value === "string" ? value : null;
+ case "select":
+ return typeof value === "string" &&
+ param.options.some((option) => option.value === value)
+ ? value
+ : null;
+ default: {
+ const exhaustive: never = param;
+ return exhaustive;
+ }
+ }
+}
diff --git a/apps/web/src/params/registry.ts b/apps/web/src/params/registry.ts
index d9fcc266..9e363c88 100644
--- a/apps/web/src/params/registry.ts
+++ b/apps/web/src/params/registry.ts
@@ -1,40 +1,438 @@
-import type { ParamDefinition, ParamValues } from "@/params";
-
-export function buildDefaultParamValues(
- params: ParamDefinition[],
-): ParamValues {
- const values: ParamValues = {};
- for (const param of params) {
- values[param.key] = param.default;
- }
- return values;
-}
-
-export class DefinitionRegistry<TKey extends string, TDefinition> {
- private definitions = new Map<TKey, TDefinition>();
- private entityName: string;
-
- constructor(entityName: string) {
- this.entityName = entityName;
- }
-
- register(key: TKey, definition: TDefinition): void {
- this.definitions.set(key, definition);
- }
-
- has(key: TKey): boolean {
- return this.definitions.has(key);
- }
-
- get(key: TKey): TDefinition {
- const def = this.definitions.get(key);
- if (!def) {
- throw new Error(`Unknown ${this.entityName}: ${key}`);
- }
- return def;
- }
-
- getAll(): TDefinition[] {
- return Array.from(this.definitions.values());
- }
-}
+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: readonly ParamDefinition[],
+): ParamValues {
+ const values: ParamValues = {};
+ for (const param of params) {
+ values[param.key] = param.default;
+ }
+ return values;
+}
+
+export class DefinitionRegistry<TKey extends string, TDefinition> {
+ private definitions = new Map<TKey, TDefinition>();
+ private entityName: string;
+
+ constructor(entityName: string) {
+ this.entityName = entityName;
+ }
+
+ register({
+ key,
+ definition,
+ }: {
+ key: TKey;
+ definition: TDefinition;
+ }): void {
+ this.definitions.set(key, definition);
+ }
+
+ has(key: TKey): boolean {
+ return this.definitions.has(key);
+ }
+
+ get(key: TKey): TDefinition {
+ const def = this.definitions.get(key);
+ if (!def) {
+ throw new Error(`Unknown ${this.entityName}: ${key}`);
+ }
+ return def;
+ }
+
+ getAll(): 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;
+}
+