refactor: unify element params

This commit is contained in:
Maze Winther 2026-04-29 01:28:50 +02:00
parent a9b9cf6bf5
commit 2ee0a44735
53 changed files with 2021 additions and 1362 deletions

View File

@ -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();

View File

@ -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;
}

View File

@ -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";

View File

@ -4,7 +4,6 @@ import type {
AnimationChannel,
AnimationInterpolation,
AnimationPath,
AnimationPropertyPath,
AnimationValue,
DiscreteAnimationChannel,
DiscreteAnimationKey,
@ -20,7 +19,6 @@ import {
decomposeAnimationValue,
} from "./binding-values";
import {
getBezierPoint,
getDefaultLeftHandle,
getDefaultRightHandle,
solveBezierProgressForTime,
@ -30,10 +28,6 @@ import {
getScalarSegmentInterpolation,
normalizeChannel,
} from "./interpolation";
import {
coerceAnimationValueForProperty,
getAnimationPropertyDefinition,
} from "./property-registry";
import {
type MediaTime,
roundMediaTime,
@ -533,47 +527,6 @@ export function upsertPathKeyframe({
});
}
export function upsertElementKeyframe({
animations,
propertyPath,
time,
value,
interpolation,
keyframeId,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPropertyPath;
time: MediaTime;
value: AnimationValue;
interpolation?: AnimationInterpolation;
keyframeId?: string;
}): ElementAnimations | undefined {
const coercedValue = coerceAnimationValueForProperty({
propertyPath,
value,
});
if (coercedValue === null) {
return animations;
}
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
return upsertPathKeyframe({
animations,
propertyPath,
time,
value: coercedValue,
interpolation,
keyframeId,
kind: propertyDefinition.kind,
defaultInterpolation: propertyDefinition.defaultInterpolation,
coerceValue: ({ value: nextValue }) =>
coerceAnimationValueForProperty({
propertyPath,
value: nextValue,
}),
});
}
export function upsertKeyframe({
channel,
time,

View File

@ -3,27 +3,22 @@ import type {
AnimationInterpolation,
AnimationPropertyPath,
AnimationValue,
NumericSpec,
} from "@/animation/types";
import { parseColorToLinearRgba } from "./binding-values";
import type { TimelineElement } from "@/timeline";
import { MIN_TRANSFORM_SCALE } from "@/animation/transform";
import {
CORNER_RADIUS_MAX,
CORNER_RADIUS_MIN,
} from "@/text/background";
coerceParamValue,
getParamDefaultInterpolation,
getParamNumericRange,
getParamValueKind,
type ParamDefinition,
} from "@/params";
import {
canElementHaveAudio,
isVisualElement,
} from "@/timeline/element-utils";
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
import { DEFAULTS } from "@/timeline/defaults";
import { snapToStep } from "@/utils/math";
export interface NumericSpec {
min?: number;
max?: number;
step?: number;
}
getBuiltInElementParams,
getElementParam,
readElementParamValue,
writeElementParamValue,
} from "@/params/registry";
import type { ElementType, TimelineElement } from "@/timeline";
export interface AnimationPropertyDefinition {
kind: AnimationBindingKind;
@ -32,10 +27,6 @@ export interface AnimationPropertyDefinition {
supportsElement: ({ element }: { element: TimelineElement }) => boolean;
getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null;
coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null;
// Apply `value` to `element` for this property. Coerces the value through
// `coerceValue` and verifies element support; returns `element` unchanged
// if either fails. Cannot be bypassed — there is no kind-narrow `setValue`
// on the public surface, so callers can't apply an unvalidated value.
applyValue: ({
element,
value,
@ -45,323 +36,98 @@ export interface AnimationPropertyDefinition {
}) => TimelineElement;
}
function applyNumericSpec({
value,
numericRange,
function getFallbackParam({
propertyPath,
}: {
value: number;
numericRange: NumericSpec | undefined;
}): number {
if (!numericRange) {
return value;
propertyPath: AnimationPropertyPath;
}): ParamDefinition | null {
const elementTypes: ElementType[] = [
"video",
"image",
"text",
"sticker",
"graphic",
"audio",
];
for (const type of elementTypes) {
const param =
getBuiltInElementParams({ type }).find(
(candidate) => candidate.key === propertyPath,
) ?? null;
if (param) {
return param;
}
}
const steppedValue =
numericRange.step != null
? snapToStep({ value, step: numericRange.step })
: value;
const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY;
const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY;
return Math.min(maxValue, Math.max(minValue, steppedValue));
return null;
}
function coerceNumberValue({
value,
numericRange,
function buildDefinition({
propertyPath,
element,
}: {
value: AnimationValue;
numericRange?: NumericSpec;
}): number | null {
if (typeof value !== "number" || Number.isNaN(value)) {
propertyPath: AnimationPropertyPath;
element?: TimelineElement;
}): AnimationPropertyDefinition | null {
const param = element
? getElementParam({ element, key: propertyPath })
: getFallbackParam({ propertyPath });
if (!param || param.keyframable === false) {
return null;
}
return applyNumericSpec({ value, numericRange });
}
const range = getParamNumericRange({ param });
function coerceColorValue({
value,
}: {
value: AnimationValue;
}): string | null {
return typeof value === "string" && parseColorToLinearRgba({ color: value })
? value
: null;
}
function createNumberPropertyDefinition({
numericRange,
supportsElement,
getValue,
setValue,
}: {
numericRange?: NumericSpec;
supportsElement: AnimationPropertyDefinition["supportsElement"];
getValue: AnimationPropertyDefinition["getValue"];
setValue: ({
element,
value,
}: {
element: TimelineElement;
value: number;
}) => TimelineElement;
}): AnimationPropertyDefinition {
return {
kind: "number",
defaultInterpolation: "linear",
numericRanges: numericRange ? { value: numericRange } : undefined,
supportsElement,
getValue,
coerceValue: ({ value }) => coerceNumberValue({ value, numericRange }),
applyValue: ({ element, value }) => {
if (!supportsElement({ element })) {
return element;
kind: getParamValueKind({ param }),
defaultInterpolation: getParamDefaultInterpolation({ param }),
numericRanges: range ? { value: range } : undefined,
supportsElement: ({ element: candidate }) =>
getElementParam({ element: candidate, key: propertyPath }) !== null,
getValue: ({ element: candidate }) =>
readElementParamValue({ element: candidate, param }),
coerceValue: ({ value }) => coerceParamValue({ param, value }),
applyValue: ({ element: candidate, value }) => {
const targetParam = getElementParam({
element: candidate,
key: propertyPath,
});
if (!targetParam) {
return candidate;
}
const coerced = coerceNumberValue({ value, numericRange });
if (coerced === null) {
return element;
const coercedValue = coerceParamValue({
param: targetParam,
value,
});
if (coercedValue === null) {
return candidate;
}
return setValue({ element, value: coerced });
return writeElementParamValue({
element: candidate,
param: targetParam,
value: coercedValue,
});
},
};
}
function createColorPropertyDefinition({
supportsElement,
getValue,
setValue,
}: {
supportsElement: AnimationPropertyDefinition["supportsElement"];
getValue: AnimationPropertyDefinition["getValue"];
setValue: ({
element,
value,
}: {
element: TimelineElement;
value: string;
}) => TimelineElement;
}): AnimationPropertyDefinition {
return {
kind: "color",
defaultInterpolation: "linear",
supportsElement,
getValue,
coerceValue: ({ value }) => coerceColorValue({ value }),
applyValue: ({ element, value }) => {
if (!supportsElement({ element })) {
return element;
}
const coerced = coerceColorValue({ value });
if (coerced === null) {
return element;
}
return setValue({ element, value: coerced });
},
};
}
const ANIMATION_PROPERTY_REGISTRY: Record<
AnimationPropertyPath,
AnimationPropertyDefinition
> = {
"transform.positionX": createNumberPropertyDefinition({
numericRange: { step: 1 },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.transform.position.x : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? {
...element,
transform: {
...element.transform,
position: { ...element.transform.position, x: value },
},
}
: element,
}),
"transform.positionY": createNumberPropertyDefinition({
numericRange: { step: 1 },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.transform.position.y : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? {
...element,
transform: {
...element.transform,
position: { ...element.transform.position, y: value },
},
}
: element,
}),
"transform.scaleX": createNumberPropertyDefinition({
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.transform.scaleX : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? {
...element,
transform: { ...element.transform, scaleX: value },
}
: element,
}),
"transform.scaleY": createNumberPropertyDefinition({
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.transform.scaleY : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? {
...element,
transform: { ...element.transform, scaleY: value },
}
: element,
}),
"transform.rotate": createNumberPropertyDefinition({
numericRange: { min: -360, max: 360, step: 1 },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.transform.rotate : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? {
...element,
transform: { ...element.transform, rotate: value },
}
: element,
}),
opacity: createNumberPropertyDefinition({
numericRange: { min: 0, max: 1, step: 0.01 },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.opacity : null,
setValue: ({ element, value }) =>
isVisualElement(element) ? { ...element, opacity: value } : element,
}),
volume: createNumberPropertyDefinition({
numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 },
supportsElement: ({ element }) => canElementHaveAudio(element),
getValue: ({ element }) =>
canElementHaveAudio(element) ? element.volume ?? 0 : null,
setValue: ({ element, value }) =>
canElementHaveAudio(element) ? { ...element, volume: value } : element,
}),
color: createColorPropertyDefinition({
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) => (element.type === "text" ? element.color : null),
setValue: ({ element, value }) =>
element.type === "text" ? { ...element, color: value } : element,
}),
"background.color": createColorPropertyDefinition({
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text" ? element.background.color : null,
setValue: ({ element, value }) =>
element.type === "text"
? {
...element,
background: { ...element.background, color: value },
}
: element,
}),
"background.paddingX": createNumberPropertyDefinition({
numericRange: { min: 0, step: 1 },
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text"
? (element.background.paddingX ?? DEFAULTS.text.background.paddingX)
: null,
setValue: ({ element, value }) =>
element.type === "text"
? {
...element,
background: { ...element.background, paddingX: value },
}
: element,
}),
"background.paddingY": createNumberPropertyDefinition({
numericRange: { min: 0, step: 1 },
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text"
? (element.background.paddingY ?? DEFAULTS.text.background.paddingY)
: null,
setValue: ({ element, value }) =>
element.type === "text"
? {
...element,
background: { ...element.background, paddingY: value },
}
: element,
}),
"background.offsetX": createNumberPropertyDefinition({
numericRange: { step: 1 },
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text"
? (element.background.offsetX ?? DEFAULTS.text.background.offsetX)
: null,
setValue: ({ element, value }) =>
element.type === "text"
? {
...element,
background: { ...element.background, offsetX: value },
}
: element,
}),
"background.offsetY": createNumberPropertyDefinition({
numericRange: { step: 1 },
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text"
? (element.background.offsetY ?? DEFAULTS.text.background.offsetY)
: null,
setValue: ({ element, value }) =>
element.type === "text"
? {
...element,
background: { ...element.background, offsetY: value },
}
: element,
}),
"background.cornerRadius": createNumberPropertyDefinition({
numericRange: {
min: CORNER_RADIUS_MIN,
max: CORNER_RADIUS_MAX,
step: 1,
},
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text"
? (element.background.cornerRadius ?? CORNER_RADIUS_MIN)
: null,
setValue: ({ element, value }) =>
element.type === "text"
? {
...element,
background: { ...element.background, cornerRadius: value },
}
: element,
}),
};
export function isAnimationPropertyPath(
propertyPath: string,
): propertyPath is AnimationPropertyPath {
return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath);
return !propertyPath.startsWith("params.") && !propertyPath.startsWith("effects.");
}
export function getAnimationPropertyDefinition({
propertyPath,
element,
}: {
propertyPath: AnimationPropertyPath;
element?: TimelineElement;
}): AnimationPropertyDefinition {
return ANIMATION_PROPERTY_REGISTRY[propertyPath];
const definition = buildDefinition({ propertyPath, element });
if (!definition) {
throw new Error(`Unknown animation property for element: ${propertyPath}`);
}
return definition;
}
export function supportsAnimationProperty({
@ -371,8 +137,7 @@ export function supportsAnimationProperty({
element: TimelineElement;
propertyPath: AnimationPropertyPath;
}): boolean {
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
return propertyDefinition.supportsElement({ element });
return getElementParam({ element, key: propertyPath }) !== null;
}
export function getElementBaseValueForProperty({
@ -382,11 +147,11 @@ export function getElementBaseValueForProperty({
element: TimelineElement;
propertyPath: AnimationPropertyPath;
}): AnimationValue | null {
const definition = getAnimationPropertyDefinition({ propertyPath });
if (!definition.supportsElement({ element })) {
const param = getElementParam({ element, key: propertyPath });
if (!param) {
return null;
}
return definition.getValue({ element });
return readElementParamValue({ element, param });
}
export function withElementBaseValueForProperty({
@ -398,26 +163,38 @@ export function withElementBaseValueForProperty({
propertyPath: AnimationPropertyPath;
value: AnimationValue;
}): TimelineElement {
const definition = getAnimationPropertyDefinition({ propertyPath });
return definition.applyValue({ element, value });
const param = getElementParam({ element, key: propertyPath });
if (!param) {
return element;
}
const coercedValue = coerceParamValue({ param, value });
if (coercedValue === null) {
return element;
}
return writeElementParamValue({ element, param, value: coercedValue });
}
export function getDefaultInterpolationForProperty({
propertyPath,
element,
}: {
propertyPath: AnimationPropertyPath;
element?: TimelineElement;
}): AnimationInterpolation {
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
return propertyDefinition.defaultInterpolation;
return getAnimationPropertyDefinition({ propertyPath, element })
.defaultInterpolation;
}
export function coerceAnimationValueForProperty({
propertyPath,
value,
element,
}: {
propertyPath: AnimationPropertyPath;
value: AnimationValue;
element?: TimelineElement;
}): AnimationValue | null {
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
return propertyDefinition.coerceValue({ value });
return getAnimationPropertyDefinition({ propertyPath, element }).coerceValue({
value,
});
}

View File

@ -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];

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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,
};
}

View File

@ -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,
};
}

View File

@ -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,
},

View File

@ -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"
/>
),
};
}

View File

@ -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 } },
},
];
});

