diff options
Diffstat (limited to 'apps/web/src/params')
| -rw-r--r-- | apps/web/src/params/__tests__/channel-layout.test.ts | 10 | ||||
| -rw-r--r-- | apps/web/src/params/index.ts | 392 | ||||
| -rw-r--r-- | apps/web/src/params/registry.ts | 478 |
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; +} + |