View File

@ -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,

View File

@ -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,

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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>
);

View File

@ -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,
},
},
})),

View File

@ -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,
};
}

View File

@ -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,
});

View File

@ -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"
);
}

View File

@ -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;

View File

@ -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,

View File

@ -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 ?? [],
}),

View File

@ -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,
});
});
});

View File

@ -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(),
];

View File

@ -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";

View File

@ -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;
}
}

View File

@ -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 });
}
}

View File

@ -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,
},
};
}

View File

@ -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;

View File

@ -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}

View File

@ -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;

View File

@ -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;
}

View File

@ -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,

View File

@ -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,
};
}

View File

@ -5,12 +5,6 @@ import type {
AnimationValue,
NumericSpec,
} from "@/animation/types";
import {
coerceAnimationParamValue,
getAnimationParamDefaultInterpolation,
getAnimationParamNumericRange,
getAnimationParamValueKind,
} from "@/animation/animated-params";
import {
parseEffectParamPath,
} from "@/animation/effect-param-channel";
@ -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,10 +47,86 @@ function paramNumericRanges({
}: {
param: ParamDefinition;
}): Partial<Record<string, NumericSpec>> | undefined {
const range = getAnimationParamNumericRange({ param });
const range = getParamNumericRange({ param });
return range ? { value: range } : undefined;
}
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 {
const param = getElementParam({ element, key: paramKey });
if (!param) {
return null;
}
return buildTimelineElementParamDescriptor({ element, param });
}
function buildTimelineElementParamDescriptor({
element,
param,
}: {
element: TimelineElement;
param: ElementParamDefinition;
}): AnimationPathDescriptor | null {
if (param.keyframable === false) {
return null;
}
return {
kind: getParamValueKind({ param }),
defaultInterpolation: getParamDefaultInterpolation({ param }),
numericRanges: paramNumericRanges({ param }),
coerceValue: ({ value }) => coerceParamValue({ param, value }),
getBaseValue: () => readElementParamValue({ element, param }),
setBaseValue: ({ value }) => {
const coercedValue = coerceParamValue({ param, value });
if (coercedValue === null) {
return element;
}
return writeElementParamValue({ element, param, value: coercedValue });
},
};
}
function buildGraphicParamDescriptor({
element,
paramKey,
@ -70,27 +146,14 @@ function buildGraphicParamDescriptor({
return null;
}
return {
kind: getAnimationParamValueKind({ param }),
defaultInterpolation: getAnimationParamDefaultInterpolation({ param }),
numericRanges: paramNumericRanges({ param }),
coerceValue: ({ value }) => coerceAnimationParamValue({ param, value }),
getBaseValue: () => element.params[param.key] ?? param.default,
setBaseValue: ({ value }) => {
const coercedValue = coerceAnimationParamValue({ param, value });
if (coercedValue === null) {
return element;
}
return {
...element,
params: {
...element.params,
[param.key]: coercedValue,
},
};
},
};
return buildParamDescriptor({
param,
baseParams: element.params,
setParams: (params) => ({
...element,
params,
}),
});
}
function buildEffectParamDescriptor({
@ -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 });

View File

@ -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,

View File

@ -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),

View File

@ -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>
</>
);
}

View File

@ -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 } },
},
],
});

View File

@ -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>
);
}

View File

@ -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" });

View File

@ -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 = {

View File

@ -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 ?? []) {

View File

@ -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;
}

View File

@ -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

View File

@ -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[],
);