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 { describe, expect, test } from "bun:test";
import { import {
coerceAnimationParamValue, coerceParamValue,
getAnimationParamDefaultInterpolation, getParamDefaultInterpolation,
getAnimationParamNumericRange, getParamNumericRange,
getAnimationParamValueKind, getParamValueKind,
} from "@/animation/animated-params"; } from "@/params";
describe("animated params", () => { describe("animated params", () => {
test("snaps and clamps number params", () => { test("snaps and clamps number params", () => {
expect( expect(
coerceAnimationParamValue({ coerceParamValue({
param: { param: {
key: "intensity", key: "intensity",
label: "Intensity", label: "Intensity",
@ -24,7 +24,7 @@ describe("animated params", () => {
).toBe(0.5); ).toBe(0.5);
expect( expect(
coerceAnimationParamValue({ coerceParamValue({
param: { param: {
key: "intensity", key: "intensity",
label: "Intensity", label: "Intensity",
@ -49,14 +49,14 @@ describe("animated params", () => {
max: 1, max: 1,
step: 0.25, step: 0.25,
}; };
expect(coerceAnimationParamValue({ param, value: Number.NaN })).toBeNull(); expect(coerceParamValue({ param, value: Number.NaN })).toBeNull();
expect(coerceAnimationParamValue({ param, value: "0.5" })).toBeNull(); expect(coerceParamValue({ param, value: "0.5" })).toBeNull();
expect(coerceAnimationParamValue({ param, value: true })).toBeNull(); expect(coerceParamValue({ param, value: true })).toBeNull();
}); });
test("passthrough with step <= 0 guard", () => { test("passthrough with step <= 0 guard", () => {
expect( expect(
coerceAnimationParamValue({ coerceParamValue({
param: { param: {
key: "x", key: "x",
label: "X", label: "X",
@ -81,13 +81,13 @@ describe("animated params", () => {
{ value: "multiply", label: "Multiply" }, { value: "multiply", label: "Multiply" },
], ],
}; };
expect(coerceAnimationParamValue({ param, value: "normal" })).toBe("normal"); expect(coerceParamValue({ param, value: "normal" })).toBe("normal");
expect(coerceAnimationParamValue({ param, value: "multiply" })).toBe("multiply"); expect(coerceParamValue({ param, value: "multiply" })).toBe("multiply");
}); });
test("rejects select values outside the allowed options", () => { test("rejects select values outside the allowed options", () => {
expect( expect(
coerceAnimationParamValue({ coerceParamValue({
param: { param: {
key: "blend", key: "blend",
label: "Blend", label: "Blend",
@ -111,9 +111,9 @@ describe("animated params", () => {
default: "normal", default: "normal",
options: [{ value: "normal", label: "Normal" }], options: [{ value: "normal", label: "Normal" }],
}; };
expect(coerceAnimationParamValue({ param, value: 42 })).toBeNull(); expect(coerceParamValue({ param, value: 42 })).toBeNull();
expect(coerceAnimationParamValue({ param, value: null })).toBeNull(); expect(coerceParamValue({ param, value: null })).toBeNull();
expect(coerceAnimationParamValue({ param, value: undefined })).toBeNull(); expect(coerceParamValue({ param, value: undefined })).toBeNull();
}); });
test("boolean params accept booleans and reject other types", () => { test("boolean params accept booleans and reject other types", () => {
@ -123,10 +123,10 @@ describe("animated params", () => {
type: "boolean" as const, type: "boolean" as const,
default: true, default: true,
}; };
expect(coerceAnimationParamValue({ param, value: true })).toBe(true); expect(coerceParamValue({ param, value: true })).toBe(true);
expect(coerceAnimationParamValue({ param, value: false })).toBe(false); expect(coerceParamValue({ param, value: false })).toBe(false);
expect(coerceAnimationParamValue({ param, value: 1 })).toBeNull(); expect(coerceParamValue({ param, value: 1 })).toBeNull();
expect(coerceAnimationParamValue({ param, value: "true" })).toBeNull(); expect(coerceParamValue({ param, value: "true" })).toBeNull();
}); });
test("color params accept strings and reject other types", () => { test("color params accept strings and reject other types", () => {
@ -136,14 +136,14 @@ describe("animated params", () => {
type: "color" as const, type: "color" as const,
default: "#ffffff", default: "#ffffff",
}; };
expect(coerceAnimationParamValue({ param, value: "#ff0000" })).toBe("#ff0000"); expect(coerceParamValue({ param, value: "#ff0000" })).toBe("#ff0000");
expect(coerceAnimationParamValue({ param, value: 0xff0000 })).toBeNull(); expect(coerceParamValue({ param, value: 0xff0000 })).toBeNull();
expect(coerceAnimationParamValue({ param, value: null })).toBeNull(); expect(coerceParamValue({ param, value: null })).toBeNull();
}); });
test("getAnimationParamValueKind maps param type to binding kind", () => { test("getAnimationParamValueKind maps param type to binding kind", () => {
expect( expect(
getAnimationParamValueKind({ getParamValueKind({
param: { param: {
key: "n", key: "n",
label: "N", label: "N",
@ -155,17 +155,17 @@ describe("animated params", () => {
}), }),
).toBe("number"); ).toBe("number");
expect( expect(
getAnimationParamValueKind({ getParamValueKind({
param: { key: "c", label: "C", type: "color", default: "#fff" }, param: { key: "c", label: "C", type: "color", default: "#fff" },
}), }),
).toBe("color"); ).toBe("color");
expect( expect(
getAnimationParamValueKind({ getParamValueKind({
param: { key: "b", label: "B", type: "boolean", default: false }, param: { key: "b", label: "B", type: "boolean", default: false },
}), }),
).toBe("discrete"); ).toBe("discrete");
expect( expect(
getAnimationParamValueKind({ getParamValueKind({
param: { param: {
key: "s", key: "s",
label: "S", label: "S",
@ -179,7 +179,7 @@ describe("animated params", () => {
test("getAnimationParamDefaultInterpolation is linear for continuous, hold for discrete", () => { test("getAnimationParamDefaultInterpolation is linear for continuous, hold for discrete", () => {
expect( expect(
getAnimationParamDefaultInterpolation({ getParamDefaultInterpolation({
param: { param: {
key: "n", key: "n",
label: "N", label: "N",
@ -191,17 +191,17 @@ describe("animated params", () => {
}), }),
).toBe("linear"); ).toBe("linear");
expect( expect(
getAnimationParamDefaultInterpolation({ getParamDefaultInterpolation({
param: { key: "c", label: "C", type: "color", default: "#fff" }, param: { key: "c", label: "C", type: "color", default: "#fff" },
}), }),
).toBe("linear"); ).toBe("linear");
expect( expect(
getAnimationParamDefaultInterpolation({ getParamDefaultInterpolation({
param: { key: "b", label: "B", type: "boolean", default: false }, param: { key: "b", label: "B", type: "boolean", default: false },
}), }),
).toBe("hold"); ).toBe("hold");
expect( expect(
getAnimationParamDefaultInterpolation({ getParamDefaultInterpolation({
param: { param: {
key: "s", key: "s",
label: "S", label: "S",
@ -215,7 +215,7 @@ describe("animated params", () => {
test("getAnimationParamNumericRange returns spec for number params, undefined otherwise", () => { test("getAnimationParamNumericRange returns spec for number params, undefined otherwise", () => {
expect( expect(
getAnimationParamNumericRange({ getParamNumericRange({
param: { param: {
key: "intensity", key: "intensity",
label: "Intensity", label: "Intensity",
@ -228,12 +228,12 @@ describe("animated params", () => {
}), }),
).toEqual({ min: 0, max: 1, step: 0.1 }); ).toEqual({ min: 0, max: 1, step: 0.1 });
expect( expect(
getAnimationParamNumericRange({ getParamNumericRange({
param: { key: "c", label: "C", type: "color", default: "#fff" }, param: { key: "c", label: "C", type: "color", default: "#fff" },
}), }),
).toBeUndefined(); ).toBeUndefined();
expect( expect(
getAnimationParamNumericRange({ getParamNumericRange({
param: { key: "b", label: "B", type: "boolean", default: false }, param: { key: "b", label: "B", type: "boolean", default: false },
}), }),
).toBeUndefined(); ).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, setChannel,
splitAnimationsAtTime, splitAnimationsAtTime,
updateScalarKeyframeCurve, updateScalarKeyframeCurve,
upsertElementKeyframe,
upsertPathKeyframe, upsertPathKeyframe,
} from "./keyframes"; } from "./keyframes";
@ -75,13 +74,4 @@ export {
isAnimationPropertyPath, isAnimationPropertyPath,
} from "./path"; } from "./path";
export { export type { NumericSpec } from "./types";
coerceAnimationValueForProperty,
getAnimationPropertyDefinition,
getDefaultInterpolationForProperty,
getElementBaseValueForProperty,
supportsAnimationProperty,
type AnimationPropertyDefinition,
type NumericSpec,
withElementBaseValueForProperty,
} from "./property-registry";

View File

@ -4,7 +4,6 @@ import type {
AnimationChannel, AnimationChannel,
AnimationInterpolation, AnimationInterpolation,
AnimationPath, AnimationPath,
AnimationPropertyPath,
AnimationValue, AnimationValue,
DiscreteAnimationChannel, DiscreteAnimationChannel,
DiscreteAnimationKey, DiscreteAnimationKey,
@ -20,7 +19,6 @@ import {
decomposeAnimationValue, decomposeAnimationValue,
} from "./binding-values"; } from "./binding-values";
import { import {
getBezierPoint,
getDefaultLeftHandle, getDefaultLeftHandle,
getDefaultRightHandle, getDefaultRightHandle,
solveBezierProgressForTime, solveBezierProgressForTime,
@ -30,10 +28,6 @@ import {
getScalarSegmentInterpolation, getScalarSegmentInterpolation,
normalizeChannel, normalizeChannel,
} from "./interpolation"; } from "./interpolation";
import {
coerceAnimationValueForProperty,
getAnimationPropertyDefinition,
} from "./property-registry";
import { import {
type MediaTime, type MediaTime,
roundMediaTime, 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({ export function upsertKeyframe({
channel, channel,
time, time,

View File

@ -3,27 +3,22 @@ import type {
AnimationInterpolation, AnimationInterpolation,
AnimationPropertyPath, AnimationPropertyPath,
AnimationValue, AnimationValue,
NumericSpec,
} from "@/animation/types"; } from "@/animation/types";
import { parseColorToLinearRgba } from "./binding-values";
import type { TimelineElement } from "@/timeline";
import { MIN_TRANSFORM_SCALE } from "@/animation/transform";
import { import {
CORNER_RADIUS_MAX, coerceParamValue,
CORNER_RADIUS_MIN, getParamDefaultInterpolation,
} from "@/text/background"; getParamNumericRange,
getParamValueKind,
type ParamDefinition,
} from "@/params";
import { import {
canElementHaveAudio, getBuiltInElementParams,
isVisualElement, getElementParam,
} from "@/timeline/element-utils"; readElementParamValue,
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants"; writeElementParamValue,
import { DEFAULTS } from "@/timeline/defaults"; } from "@/params/registry";
import { snapToStep } from "@/utils/math"; import type { ElementType, TimelineElement } from "@/timeline";
export interface NumericSpec {
min?: number;
max?: number;
step?: number;
}
export interface AnimationPropertyDefinition { export interface AnimationPropertyDefinition {
kind: AnimationBindingKind; kind: AnimationBindingKind;
@ -32,10 +27,6 @@ export interface AnimationPropertyDefinition {
supportsElement: ({ element }: { element: TimelineElement }) => boolean; supportsElement: ({ element }: { element: TimelineElement }) => boolean;
getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null; getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null;
coerceValue: ({ value }: { value: AnimationValue }) => 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: ({ applyValue: ({
element, element,
value, value,
@ -45,323 +36,98 @@ export interface AnimationPropertyDefinition {
}) => TimelineElement; }) => TimelineElement;
} }
function applyNumericSpec({ function getFallbackParam({
value, propertyPath,
numericRange,
}: { }: {
value: number; propertyPath: AnimationPropertyPath;
numericRange: NumericSpec | undefined; }): ParamDefinition | null {
}): number { const elementTypes: ElementType[] = [
if (!numericRange) { "video",
return value; "image",
"text",
"sticker",
"graphic",
"audio",
];
for (const type of elementTypes) {
const param =
getBuiltInElementParams({ type }).find(
(candidate) => candidate.key === propertyPath,
) ?? null;
if (param) {
return param;
}
} }
return null;
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));
} }
function coerceNumberValue({ function buildDefinition({
value, propertyPath,
numericRange, element,
}: { }: {
value: AnimationValue; propertyPath: AnimationPropertyPath;
numericRange?: NumericSpec; element?: TimelineElement;
}): number | null { }): AnimationPropertyDefinition | null {
if (typeof value !== "number" || Number.isNaN(value)) { const param = element
? getElementParam({ element, key: propertyPath })
: getFallbackParam({ propertyPath });
if (!param || param.keyframable === false) {
return null; 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 { return {
kind: "number", kind: getParamValueKind({ param }),
defaultInterpolation: "linear", defaultInterpolation: getParamDefaultInterpolation({ param }),
numericRanges: numericRange ? { value: numericRange } : undefined, numericRanges: range ? { value: range } : undefined,
supportsElement, supportsElement: ({ element: candidate }) =>
getValue, getElementParam({ element: candidate, key: propertyPath }) !== null,
coerceValue: ({ value }) => coerceNumberValue({ value, numericRange }), getValue: ({ element: candidate }) =>
applyValue: ({ element, value }) => { readElementParamValue({ element: candidate, param }),
if (!supportsElement({ element })) { coerceValue: ({ value }) => coerceParamValue({ param, value }),
return element; applyValue: ({ element: candidate, value }) => {
const targetParam = getElementParam({
element: candidate,
key: propertyPath,
});
if (!targetParam) {
return candidate;
} }
const coerced = coerceNumberValue({ value, numericRange }); const coercedValue = coerceParamValue({
if (coerced === null) { param: targetParam,
return element; 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( export function isAnimationPropertyPath(
propertyPath: string, propertyPath: string,
): propertyPath is AnimationPropertyPath { ): propertyPath is AnimationPropertyPath {
return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath); return !propertyPath.startsWith("params.") && !propertyPath.startsWith("effects.");
} }
export function getAnimationPropertyDefinition({ export function getAnimationPropertyDefinition({
propertyPath, propertyPath,
element,
}: { }: {
propertyPath: AnimationPropertyPath; propertyPath: AnimationPropertyPath;
element?: TimelineElement;
}): AnimationPropertyDefinition { }): 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({ export function supportsAnimationProperty({
@ -371,8 +137,7 @@ export function supportsAnimationProperty({
element: TimelineElement; element: TimelineElement;
propertyPath: AnimationPropertyPath; propertyPath: AnimationPropertyPath;
}): boolean { }): boolean {
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); return getElementParam({ element, key: propertyPath }) !== null;
return propertyDefinition.supportsElement({ element });
} }
export function getElementBaseValueForProperty({ export function getElementBaseValueForProperty({
@ -382,11 +147,11 @@ export function getElementBaseValueForProperty({
element: TimelineElement; element: TimelineElement;
propertyPath: AnimationPropertyPath; propertyPath: AnimationPropertyPath;
}): AnimationValue | null { }): AnimationValue | null {
const definition = getAnimationPropertyDefinition({ propertyPath }); const param = getElementParam({ element, key: propertyPath });
if (!definition.supportsElement({ element })) { if (!param) {
return null; return null;
} }
return definition.getValue({ element }); return readElementParamValue({ element, param });
} }
export function withElementBaseValueForProperty({ export function withElementBaseValueForProperty({
@ -398,26 +163,38 @@ export function withElementBaseValueForProperty({
propertyPath: AnimationPropertyPath; propertyPath: AnimationPropertyPath;
value: AnimationValue; value: AnimationValue;
}): TimelineElement { }): TimelineElement {
const definition = getAnimationPropertyDefinition({ propertyPath }); const param = getElementParam({ element, key: propertyPath });
return definition.applyValue({ element, value }); if (!param) {
return element;
}
const coercedValue = coerceParamValue({ param, value });
if (coercedValue === null) {
return element;
}
return writeElementParamValue({ element, param, value: coercedValue });
} }
export function getDefaultInterpolationForProperty({ export function getDefaultInterpolationForProperty({
propertyPath, propertyPath,
element,
}: { }: {
propertyPath: AnimationPropertyPath; propertyPath: AnimationPropertyPath;
element?: TimelineElement;
}): AnimationInterpolation { }): AnimationInterpolation {
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); return getAnimationPropertyDefinition({ propertyPath, element })
return propertyDefinition.defaultInterpolation; .defaultInterpolation;
} }
export function coerceAnimationValueForProperty({ export function coerceAnimationValueForProperty({
propertyPath, propertyPath,
value, value,
element,
}: { }: {
propertyPath: AnimationPropertyPath; propertyPath: AnimationPropertyPath;
value: AnimationValue; value: AnimationValue;
element?: TimelineElement;
}): AnimationValue | null { }): AnimationValue | null {
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); return getAnimationPropertyDefinition({ propertyPath, element }).coerceValue({
return propertyDefinition.coerceValue({ value }); value,
});
} }

View File

@ -1,4 +1,3 @@
import type { ParamValues } from "@/params";
import type { MediaTime } from "@/wasm"; import type { MediaTime } from "@/wasm";
export const ANIMATION_PROPERTY_PATHS = [ export const ANIMATION_PROPERTY_PATHS = [
@ -21,10 +20,7 @@ export const ANIMATION_PROPERTY_PATHS = [
export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number]; export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number];
export type GraphicParamPath = `params.${string}`; export type GraphicParamPath = `params.${string}`;
export type EffectParamPath = `effects.${string}.params.${string}`; export type EffectParamPath = `effects.${string}.params.${string}`;
export type AnimationPath = export type AnimationPath = string;
| AnimationPropertyPath
| GraphicParamPath
| EffectParamPath;
export const ANIMATION_PROPERTY_GROUPS = { export const ANIMATION_PROPERTY_GROUPS = {
"transform.scale": ["transform.scaleX", "transform.scaleY"], "transform.scale": ["transform.scaleX", "transform.scaleY"],
@ -63,7 +59,7 @@ export type AnimationValueForPath<TPath extends AnimationPath> =
? AnimationPropertyValueMap[TPath] ? AnimationPropertyValueMap[TPath]
: TPath extends GraphicParamPath | EffectParamPath : TPath extends GraphicParamPath | EffectParamPath
? DynamicAnimationPathValue ? DynamicAnimationPathValue
: never; : DynamicAnimationPathValue;
export type AnimationNumericPropertyPath = { export type AnimationNumericPropertyPath = {
[K in AnimationPropertyPath]: AnimationValueForPath<K> extends number ? K : never; [K in AnimationPropertyPath]: AnimationValueForPath<K> extends number ? K : never;
}[AnimationPropertyPath]; }[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"); console.error("Text element must have content");
return false; 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"; "use client";
import type { ParamDefinition, NumberParamDefinition } from "@/params"; import type {
ParamDefinition,
NumberParamDefinition,
ParamValue,
} from "@/params";
import { import {
formatNumberForDisplay, formatNumberForDisplay,
getFractionDigitsForStep, getFractionDigitsForStep,
@ -19,6 +23,7 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { usePropertyDraft } from "../hooks/use-property-draft"; import { usePropertyDraft } from "../hooks/use-property-draft";
import { KeyframeToggle } from "./keyframe-toggle"; import { KeyframeToggle } from "./keyframe-toggle";
import { Textarea } from "@/components/ui/textarea";
export function PropertyParamField({ export function PropertyParamField({
param, param,
@ -28,8 +33,8 @@ export function PropertyParamField({
keyframe, keyframe,
}: { }: {
param: ParamDefinition; param: ParamDefinition;
value: number | string | boolean; value: ParamValue;
onPreview: (value: number | string | boolean) => void; onPreview: (value: ParamValue) => void;
onCommit: () => void; onCommit: () => void;
keyframe?: { keyframe?: {
isActive: boolean; isActive: boolean;
@ -41,7 +46,7 @@ export function PropertyParamField({
<SectionField <SectionField
label={param.label} label={param.label}
beforeLabel={ beforeLabel={
keyframe ? ( keyframe && param.keyframable !== false ? (
<KeyframeToggle <KeyframeToggle
isActive={keyframe.isActive} isActive={keyframe.isActive}
isDisabled={keyframe.isDisabled} isDisabled={keyframe.isDisabled}
@ -68,8 +73,8 @@ function ParamInput({
onCommit, onCommit,
}: { }: {
param: ParamDefinition; param: ParamDefinition;
value: number | string | boolean; value: ParamValue;
onPreview: (value: number | string | boolean) => void; onPreview: (value: ParamValue) => void;
onCommit: () => void; onCommit: () => void;
}) { }) {
if (param.type === "number") { 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; 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, upsertPathKeyframe,
} from "@/animation"; } from "@/animation";
import type { import type {
AnimationPath,
ElementAnimations, ElementAnimations,
} from "@/animation/types"; } from "@/animation/types";
import { import {
coerceAnimationParamValue, coerceParamValue,
getAnimationParamDefaultInterpolation, getParamDefaultInterpolation,
getAnimationParamValueKind, getParamValueKind,
} from "@/animation/animated-params"; type ParamDefinition,
import type { ParamDefinition } from "@/params"; } from "@/params";
import type { TimelineElement } from "@/timeline"; import type { TimelineElement } from "@/timeline";
import type { MediaTime } from "@/wasm"; import type { MediaTime } from "@/wasm";
@ -33,6 +34,7 @@ export function useKeyframedParamProperty({
trackId, trackId,
elementId, elementId,
animations, animations,
propertyPath,
localTime, localTime,
isPlayheadWithinElementRange, isPlayheadWithinElementRange,
resolvedValue, resolvedValue,
@ -42,6 +44,7 @@ export function useKeyframedParamProperty({
trackId: string; trackId: string;
elementId: string; elementId: string;
animations: ElementAnimations | undefined; animations: ElementAnimations | undefined;
propertyPath?: AnimationPath;
localTime: MediaTime; localTime: MediaTime;
isPlayheadWithinElementRange: boolean; isPlayheadWithinElementRange: boolean;
resolvedValue: number | string | boolean; resolvedValue: number | string | boolean;
@ -52,15 +55,16 @@ export function useKeyframedParamProperty({
}) => Partial<TimelineElement>; }) => Partial<TimelineElement>;
}): KeyframedParamPropertyResult { }): KeyframedParamPropertyResult {
const editor = useEditor(); const editor = useEditor();
const propertyPath = buildGraphicParamPath({ paramKey: param.key }); const resolvedPropertyPath =
propertyPath ?? buildGraphicParamPath({ paramKey: param.key });
const hasAnimatedKeyframes = hasKeyframesForPath({ const hasAnimatedKeyframes = hasKeyframesForPath({
animations, animations,
propertyPath, propertyPath: resolvedPropertyPath,
}); });
const keyframeAtTime = isPlayheadWithinElementRange const keyframeAtTime = isPlayheadWithinElementRange
? getKeyframeAtTime({ ? getKeyframeAtTime({
animations, animations,
propertyPath, propertyPath: resolvedPropertyPath,
time: localTime, time: localTime,
}) })
: null; : null;
@ -79,15 +83,15 @@ export function useKeyframedParamProperty({
updates: { updates: {
animations: upsertPathKeyframe({ animations: upsertPathKeyframe({
animations, animations,
propertyPath, propertyPath: resolvedPropertyPath,
time: localTime, time: localTime,
value, value,
kind: getAnimationParamValueKind({ param }), kind: getParamValueKind({ param }),
defaultInterpolation: getAnimationParamDefaultInterpolation({ defaultInterpolation: getParamDefaultInterpolation({
param, param,
}), }),
coerceValue: ({ value: nextValue }) => coerceValue: ({ value: nextValue }) =>
coerceAnimationParamValue({ coerceParamValue({
param, param,
value: nextValue, value: nextValue,
}), }),
@ -121,7 +125,7 @@ export function useKeyframedParamProperty({
{ {
trackId, trackId,
elementId, elementId,
propertyPath, propertyPath: resolvedPropertyPath,
keyframeId: keyframeIdAtTime, keyframeId: keyframeIdAtTime,
}, },
], ],
@ -134,7 +138,7 @@ export function useKeyframedParamProperty({
{ {
trackId, trackId,
elementId, elementId,
propertyPath, propertyPath: resolvedPropertyPath,
time: localTime, time: localTime,
value: resolvedValue, value: resolvedValue,
}, },

View File

@ -22,16 +22,43 @@ import {
MagicWand05Icon, MagicWand05Icon,
DashboardSpeed02Icon, DashboardSpeed02Icon,
} from "@hugeicons/core-free-icons"; } from "@hugeicons/core-free-icons";
import { TransformTab } from "@/rendering/components/transform-tab"; import { ElementParamsTab } from "./components/element-params-tab";
import { BlendingTab } from "@/rendering/components/blending-tab";
import { AudioTab } from "@/timeline/components/audio-tab";
import { TextTab } from "@/text/components/text-tab";
import { ClipEffectsTab, StandaloneEffectTab } from "@/effects/components/effects-tab"; import { ClipEffectsTab, StandaloneEffectTab } from "@/effects/components/effects-tab";
import { MasksTab } from "@/masks/components/masks-tab"; import { MasksTab } from "@/masks/components/masks-tab";
import { SpeedTab } from "@/speed/components/speed-tab"; import { SpeedTab } from "@/speed/components/speed-tab";
import { GraphicTab } from "@/graphics/components/graphic-tab"; import { GraphicTab } from "@/graphics/components/graphic-tab";
import { OcShapesIcon } from "@/components/icons"; 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 = { export type TabContentProps = {
trackId: string; trackId: string;
}; };
@ -58,7 +85,12 @@ function buildTransformTab({
label: "Transform", label: "Transform",
icon: <HugeiconsIcon icon={ArrowExpandIcon} size={16} />, icon: <HugeiconsIcon icon={ArrowExpandIcon} size={16} />,
content: ({ trackId }) => ( 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", label: "Blending",
icon: <HugeiconsIcon icon={RainDropIcon} size={16} />, icon: <HugeiconsIcon icon={RainDropIcon} size={16} />,
content: ({ trackId }) => ( 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", id: "audio",
label: "Audio", label: "Audio",
icon: <HugeiconsIcon icon={MusicNote03Icon} size={16} />, 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", id: "text",
label: "Text", label: "Text",
icon: <HugeiconsIcon icon={TextFontIcon} size={16} />, 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, canElementBeHidden,
canElementHaveAudio, canElementHaveAudio,
} from "@/timeline/element-utils"; } from "@/timeline/element-utils";
import { isElementMuted } from "@/timeline/audio-state";
import type { import type {
AnimationPath, AnimationPath,
AnimationInterpolation, AnimationInterpolation,
@ -843,7 +844,7 @@ export class TimelineManager {
}): void { }): void {
const shouldMute = elements.some(({ trackId, elementId }) => { const shouldMute = elements.some(({ trackId, elementId }) => {
const element = this.getElementByRef({ 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 }) => { const nextUpdates = elements.flatMap(({ trackId, elementId }) => {
@ -856,7 +857,7 @@ export class TimelineManager {
{ {
trackId, trackId,
elementId, elementId,
patch: { muted: shouldMute }, patch: { params: { muted: shouldMute } },
}, },
]; ];
}); });

View File

@ -35,7 +35,7 @@ const PERCENTAGE_DISPLAY = {
step: 1, step: 1,
} as const; } as const;
const TEXT_MASK_ALIGNMENT = DEFAULTS.text.element.textAlign; const TEXT_MASK_ALIGNMENT = "center";
const TEXT_MASK_PARAMS: ParamDefinition<keyof TextMaskParams & string>[] = [ const TEXT_MASK_PARAMS: ParamDefinition<keyof TextMaskParams & string>[] = [
{ {
@ -60,7 +60,7 @@ const TEXT_MASK_PARAMS: ParamDefinition<keyof TextMaskParams & string>[] = [
key: "fontSize", key: "fontSize",
label: "Size", label: "Size",
type: "number", type: "number",
default: DEFAULTS.text.element.fontSize, default: 15,
min: MIN_FONT_SIZE, min: MIN_FONT_SIZE,
max: MAX_FONT_SIZE, max: MAX_FONT_SIZE,
step: 1, step: 1,
@ -351,11 +351,11 @@ export const textMaskDefinition: MaskDefinition<TextMaskParams> = {
strokeWidth: 0, strokeWidth: 0,
strokeAlign: "center", strokeAlign: "center",
content: "Mask", content: "Mask",
fontSize: DEFAULTS.text.element.fontSize, fontSize: 15,
fontFamily: DEFAULTS.text.element.fontFamily, fontFamily: "Arial",
fontWeight: DEFAULTS.text.element.fontWeight, fontWeight: "normal",
fontStyle: DEFAULTS.text.element.fontStyle, fontStyle: "normal",
textDecoration: DEFAULTS.text.element.textDecoration, textDecoration: "none",
letterSpacing: DEFAULTS.text.letterSpacing, letterSpacing: DEFAULTS.text.letterSpacing,
lineHeight: DEFAULTS.text.lineHeight, lineHeight: DEFAULTS.text.lineHeight,
centerX: 0, centerX: 0,

View File

@ -11,6 +11,7 @@ import { applyAudioMasteringToBuffer } from "@/media/audio-mastering";
import type { AudioCapableElement } from "@/timeline/audio-state"; import type { AudioCapableElement } from "@/timeline/audio-state";
import { import {
hasAnimatedVolume, hasAnimatedVolume,
isElementMuted,
resolveEffectiveAudioGain, resolveEffectiveAudioGain,
} from "@/timeline/audio-state"; } from "@/timeline/audio-state";
import { doesElementHaveEnabledAudio } from "@/timeline/audio-separation"; import { doesElementHaveEnabledAudio } from "@/timeline/audio-separation";
@ -128,7 +129,7 @@ export function timelineHasAudio({
mediaAssets: MediaAsset[]; mediaAssets: MediaAsset[];
}): boolean { }): boolean {
return collectAudibleCandidates({ tracks, mediaAssets }).some( return collectAudibleCandidates({ tracks, mediaAssets }).some(
({ element }) => element.muted !== true, ({ element }) => !isElementMuted({ element }),
); );
} }
@ -168,7 +169,7 @@ export async function collectAudioElements({
trackMuted: false, trackMuted: false,
localTime: 0, localTime: 0,
}), }),
muted: element.muted === true, muted: isElementMuted({ element }),
retime: element.retime, retime: element.retime,
}; };
}), }),
@ -197,7 +198,7 @@ export async function collectAudioElements({
trackMuted: false, trackMuted: false,
localTime: 0, localTime: 0,
}), }),
muted: element.muted ?? false, muted: isElementMuted({ element }),
retime: element.retime, retime: element.retime,
}; };
}), }),
@ -501,7 +502,7 @@ export async function collectAudioMixSources({
for (const element of track.elements) { for (const element of track.elements) {
if (!canElementHaveAudio(element)) continue; if (!canElementHaveAudio(element)) continue;
if (element.muted === true) continue; if (isElementMuted({ element })) continue;
const mediaAsset = hasMediaId(element) const mediaAsset = hasMediaId(element)
? (mediaMap.get(element.mediaId) ?? null) ? (mediaMap.get(element.mediaId) ?? null)
: null; : null;
@ -570,9 +571,7 @@ export async function collectAudioClips({
: null; : null;
if (!doesElementHaveEnabledAudio({ element, mediaAsset })) continue; if (!doesElementHaveEnabledAudio({ element, mediaAsset })) continue;
const isElementMuted = const muted = isTrackMuted || isElementMuted({ element });
"muted" in element ? (element.muted ?? false) : false;
const muted = isTrackMuted || isElementMuted;
const volume = resolveEffectiveAudioGain({ const volume = resolveEffectiveAudioGain({
element, element,
trackMuted: isTrackMuted, 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"; export type ParamGroup = "stroke";
@ -6,6 +9,8 @@ interface BaseParamDefinition<TKey extends string = string> {
key: TKey; key: TKey;
label: string; label: string;
group?: ParamGroup; group?: ParamGroup;
keyframable?: boolean;
dependencies?: Array<{ param: string; equals: ParamValue }>;
} }
export interface NumberParamDefinition<TKey extends string = string> export interface NumberParamDefinition<TKey extends string = string>
@ -42,9 +47,92 @@ export interface SelectParamDefinition<TKey extends string = string>
options: Array<{ value: string; label: 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> = export type ParamDefinition<TKey extends string = string> =
| NumberParamDefinition<TKey> | NumberParamDefinition<TKey>
| BooleanParamDefinition<TKey> | BooleanParamDefinition<TKey>
| ColorParamDefinition<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( export function buildDefaultParamValues(
params: ParamDefinition[], params: readonly ParamDefinition[],
): ParamValues { ): ParamValues {
const values: ParamValues = {}; const values: ParamValues = {};
for (const param of params) { for (const param of params) {
@ -44,3 +72,367 @@ export class DefinitionRegistry<TKey extends string, TDefinition> {
return Array.from(this.definitions.values()); 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, getElementLocalTime,
} from "@/animation"; } from "@/animation";
import { resolveTransformAtTime } from "@/rendering/animation-values"; import { resolveTransformAtTime } from "@/rendering/animation-values";
import { buildTransformFromParams } from "@/rendering";
import { resolveTextLayout } from "@/text/primitives"; import { resolveTextLayout } from "@/text/primitives";
import {
buildTextBackgroundFromElement,
buildTextLayoutParamsFromElement,
} from "@/text/measure-element";
export function TextEditOverlay({ export function TextEditOverlay({
trackId, trackId,
@ -42,7 +47,7 @@ export function TextEditOverlay({
if (!div) return; if (!div) return;
const text = div.innerText; const text = div.innerText;
editor.timeline.previewElements({ editor.timeline.previewElements({
updates: [{ trackId, elementId, updates: { content: text } }], updates: [{ trackId, elementId, updates: { params: { content: text } } }],
}); });
}, [editor.timeline, trackId, elementId]); }, [editor.timeline, trackId, elementId]);
@ -69,7 +74,7 @@ export function TextEditOverlay({
elementDuration: element.duration, elementDuration: element.duration,
}); });
const transform = resolveTransformAtTime({ const transform = resolveTransformAtTime({
baseTransform: element.transform, baseTransform: buildTransformFromParams({ params: element.params }),
animations: element.animations, animations: element.animations,
localTime, localTime,
}); });
@ -80,26 +85,17 @@ export function TextEditOverlay({
}); });
const { x: displayScaleX } = viewport.getDisplayScale(); const { x: displayScaleX } = viewport.getDisplayScale();
const textParams = buildTextLayoutParamsFromElement({ element });
const resolvedTextLayout = resolveTextLayout({ const resolvedTextLayout = resolveTextLayout({
text: { text: textParams,
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,
},
canvasHeight: canvasSize.height, canvasHeight: canvasSize.height,
}); });
const lineHeight = element.lineHeight ?? DEFAULTS.text.lineHeight; const lineHeight = textParams.lineHeight ?? DEFAULTS.text.lineHeight;
const canvasLetterSpacing = element.letterSpacing ?? 0; const canvasLetterSpacing = textParams.letterSpacing ?? 0;
const lineHeightPx = resolvedTextLayout.lineHeightPx; const lineHeightPx = resolvedTextLayout.lineHeightPx;
const bg = element.background; const bg = buildTextBackgroundFromElement({ element });
const shouldShowBackground = const shouldShowBackground =
bg.enabled && bg.color && bg.color !== "transparent"; bg.enabled && bg.color && bg.color !== "transparent";
const fontSizeRatio = resolvedTextLayout.fontSizeRatio; const fontSizeRatio = resolvedTextLayout.fontSizeRatio;
@ -130,17 +126,20 @@ export function TextEditOverlay({
className="cursor-text select-text outline-none whitespace-pre" className="cursor-text select-text outline-none whitespace-pre"
style={{ style={{
fontSize: resolvedTextLayout.scaledFontSize, fontSize: resolvedTextLayout.scaledFontSize,
fontFamily: element.fontFamily, fontFamily: textParams.fontFamily,
fontWeight: element.fontWeight === "bold" ? "bold" : "normal", fontWeight: textParams.fontWeight === "bold" ? "bold" : "normal",
fontStyle: element.fontStyle === "italic" ? "italic" : "normal", fontStyle: textParams.fontStyle === "italic" ? "italic" : "normal",
textAlign: element.textAlign, textAlign: textParams.textAlign,
letterSpacing: `${canvasLetterSpacing}px`, letterSpacing: `${canvasLetterSpacing}px`,
lineHeight, lineHeight,
color: "transparent", color: "transparent",
caretColor: element.color, caretColor:
typeof element.params.color === "string"
? element.params.color
: "#ffffff",
backgroundColor: shouldShowBackground ? bg.color : "transparent", backgroundColor: shouldShowBackground ? bg.color : "transparent",
minHeight: lineHeightPx, minHeight: lineHeightPx,
textDecoration: element.textDecoration ?? "none", textDecoration: textParams.textDecoration ?? "none",
padding: shouldShowBackground padding: shouldShowBackground
? `${canvasPaddingY}px ${canvasPaddingX}px` ? `${canvasPaddingY}px ${canvasPaddingX}px`
: 0, : 0,
@ -150,7 +149,7 @@ export function TextEditOverlay({
onBlur={onCommit} onBlur={onCommit}
onKeyDown={(event) => handleKeyDown({ event })} onKeyDown={(event) => handleKeyDown({ event })}
> >
{element.content || ""} {textParams.content}
</div> </div>
</div> </div>
); );

View File

@ -18,7 +18,8 @@ import {
type SnapLine, type SnapLine,
} from "@/preview/preview-snap"; } from "@/preview/preview-snap";
import type { TCanvasSize } from "@/project/types"; 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 { isVisualElement } from "@/timeline/element-utils";
import type { import type {
ElementRef, ElementRef,
@ -51,6 +52,7 @@ interface DragElementSnapshot {
readonly trackId: string; readonly trackId: string;
readonly elementId: string; readonly elementId: string;
readonly initialTransform: Transform; readonly initialTransform: Transform;
readonly initialParams: ParamValues;
} }
interface DraggingGesture extends CapturedPointerState { interface DraggingGesture extends CapturedPointerState {
@ -218,7 +220,8 @@ function toDragElementSnapshots({
.map(({ track, element }) => ({ .map(({ track, element }) => ({
trackId: track.id, trackId: track.id,
elementId: element.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; snappedPosition.y - firstElement.initialTransform.position.y;
this.deps.timeline.previewElements( this.deps.timeline.previewElements(
drag.elements.map(({ trackId, elementId, initialTransform }) => ({ drag.elements.map(({ trackId, elementId, initialTransform, initialParams }) => ({
trackId, trackId,
elementId, elementId,
updates: { updates: {
transform: { params: {
...initialTransform, ...initialParams,
position: { "transform.positionX": initialTransform.position.x + deltaSnappedX,
x: initialTransform.position.x + deltaSnappedX, "transform.positionY": initialTransform.position.y + deltaSnappedY,
y: initialTransform.position.y + deltaSnappedY,
},
}, },
}, },
})), })),

View File

@ -23,7 +23,8 @@ import {
setChannel, setChannel,
} from "@/animation"; } from "@/animation";
import type { ElementAnimations } from "@/animation/types"; 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 { resolveTransformAtTime } from "@/rendering/animation-values";
import type { import type {
ElementRef, ElementRef,
@ -47,6 +48,7 @@ interface CornerScaleSession extends CapturedPointerState {
readonly trackId: string; readonly trackId: string;
readonly elementId: string; readonly elementId: string;
readonly initialTransform: Transform; readonly initialTransform: Transform;
readonly initialParams: ParamValues;
readonly initialDistance: number; readonly initialDistance: number;
readonly initialBoundsCx: number; readonly initialBoundsCx: number;
readonly initialBoundsCy: number; readonly initialBoundsCy: number;
@ -62,6 +64,7 @@ interface EdgeScaleSession extends CapturedPointerState {
readonly trackId: string; readonly trackId: string;
readonly elementId: string; readonly elementId: string;
readonly initialTransform: Transform; readonly initialTransform: Transform;
readonly initialParams: ParamValues;
readonly initialBoundsCx: number; readonly initialBoundsCx: number;
readonly initialBoundsCy: number; readonly initialBoundsCy: number;
readonly baseWidth: number; readonly baseWidth: number;
@ -76,6 +79,7 @@ interface RotationSession extends CapturedPointerState {
readonly trackId: string; readonly trackId: string;
readonly elementId: string; readonly elementId: string;
readonly initialTransform: Transform; readonly initialTransform: Transform;
readonly initialParams: ParamValues;
readonly initialAngle: number; readonly initialAngle: number;
readonly initialBoundsCx: number; readonly initialBoundsCx: number;
readonly initialBoundsCy: number; readonly initialBoundsCy: number;
@ -369,6 +373,7 @@ export class TransformHandleController {
trackId: context.trackId, trackId: context.trackId,
elementId: context.elementId, elementId: context.elementId,
initialTransform: context.resolvedTransform, initialTransform: context.resolvedTransform,
initialParams: context.element.params,
initialDistance: getCornerDistance({ initialDistance: getCornerDistance({
bounds: context.bounds, bounds: context.bounds,
corner, corner,
@ -410,6 +415,7 @@ export class TransformHandleController {
trackId: context.trackId, trackId: context.trackId,
elementId: context.elementId, elementId: context.elementId,
initialTransform: context.resolvedTransform, initialTransform: context.resolvedTransform,
initialParams: context.element.params,
initialAngle, initialAngle,
initialBoundsCx: context.bounds.cx, initialBoundsCx: context.bounds.cx,
initialBoundsCy: context.bounds.cy, initialBoundsCy: context.bounds.cy,
@ -447,6 +453,7 @@ export class TransformHandleController {
trackId: context.trackId, trackId: context.trackId,
elementId: context.elementId, elementId: context.elementId,
initialTransform: context.resolvedTransform, initialTransform: context.resolvedTransform,
initialParams: context.element.params,
initialBoundsCx: context.bounds.cx, initialBoundsCx: context.bounds.cx,
initialBoundsCy: context.bounds.cy, initialBoundsCy: context.bounds.cy,
baseWidth: context.bounds.width / context.resolvedTransform.scaleX, baseWidth: context.bounds.width / context.resolvedTransform.scaleX,
@ -561,7 +568,9 @@ export class TransformHandleController {
element: selectedWithBounds.element, element: selectedWithBounds.element,
bounds: selectedWithBounds.bounds, bounds: selectedWithBounds.bounds,
resolvedTransform: resolveTransformAtTime({ resolvedTransform: resolveTransformAtTime({
baseTransform: selectedWithBounds.element.transform, baseTransform: buildTransformFromParams({
params: selectedWithBounds.element.params,
}),
animations: selectedWithBounds.element.animations, animations: selectedWithBounds.element.animations,
localTime, localTime,
}), }),
@ -608,15 +617,18 @@ export class TransformHandleController {
trackId: session.trackId, trackId: session.trackId,
elementId: session.elementId, elementId: session.elementId,
updates: { updates: {
transform: { params: buildParamsWithTransform({
...session.initialTransform, params: session.initialParams,
scaleX: clampScaleNonZero( transform: {
session.initialTransform.scaleX * snappedScale, ...session.initialTransform,
), scaleX: clampScaleNonZero(
scaleY: clampScaleNonZero( session.initialTransform.scaleX * snappedScale,
session.initialTransform.scaleY * snappedScale, ),
), scaleY: clampScaleNonZero(
}, session.initialTransform.scaleY * snappedScale,
),
},
}),
...(session.shouldClearScaleAnimation && { ...(session.shouldClearScaleAnimation && {
animations: session.animationsWithoutScale, animations: session.animationsWithoutScale,
}), }),
@ -699,17 +711,20 @@ export class TransformHandleController {
trackId: session.trackId, trackId: session.trackId,
elementId: session.elementId, elementId: session.elementId,
updates: { updates: {
transform: { params: buildParamsWithTransform({
...session.initialTransform, params: session.initialParams,
scaleX: transform: {
session.edge === "right" || session.edge === "left" ...session.initialTransform,
? xSnap.snappedScale scaleX:
: session.initialTransform.scaleX, session.edge === "right" || session.edge === "left"
scaleY: ? xSnap.snappedScale
session.edge === "bottom" : session.initialTransform.scaleX,
? ySnap.snappedScale scaleY:
: session.initialTransform.scaleY, session.edge === "bottom"
}, ? ySnap.snappedScale
: session.initialTransform.scaleY,
},
}),
...(session.shouldClearScaleAnimation && { ...(session.shouldClearScaleAnimation && {
animations: session.animationsWithoutScale, animations: session.animationsWithoutScale,
}), }),
@ -742,12 +757,32 @@ export class TransformHandleController {
trackId: session.trackId, trackId: session.trackId,
elementId: session.elementId, elementId: session.elementId,
updates: { updates: {
transform: { params: buildParamsWithTransform({
...session.initialTransform, params: session.initialParams,
rotate: snappedRotation, 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, getElementLocalTime,
} from "@/animation"; } from "@/animation";
import { resolveTransformAtTime } from "@/rendering/animation-values"; import { resolveTransformAtTime } from "@/rendering/animation-values";
import { buildTransformFromParams } from "@/rendering";
export interface ElementBounds { export interface ElementBounds {
cx: number; cx: number;
@ -123,7 +124,7 @@ function getElementBounds({
if (element.type === "video" || element.type === "image") { if (element.type === "video" || element.type === "image") {
const transform = resolveTransformAtTime({ const transform = resolveTransformAtTime({
baseTransform: element.transform, baseTransform: buildTransformFromParams({ params: element.params }),
animations: element.animations, animations: element.animations,
localTime, localTime,
}); });
@ -140,7 +141,7 @@ function getElementBounds({
if (element.type === "sticker") { if (element.type === "sticker") {
const transform = resolveTransformAtTime({ const transform = resolveTransformAtTime({
baseTransform: element.transform, baseTransform: buildTransformFromParams({ params: element.params }),
animations: element.animations, animations: element.animations,
localTime, localTime,
}); });
@ -155,7 +156,7 @@ function getElementBounds({
if (element.type === "graphic") { if (element.type === "graphic") {
const transform = resolveTransformAtTime({ const transform = resolveTransformAtTime({
baseTransform: element.transform, baseTransform: buildTransformFromParams({ params: element.params }),
animations: element.animations, animations: element.animations,
localTime, localTime,
}); });
@ -170,7 +171,7 @@ function getElementBounds({
if (element.type === "text") { if (element.type === "text") {
const transform = resolveTransformAtTime({ const transform = resolveTransformAtTime({
baseTransform: element.transform, baseTransform: buildTransformFromParams({ params: element.params }),
animations: element.animations, animations: element.animations,
localTime, localTime,
}); });

View File

@ -1,3 +1,5 @@
import type { ParamValues } from "@/params";
export interface Transform { export interface Transform {
scaleX: number; scaleX: number;
scaleY: number; scaleY: number;
@ -26,3 +28,71 @@ export type BlendMode =
| "saturation" | "saturation"
| "color" | "color"
| "luminosity"; | "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 { BaseNode } from "./base-node";
import type { TextElement } from "@/timeline"; import type { TextElement } from "@/timeline";
import type { EffectPass } from "@/effects/types"; import type { EffectPass } from "@/effects/types";
import type { Transform } from "@/rendering"; import type { BlendMode, Transform } from "@/rendering";
import { drawMeasuredTextLayout } from "@/text/primitives"; import { drawMeasuredTextLayout } from "@/text/primitives";
import type { MeasuredTextElement } from "@/text/measure-element"; import type { MeasuredTextElement } from "@/text/measure-element";
export type TextNodeParams = TextElement & { export type TextNodeParams = TextElement & {
transform: Transform;
opacity: number;
blendMode?: BlendMode;
canvasCenter: { x: number; y: number }; canvasCenter: { x: number; y: number };
canvasHeight: number; canvasHeight: number;
textBaseline?: CanvasTextBaseline; textBaseline?: CanvasTextBaseline;

View File

@ -13,6 +13,7 @@ import {
resolveGraphicElementParamsAtTime, resolveGraphicElementParamsAtTime,
} from "@/graphics"; } from "@/graphics";
import { import {
buildTextBackgroundFromElement,
getTextMeasurementContext, getTextMeasurementContext,
measureTextElement, measureTextElement,
} from "@/text/measure-element"; } from "@/text/measure-element";
@ -330,6 +331,7 @@ function resolveTextNode({
elementStartTime: node.params.startTime, elementStartTime: node.params.startTime,
elementDuration: node.params.duration, elementDuration: node.params.duration,
}); });
const background = buildTextBackgroundFromElement({ element: node.params });
return { return {
transform: resolveTransformAtTime({ transform: resolveTransformAtTime({
@ -343,13 +345,16 @@ function resolveTextNode({
localTime, localTime,
}), }),
textColor: resolveColorAtTime({ textColor: resolveColorAtTime({
baseColor: node.params.color, baseColor:
typeof node.params.params.color === "string"
? node.params.params.color
: "#ffffff",
animations: node.params.animations, animations: node.params.animations,
propertyPath: "color", propertyPath: "color",
localTime, localTime,
}), }),
backgroundColor: resolveColorAtTime({ backgroundColor: resolveColorAtTime({
baseColor: node.params.background.color, baseColor: background.color,
animations: node.params.animations, animations: node.params.animations,
propertyPath: "background.color", propertyPath: "background.color",
localTime, localTime,

View File

@ -12,6 +12,11 @@ import { EffectLayerNode } from "./nodes/effect-layer-node";
import type { AnyBaseNode } from "./nodes/base-node"; import type { AnyBaseNode } from "./nodes/base-node";
import type { TBackground, TCanvasSize } from "@/project/types"; import type { TBackground, TCanvasSize } from "@/project/types";
import { DEFAULT_BACKGROUND_BLUR_INTENSITY } from "@/background/blur"; import { DEFAULT_BACKGROUND_BLUR_INTENSITY } from "@/background/blur";
import {
buildTransformFromParams,
readBlendModeFromParams,
readOpacityFromParams,
} from "@/rendering";
const PREVIEW_MAX_IMAGE_SIZE = 2048; const PREVIEW_MAX_IMAGE_SIZE = 2048;
@ -71,10 +76,10 @@ function buildTrackNodes({
trimStart: element.trimStart, trimStart: element.trimStart,
trimEnd: element.trimEnd, trimEnd: element.trimEnd,
retime: element.retime, retime: element.retime,
transform: element.transform, transform: buildTransformFromParams({ params: element.params }),
animations: element.animations, animations: element.animations,
opacity: element.opacity, opacity: readOpacityFromParams({ params: element.params }),
blendMode: element.blendMode, blendMode: readBlendModeFromParams({ params: element.params }),
effects: element.effects ?? [], effects: element.effects ?? [],
masks: element.masks ?? [], masks: element.masks ?? [],
}), }),
@ -88,10 +93,10 @@ function buildTrackNodes({
timeOffset: element.startTime, timeOffset: element.startTime,
trimStart: element.trimStart, trimStart: element.trimStart,
trimEnd: element.trimEnd, trimEnd: element.trimEnd,
transform: element.transform, transform: buildTransformFromParams({ params: element.params }),
animations: element.animations, animations: element.animations,
opacity: element.opacity, opacity: readOpacityFromParams({ params: element.params }),
blendMode: element.blendMode, blendMode: readBlendModeFromParams({ params: element.params }),
effects: element.effects ?? [], effects: element.effects ?? [],
masks: element.masks ?? [], masks: element.masks ?? [],
...(isPreview && { ...(isPreview && {
@ -106,6 +111,9 @@ function buildTrackNodes({
nodes.push( nodes.push(
new TextNode({ new TextNode({
...element, ...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 }, canvasCenter: { x: canvasSize.width / 2, y: canvasSize.height / 2 },
canvasHeight: canvasSize.height, canvasHeight: canvasSize.height,
textBaseline: "middle", textBaseline: "middle",
@ -124,10 +132,10 @@ function buildTrackNodes({
timeOffset: element.startTime, timeOffset: element.startTime,
trimStart: element.trimStart, trimStart: element.trimStart,
trimEnd: element.trimEnd, trimEnd: element.trimEnd,
transform: element.transform, transform: buildTransformFromParams({ params: element.params }),
animations: element.animations, animations: element.animations,
opacity: element.opacity, opacity: readOpacityFromParams({ params: element.params }),
blendMode: element.blendMode, blendMode: readBlendModeFromParams({ params: element.params }),
effects: element.effects ?? [], effects: element.effects ?? [],
}), }),
); );
@ -142,10 +150,10 @@ function buildTrackNodes({
timeOffset: element.startTime, timeOffset: element.startTime,
trimStart: element.trimStart, trimStart: element.trimStart,
trimEnd: element.trimEnd, trimEnd: element.trimEnd,
transform: element.transform, transform: buildTransformFromParams({ params: element.params }),
animations: element.animations, animations: element.animations,
opacity: element.opacity, opacity: readOpacityFromParams({ params: element.params }),
blendMode: element.blendMode, blendMode: readBlendModeFromParams({ params: element.params }),
effects: element.effects ?? [], effects: element.effects ?? [],
masks: element.masks ?? [], 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 { V25toV26Migration } from "./v25-to-v26";
import { V26toV27Migration } from "./v26-to-v27"; import { V26toV27Migration } from "./v26-to-v27";
import { V27toV28Migration } from "./v27-to-v28"; import { V27toV28Migration } from "./v27-to-v28";
import { V28toV29Migration } from "./v28-to-v29";
export { runStorageMigrations } from "./runner"; export { runStorageMigrations } from "./runner";
export type { MigrationProgress } from "./runner"; export type { MigrationProgress } from "./runner";
export const CURRENT_PROJECT_VERSION = 28; export const CURRENT_PROJECT_VERSION = 29;
export const migrations = [ export const migrations = [
new V0toV1Migration(), new V0toV1Migration(),
@ -61,4 +62,5 @@ export const migrations = [
new V25toV26Migration(), new V25toV26Migration(),
new V26toV27Migration(), new V26toV27Migration(),
new V27toV28Migration(), new V27toV28Migration(),
new V28toV29Migration(),
]; ];

View File

@ -4,4 +4,5 @@ export { transformProjectV2ToV3 } from "./v2-to-v3";
export { transformProjectV3ToV4 } from "./v3-to-v4"; export { transformProjectV3ToV4 } from "./v3-to-v4";
export { transformProjectV4ToV5 } from "./v4-to-v5"; export { transformProjectV4ToV5 } from "./v4-to-v5";
export { transformProjectV17ToV18 } from "./v17-to-v18"; export { transformProjectV17ToV18 } from "./v17-to-v18";
export { transformProjectV28ToV29 } from "./v28-to-v29";
export type { MigrationResult, ProjectRecord } from "./types"; 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"; } from "@/text/layout";
import { DEFAULTS } from "@/timeline/defaults"; import { DEFAULTS } from "@/timeline/defaults";
import { mediaTimeFromSeconds } from "@/wasm"; 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"; import type { SubtitleCue, SubtitleStyleOverrides } from "./types";
const SUBTITLE_MAX_WIDTH_RATIO = 0.8; const SUBTITLE_MAX_WIDTH_RATIO = 0.8;
@ -89,8 +95,8 @@ function measureWrappedTextBlock({
ctx: CanvasRenderingContext2D; ctx: CanvasRenderingContext2D;
content: string; content: string;
canvasHeight: number; canvasHeight: number;
textAlign: CreateTextElement["textAlign"]; textAlign: TextAlign;
background: CreateTextElement["background"]; background: TextBackground;
fontSize: number; fontSize: number;
lineHeight: number; lineHeight: number;
}) { }) {
@ -107,7 +113,7 @@ function measureWrappedTextBlock({
textAlign, textAlign,
block, block,
background, background,
fontSizeRatio: fontSize / DEFAULTS.text.element.fontSize, fontSizeRatio: fontSize / 15,
}); });
return { return {
@ -124,13 +130,13 @@ function resolveSubtitleStyle({
fontFamily: string; fontFamily: string;
fontSize: number; fontSize: number;
color: string; color: string;
textAlign: CreateTextElement["textAlign"]; textAlign: TextAlign;
fontWeight: CreateTextElement["fontWeight"]; fontWeight: TextFontWeight;
fontStyle: CreateTextElement["fontStyle"]; fontStyle: TextFontStyle;
textDecoration: CreateTextElement["textDecoration"]; textDecoration: TextDecoration;
letterSpacing: number; letterSpacing: number;
lineHeight: number; lineHeight: number;
background: CreateTextElement["background"]; background: TextBackground;
placement: NonNullable<SubtitleStyleOverrides["placement"]>; placement: NonNullable<SubtitleStyleOverrides["placement"]>;
} { } {
const fontSize = const fontSize =
@ -139,18 +145,17 @@ function resolveSubtitleStyle({
: (style?.fontSize ?? SUBTITLE_FONT_SIZE); : (style?.fontSize ?? SUBTITLE_FONT_SIZE);
return { return {
fontFamily: style?.fontFamily ?? DEFAULTS.text.element.fontFamily, fontFamily: style?.fontFamily ?? "Arial",
fontSize, fontSize,
color: style?.color ?? DEFAULTS.text.element.color, color: style?.color ?? "#ffffff",
textAlign: style?.textAlign ?? "center", textAlign: style?.textAlign ?? "center",
fontWeight: style?.fontWeight ?? "bold", fontWeight: style?.fontWeight ?? "bold",
fontStyle: style?.fontStyle ?? DEFAULTS.text.element.fontStyle, fontStyle: style?.fontStyle ?? "normal",
textDecoration: textDecoration: style?.textDecoration ?? "none",
style?.textDecoration ?? DEFAULTS.text.element.textDecoration,
letterSpacing: style?.letterSpacing ?? DEFAULTS.text.letterSpacing, letterSpacing: style?.letterSpacing ?? DEFAULTS.text.letterSpacing,
lineHeight: style?.lineHeight ?? DEFAULTS.text.lineHeight, lineHeight: style?.lineHeight ?? DEFAULTS.text.lineHeight,
background: { background: {
...DEFAULTS.text.element.background, ...DEFAULTS.text.background,
enabled: false, enabled: false,
...(style?.background ?? {}), ...(style?.background ?? {}),
}, },
@ -188,7 +193,7 @@ function resolvePositionX({
visualRect, visualRect,
}: { }: {
canvasWidth: number; canvasWidth: number;
textAlign: CreateTextElement["textAlign"]; textAlign: TextAlign;
placement: ReturnType<typeof resolveSubtitleStyle>["placement"]; placement: ReturnType<typeof resolveSubtitleStyle>["placement"];
visualRect: { left: number; width: number }; visualRect: { left: number; width: number };
}): number { }): number {
@ -309,25 +314,34 @@ export function buildSubtitleTextElement({
return { return {
...DEFAULTS.text.element, ...DEFAULTS.text.element,
name: `Caption ${index + 1}`, name: `Caption ${index + 1}`,
content,
duration: mediaTimeFromSeconds({ seconds: caption.duration }), duration: mediaTimeFromSeconds({ seconds: caption.duration }),
startTime: mediaTimeFromSeconds({ seconds: caption.startTime }), startTime: mediaTimeFromSeconds({ seconds: caption.startTime }),
fontSize: style.fontSize, params: {
fontFamily: style.fontFamily, ...DEFAULTS.text.element.params,
color: style.color, content,
textAlign: style.textAlign, fontSize: style.fontSize,
fontWeight: style.fontWeight, fontFamily: style.fontFamily,
fontStyle: style.fontStyle, color: style.color,
textDecoration: style.textDecoration, textAlign: style.textAlign,
letterSpacing: style.letterSpacing, fontWeight: style.fontWeight,
lineHeight: style.lineHeight, fontStyle: style.fontStyle,
background: style.background, textDecoration: style.textDecoration,
transform: { letterSpacing: style.letterSpacing,
...DEFAULTS.text.element.transform, lineHeight: style.lineHeight,
position: { "background.enabled": style.background.enabled,
x: positionX, "background.color": style.background.color,
y: positionY, "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"; import type { CaptionChunk } from "@/transcription/types";
export interface SubtitlePlacementStyle { export interface SubtitlePlacementStyle {
@ -26,10 +32,10 @@ export interface SubtitleStyleOverrides {
color?: string; color?: string;
background?: Pick<TextBackground, "enabled" | "color"> & background?: Pick<TextBackground, "enabled" | "color"> &
Partial<Omit<TextBackground, "enabled" | "color">>; Partial<Omit<TextBackground, "enabled" | "color">>;
textAlign?: TextElement["textAlign"]; textAlign?: TextAlign;
fontWeight?: TextElement["fontWeight"]; fontWeight?: TextFontWeight;
fontStyle?: TextElement["fontStyle"]; fontStyle?: TextFontStyle;
textDecoration?: TextElement["textDecoration"]; textDecoration?: TextDecoration;
letterSpacing?: number; letterSpacing?: number;
lineHeight?: number; lineHeight?: number;
placement?: SubtitlePlacementStyle; placement?: SubtitlePlacementStyle;

View File

@ -36,7 +36,7 @@ export function TextView() {
id: "temp-text-id", id: "temp-text-id",
type: DEFAULTS.text.element.type, type: DEFAULTS.text.element.type,
name: DEFAULTS.text.element.name, name: DEFAULTS.text.element.name,
content: DEFAULTS.text.element.content, content: "Default text",
}} }}
aspectRatio={1} aspectRatio={1}
onAddToTimeline={handleAddToTimeline} 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 { DEFAULTS } from "@/timeline/defaults";
import type { TextAlign } from "@/text/primitives";
type TextRect = { type TextRect = {
left: number; left: number;
@ -78,7 +79,7 @@ function getTextRect({
textAlign, textAlign,
block, block,
}: { }: {
textAlign: TextElement["textAlign"]; textAlign: TextAlign;
block: TextBlockMeasurement; block: TextBlockMeasurement;
}): TextRect { }): TextRect {
const textAlignToLeft: Record<typeof textAlign, number> = { const textAlignToLeft: Record<typeof textAlign, number> = {
@ -114,7 +115,7 @@ export function getTextBackgroundRect({
background, background,
fontSizeRatio = 1, fontSizeRatio = 1,
}: { }: {
textAlign: TextElement["textAlign"]; textAlign: TextAlign;
block: TextBlockMeasurement; block: TextBlockMeasurement;
background: TextBackground; background: TextBackground;
fontSizeRatio?: number; fontSizeRatio?: number;
@ -145,7 +146,7 @@ export function getTextVisualRect({
background, background,
fontSizeRatio = 1, fontSizeRatio = 1,
}: { }: {
textAlign: TextElement["textAlign"]; textAlign: TextAlign;
block: TextBlockMeasurement; block: TextBlockMeasurement;
background: TextBackground; background: TextBackground;
fontSizeRatio?: number; fontSizeRatio?: number;

View File

@ -8,6 +8,11 @@ import {
import { import {
measureTextLayout, measureTextLayout,
type MeasuredTextLayout, type MeasuredTextLayout,
type TextAlign,
type TextDecoration,
type TextFontStyle,
type TextFontWeight,
type TextLayoutParams,
} from "./primitives"; } from "./primitives";
export interface ResolvedTextBackground extends TextBackground { export interface ResolvedTextBackground extends TextBackground {
@ -67,23 +72,14 @@ export function measureTextElement({
localTime: number; localTime: number;
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
}): MeasuredTextElement { }): MeasuredTextElement {
const text = buildTextLayoutParamsFromElement({ element });
const measuredLayout = measureTextLayout({ const measuredLayout = measureTextLayout({
text: { 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,
},
canvasHeight, canvasHeight,
ctx, ctx,
}); });
const bg = element.background; const bg = buildTextBackgroundFromElement({ element });
const resolvedBackground: ResolvedTextBackground = { const resolvedBackground: ResolvedTextBackground = {
...bg, ...bg,
paddingX: resolveNumberAtTime({ paddingX: resolveNumberAtTime({
@ -119,7 +115,7 @@ export function measureTextElement({
}; };
const visualRect = getTextVisualRect({ const visualRect = getTextVisualRect({
textAlign: element.textAlign, textAlign: text.textAlign,
block: measuredLayout.block, block: measuredLayout.block,
background: resolvedBackground, background: resolvedBackground,
fontSizeRatio: measuredLayout.fontSizeRatio, fontSizeRatio: measuredLayout.fontSizeRatio,
@ -131,3 +127,180 @@ export function measureTextElement({
visualRect, 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 letterSpacing = text.letterSpacing ?? DEFAULTS.text.letterSpacing;
const lineHeightPx = const lineHeightPx =
scaledFontSize * (text.lineHeight ?? DEFAULTS.text.lineHeight); scaledFontSize * (text.lineHeight ?? DEFAULTS.text.lineHeight);
const fontSizeRatio = text.fontSize / DEFAULTS.text.element.fontSize; const fontSizeRatio = text.fontSize / 15;
return { return {
scaledFontSize, scaledFontSize,

View File

@ -23,8 +23,14 @@ function buildVideoElement(overrides: Partial<VideoElement> = {}): VideoElement
trimStart: ZERO_MEDIA_TIME, trimStart: ZERO_MEDIA_TIME,
trimEnd: ZERO_MEDIA_TIME, trimEnd: ZERO_MEDIA_TIME,
mediaId: "media-1", mediaId: "media-1",
transform: buildTransform(), params: {
opacity: 1, "transform.positionX": 0,
"transform.positionY": 0,
"transform.scaleX": 1,
"transform.scaleY": 1,
"transform.rotate": 0,
opacity: 1,
},
...overrides, ...overrides,
}; };
} }

View File

@ -5,12 +5,6 @@ import type {
AnimationValue, AnimationValue,
NumericSpec, NumericSpec,
} from "@/animation/types"; } from "@/animation/types";
import {
coerceAnimationParamValue,
getAnimationParamDefaultInterpolation,
getAnimationParamNumericRange,
getAnimationParamValueKind,
} from "@/animation/animated-params";
import { import {
parseEffectParamPath, parseEffectParamPath,
} from "@/animation/effect-param-channel"; } from "@/animation/effect-param-channel";
@ -19,16 +13,22 @@ import {
} from "@/animation/graphic-param-channel"; } from "@/animation/graphic-param-channel";
import { effectsRegistry, registerDefaultEffects } from "@/effects"; import { effectsRegistry, registerDefaultEffects } from "@/effects";
import { getGraphicDefinition } from "@/graphics"; 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 type { TimelineElement } from "@/timeline";
import { isVisualElement } from "@/timeline/element-utils"; import { isVisualElement } from "@/timeline/element-utils";
import { isAnimationPropertyPath } from "@/animation/path";
import {
coerceAnimationValueForProperty,
getAnimationPropertyDefinition,
getElementBaseValueForProperty,
withElementBaseValueForProperty,
} from "@/animation";
export interface AnimationPathDescriptor { export interface AnimationPathDescriptor {
kind: AnimationBindingKind; kind: AnimationBindingKind;
@ -47,10 +47,86 @@ function paramNumericRanges({
}: { }: {
param: ParamDefinition; param: ParamDefinition;
}): Partial<Record<string, NumericSpec>> | undefined { }): Partial<Record<string, NumericSpec>> | undefined {
const range = getAnimationParamNumericRange({ param }); const range = getParamNumericRange({ param });
return range ? { value: range } : undefined; 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({ function buildGraphicParamDescriptor({
element, element,
paramKey, paramKey,
@ -70,27 +146,14 @@ function buildGraphicParamDescriptor({
return null; return null;
} }
return { return buildParamDescriptor({
kind: getAnimationParamValueKind({ param }), param,
defaultInterpolation: getAnimationParamDefaultInterpolation({ param }), baseParams: element.params,
numericRanges: paramNumericRanges({ param }), setParams: (params) => ({
coerceValue: ({ value }) => coerceAnimationParamValue({ param, value }), ...element,
getBaseValue: () => element.params[param.key] ?? param.default, params,
setBaseValue: ({ value }) => { }),
const coercedValue = coerceAnimationParamValue({ param, value }); });
if (coercedValue === null) {
return element;
}
return {
...element,
params: {
...element.params,
[param.key]: coercedValue,
},
};
},
};
} }
function buildEffectParamDescriptor({ function buildEffectParamDescriptor({
@ -118,35 +181,22 @@ function buildEffectParamDescriptor({
return null; return null;
} }
return { return buildParamDescriptor({
kind: getAnimationParamValueKind({ param }), param,
defaultInterpolation: getAnimationParamDefaultInterpolation({ param }), baseParams: effect.params,
numericRanges: paramNumericRanges({ param }), setParams: (params) => ({
coerceValue: ({ value }) => coerceAnimationParamValue({ param, value }), ...element,
getBaseValue: () => effect.params[param.key] ?? param.default, effects:
setBaseValue: ({ value }) => { element.effects?.map((candidate) =>
const coercedValue = coerceAnimationParamValue({ param, value }); candidate.id !== effectId
if (coercedValue === null) { ? candidate
return element; : {
} ...candidate,
params,
return { },
...element, ) ?? element.effects,
effects: }),
element.effects?.map((candidate) => });
candidate.id !== effectId
? candidate
: {
...candidate,
params: {
...candidate.params,
[param.key]: coercedValue,
},
},
) ?? element.effects,
};
},
};
} }
export function resolveAnimationTarget({ export function resolveAnimationTarget({
@ -156,41 +206,12 @@ export function resolveAnimationTarget({
element: TimelineElement; element: TimelineElement;
path: AnimationPath; path: AnimationPath;
}): AnimationPathDescriptor | null { }): AnimationPathDescriptor | null {
if (isAnimationPropertyPath(path)) { const elementParamTarget = buildElementParamDescriptor({
const propertyDefinition = getAnimationPropertyDefinition({ element,
propertyPath: path, paramKey: path,
}); });
if (!propertyDefinition.supportsElement({ element })) { if (elementParamTarget) {
return null; return elementParamTarget;
}
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 graphicParamTarget = parseGraphicParamPath({ propertyPath: path }); const graphicParamTarget = parseGraphicParamPath({ propertyPath: path });

View File

@ -87,8 +87,13 @@ export function buildSeparatedAudioElement({
trimStart: sourceElement.trimStart, trimStart: sourceElement.trimStart,
trimEnd: sourceElement.trimEnd, trimEnd: sourceElement.trimEnd,
sourceDuration: sourceElement.sourceDuration, sourceDuration: sourceElement.sourceDuration,
volume: sourceElement.volume ?? DEFAULTS.element.volume, params: {
muted: sourceElement.muted ?? false, volume:
typeof sourceElement.params.volume === "number"
? sourceElement.params.volume
: DEFAULTS.element.volume,
muted: sourceElement.params.muted === true,
},
retime: sourceElement.retime retime: sourceElement.retime
? { ? {
rate: sourceElement.retime.rate, rate: sourceElement.retime.rate,

View File

@ -21,6 +21,23 @@ export function dBToLinear(db: number): number {
return 10 ** (clampDb(db) / 20); 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({ export function hasAnimatedVolume({
element, element,
}: { }: {
@ -43,12 +60,12 @@ export function resolveEffectiveAudioGain({
trackMuted?: boolean; trackMuted?: boolean;
localTime: number; localTime: number;
}): number { }): number {
if (trackMuted || element.muted === true) { if (trackMuted || isElementMuted({ element })) {
return 0; return 0;
} }
const resolvedDb = resolveNumberAtTime({ const resolvedDb = resolveNumberAtTime({
baseValue: element.volume ?? 0, baseValue: getElementVolume({ element }),
animations: element.animations, animations: element.animations,
propertyPath: "volume", propertyPath: "volume",
localTime: Math.round(localTime * TICKS_PER_SECOND), 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 { useCallback, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useEditor } from "@/editor/use-editor"; import { useEditor } from "@/editor/use-editor";
import { DEFAULTS } from "@/timeline/defaults";
import { import {
getDbFromLinePos, getDbFromLinePos,
getLinePosFromDb, getLinePosFromDb,
} from "@/timeline/audio-display"; } from "@/timeline/audio-display";
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants"; 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 type { AudioElement } from "@/timeline/types";
import { import {
clamp, clamp,
@ -60,10 +59,8 @@ export function AudioVolumeLine({
const editor = useEditor(); const editor = useEditor();
const surfaceRef = useRef<HTMLDivElement>(null); const surfaceRef = useRef<HTMLDivElement>(null);
const activePointerIdRef = useRef<number | null>(null); const activePointerIdRef = useRef<number | null>(null);
const startVolumeRef = useRef(element.volume ?? DEFAULTS.element.volume); const startVolumeRef = useRef(getElementVolume({ element }));
const lastPreviewVolumeRef = useRef( const lastPreviewVolumeRef = useRef(getElementVolume({ element }));
element.volume ?? DEFAULTS.element.volume,
);
const hasChangedRef = useRef(false); const hasChangedRef = useRef(false);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [tooltipClientPos, setTooltipClientPos] = useState<{ const [tooltipClientPos, setTooltipClientPos] = useState<{
@ -72,7 +69,7 @@ export function AudioVolumeLine({
} | null>(null); } | null>(null);
const hasAnimatedEnvelope = hasAnimatedVolume({ element }); const hasAnimatedEnvelope = hasAnimatedVolume({ element });
const currentVolume = element.volume ?? DEFAULTS.element.volume; const currentVolume = getElementVolume({ element });
const lineTop = `${getLinePosFromDb({ db: currentVolume })}%`; const lineTop = `${getLinePosFromDb({ db: currentVolume })}%`;
const volumeLabel = `${formatNumberForDisplay({ const volumeLabel = `${formatNumberForDisplay({
@ -96,7 +93,7 @@ export function AudioVolumeLine({
{ {
trackId, trackId,
elementId: element.id, elementId: element.id,
updates: { volume: nextVolume }, updates: { params: { volume: nextVolume } },
}, },
], ],
}); });

View File

@ -47,7 +47,7 @@ import {
getSourceAudioActionLabel, getSourceAudioActionLabel,
isSourceAudioSeparated, isSourceAudioSeparated,
} from "@/timeline/audio-separation"; } from "@/timeline/audio-separation";
import { buildWaveformGainSamples } from "@/timeline/audio-state"; import { buildWaveformGainSamples, isElementMuted } from "@/timeline/audio-state";
import { getTimelinePixelsPerSecond } from "@/timeline"; import { getTimelinePixelsPerSecond } from "@/timeline";
import { buildWaveformSourceKey } from "@/media/waveform-summary"; import { buildWaveformSourceKey } from "@/media/waveform-summary";
import { addMediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm"; 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 = const canToggleCurrentSourceAudio =
selectedElements.length === 1 && selectedElements.length === 1 &&
isCurrentElementSelected && isCurrentElementSelected &&
@ -908,7 +908,9 @@ function TextElementContent({
}) { }) {
return ( return (
<div className="flex size-full items-center justify-start pl-2"> <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> </div>
); );
} }

View File

@ -385,7 +385,10 @@ export class DragDropController {
dragData: Extract<TimelineDragData, { type: "text" }>; dragData: Extract<TimelineDragData, { type: "text" }>;
}): void { }): void {
const element = buildTextElement({ const element = buildTextElement({
raw: { name: dragData.name ?? "", content: dragData.content ?? "" }, raw: {
name: dragData.name ?? "",
params: { content: dragData.content ?? "" },
},
startTime: target.xPosition, startTime: target.xPosition,
}); });
this.insertAtTarget({ element, target, trackType: "text" }); this.insertAtTarget({ element, target, trackType: "text" });

View File

@ -31,26 +31,36 @@ const defaultTextBackground = {
const defaultTextElement: Omit<TextElement, "id"> = { const defaultTextElement: Omit<TextElement, "id"> = {
type: "text", type: "text",
name: "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, duration: DEFAULT_NEW_ELEMENT_DURATION,
startTime: ZERO_MEDIA_TIME, startTime: ZERO_MEDIA_TIME,
trimStart: ZERO_MEDIA_TIME, trimStart: ZERO_MEDIA_TIME,
trimEnd: ZERO_MEDIA_TIME, trimEnd: ZERO_MEDIA_TIME,
transform: { params: {
...defaultTransform, content: "Default text",
position: { ...defaultTransform.position }, 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 = { const defaultTimelineViewState: TTimelineViewState = {

View File

@ -11,7 +11,6 @@ import {
type CreateStickerElement, type CreateStickerElement,
type CreateUploadAudioElement, type CreateUploadAudioElement,
type CreateLibraryAudioElement, type CreateLibraryAudioElement,
type TextBackground,
type TextElement, type TextElement,
type SceneTracks, type SceneTracks,
type TimelineElement, type TimelineElement,
@ -28,6 +27,10 @@ import type { MediaType } from "@/media/types";
import { buildDefaultEffectInstance } from "@/effects"; import { buildDefaultEffectInstance } from "@/effects";
import { buildDefaultGraphicInstance } from "@/graphics"; import { buildDefaultGraphicInstance } from "@/graphics";
import type { ParamValues } from "@/params"; import type { ParamValues } from "@/params";
import {
buildDefaultParamValues,
getBuiltInElementParams,
} from "@/params/registry";
import { capitalizeFirstLetter } from "@/utils/string"; import { capitalizeFirstLetter } from "@/utils/string";
import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
@ -87,20 +90,12 @@ export function requiresMediaId({
); );
} }
function buildTextBackground( function buildDefaultElementParams({
raw: Partial<TextBackground> | undefined, type,
): TextBackground { }: {
const color = raw?.color ?? DEFAULTS.text.element.background.color; type: TimelineElement["type"];
const enabled = raw?.enabled ?? color !== "transparent"; }): ParamValues {
return { return buildDefaultParamValues(getBuiltInElementParams({ type }));
enabled,
color,
cornerRadius: raw?.cornerRadius,
paddingX: raw?.paddingX,
paddingY: raw?.paddingY,
offsetX: raw?.offsetX,
offsetY: raw?.offsetY,
};
} }
export function buildTextElement({ export function buildTextElement({
@ -115,24 +110,14 @@ export function buildTextElement({
return { return {
type: "text", type: "text",
name: t.name ?? DEFAULTS.text.element.name, name: t.name ?? DEFAULTS.text.element.name,
content: t.content ?? DEFAULTS.text.element.content,
duration: t.duration ?? DEFAULT_NEW_ELEMENT_DURATION, duration: t.duration ?? DEFAULT_NEW_ELEMENT_DURATION,
startTime, startTime,
trimStart: ZERO_MEDIA_TIME, trimStart: ZERO_MEDIA_TIME,
trimEnd: ZERO_MEDIA_TIME, trimEnd: ZERO_MEDIA_TIME,
fontSize: t.fontSize ?? DEFAULTS.text.element.fontSize, params: {
fontFamily: t.fontFamily ?? DEFAULTS.text.element.fontFamily, ...buildDefaultElementParams({ type: "text" }),
color: t.color ?? DEFAULTS.text.element.color, ...(t.params ?? {}),
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,
}; };
} }
@ -183,12 +168,7 @@ export function buildStickerElement({
startTime, startTime,
trimStart: ZERO_MEDIA_TIME, trimStart: ZERO_MEDIA_TIME,
trimEnd: ZERO_MEDIA_TIME, trimEnd: ZERO_MEDIA_TIME,
transform: { params: buildDefaultElementParams({ type: "sticker" }),
...DEFAULTS.element.transform,
position: { ...DEFAULTS.element.transform.position },
},
opacity: DEFAULTS.element.opacity,
blendMode: DEFAULTS.element.blendMode,
}; };
} }
@ -208,20 +188,36 @@ export function buildGraphicElement({
type: "graphic", type: "graphic",
name: name ?? capitalizeFirstLetter({ string: instance.definitionId }), name: name ?? capitalizeFirstLetter({ string: instance.definitionId }),
definitionId: 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, duration: DEFAULT_NEW_ELEMENT_DURATION,
startTime, startTime,
trimStart: ZERO_MEDIA_TIME, trimStart: ZERO_MEDIA_TIME,
trimEnd: 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({ function buildVideoElement({
mediaId, mediaId,
name, name,
@ -242,16 +238,9 @@ function buildVideoElement({
trimStart: ZERO_MEDIA_TIME, trimStart: ZERO_MEDIA_TIME,
trimEnd: ZERO_MEDIA_TIME, trimEnd: ZERO_MEDIA_TIME,
sourceDuration: duration, sourceDuration: duration,
muted: false,
isSourceAudioEnabled: true, isSourceAudioEnabled: true,
hidden: false, hidden: false,
transform: { params: buildDefaultElementParams({ type: "video" }),
...DEFAULTS.element.transform,
position: { ...DEFAULTS.element.transform.position },
},
opacity: DEFAULTS.element.opacity,
blendMode: DEFAULTS.element.blendMode,
volume: DEFAULTS.element.volume,
}; };
} }
@ -275,12 +264,7 @@ function buildImageElement({
trimStart: ZERO_MEDIA_TIME, trimStart: ZERO_MEDIA_TIME,
trimEnd: ZERO_MEDIA_TIME, trimEnd: ZERO_MEDIA_TIME,
hidden: false, hidden: false,
transform: { params: buildDefaultElementParams({ type: "image" }),
...DEFAULTS.element.transform,
position: { ...DEFAULTS.element.transform.position },
},
opacity: DEFAULTS.element.opacity,
blendMode: DEFAULTS.element.blendMode,
}; };
} }
@ -307,8 +291,7 @@ function buildUploadAudioElement({
trimStart: ZERO_MEDIA_TIME, trimStart: ZERO_MEDIA_TIME,
trimEnd: ZERO_MEDIA_TIME, trimEnd: ZERO_MEDIA_TIME,
sourceDuration: duration, sourceDuration: duration,
volume: DEFAULTS.element.volume, params: buildDefaultElementParams({ type: "audio" }),
muted: false,
}; };
if (buffer) { if (buffer) {
element.buffer = buffer; element.buffer = buffer;
@ -370,8 +353,7 @@ export function buildLibraryAudioElement({
trimStart: ZERO_MEDIA_TIME, trimStart: ZERO_MEDIA_TIME,
trimEnd: ZERO_MEDIA_TIME, trimEnd: ZERO_MEDIA_TIME,
sourceDuration: duration, sourceDuration: duration,
volume: DEFAULTS.element.volume, params: buildDefaultElementParams({ type: "audio" }),
muted: false,
}; };
if (buffer) { if (buffer) {
element.buffer = buffer; element.buffer = buffer;
@ -411,8 +393,8 @@ export function getElementFontFamilies({
const families = new Set<string>(); const families = new Set<string>();
for (const track of [...tracks.overlay, tracks.main, ...tracks.audio]) { for (const track of [...tracks.overlay, tracks.main, ...tracks.audio]) {
for (const element of track.elements) { for (const element of track.elements) {
if (element.type === "text" && element.fontFamily) { if (element.type === "text" && typeof element.params.fontFamily === "string") {
families.add(element.fontFamily); families.add(element.params.fontFamily);
} }
if ("masks" in element) { if ("masks" in element) {
for (const mask of element.masks ?? []) { for (const mask of element.masks ?? []) {

View File

@ -72,7 +72,7 @@ function buildElement({
duration: mediaTime({ ticks: duration }), duration: mediaTime({ ticks: duration }),
trimStart: ZERO_MEDIA_TIME, trimStart: ZERO_MEDIA_TIME,
trimEnd: ZERO_MEDIA_TIME, trimEnd: ZERO_MEDIA_TIME,
volume: 1, params: { volume: 1, muted: false },
sourceType: "upload", sourceType: "upload",
mediaId: `media-${id}`, mediaId: `media-${id}`,
} satisfies AudioElement; } satisfies AudioElement;
@ -86,9 +86,14 @@ function buildElement({
trimStart: ZERO_MEDIA_TIME, trimStart: ZERO_MEDIA_TIME,
trimEnd: ZERO_MEDIA_TIME, trimEnd: ZERO_MEDIA_TIME,
definitionId: `graphic-${id}`, definitionId: `graphic-${id}`,
params: {}, params: {
transform: buildTransform(), "transform.positionX": 0,
opacity: 1, "transform.positionY": 0,
"transform.scaleX": 1,
"transform.scaleY": 1,
"transform.rotate": 0,
opacity: 1,
},
} satisfies GraphicElement; } satisfies GraphicElement;
case "text": case "text":
return { return {
@ -99,20 +104,24 @@ function buildElement({
duration: mediaTime({ ticks: duration }), duration: mediaTime({ ticks: duration }),
trimStart: ZERO_MEDIA_TIME, trimStart: ZERO_MEDIA_TIME,
trimEnd: ZERO_MEDIA_TIME, trimEnd: ZERO_MEDIA_TIME,
content: id, params: {
fontSize: 32, content: id,
fontFamily: "sans-serif", fontSize: 32,
color: "#ffffff", fontFamily: "sans-serif",
background: { color: "#ffffff",
enabled: false, "background.enabled": false,
color: "#000000", "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; } satisfies TextElement;
case "video": case "video":
return { return {
@ -124,8 +133,14 @@ function buildElement({
trimStart: ZERO_MEDIA_TIME, trimStart: ZERO_MEDIA_TIME,
trimEnd: ZERO_MEDIA_TIME, trimEnd: ZERO_MEDIA_TIME,
mediaId: `media-${id}`, mediaId: `media-${id}`,
transform: buildTransform(), params: {
opacity: 1, "transform.positionX": 0,
"transform.positionY": 0,
"transform.scaleX": 1,
"transform.scaleY": 1,
"transform.rotate": 0,
opacity: 1,
},
} satisfies VideoElement; } satisfies VideoElement;
} }

View File

@ -2,7 +2,6 @@ import type { ElementAnimations } from "@/animation/types";
import type { Effect } from "@/effects/types"; import type { Effect } from "@/effects/types";
import type { Mask } from "@/masks/types"; import type { Mask } from "@/masks/types";
import type { ParamValues } from "@/params"; import type { ParamValues } from "@/params";
import type { BlendMode, Transform } from "@/rendering";
import type { MediaTime } from "@/wasm"; import type { MediaTime } from "@/wasm";
export type ElementRef = { export type ElementRef = {
@ -87,8 +86,6 @@ export interface RetimeConfig {
interface BaseAudioElement extends BaseTimelineElement { interface BaseAudioElement extends BaseTimelineElement {
type: "audio"; type: "audio";
volume: number;
muted?: boolean;
buffer?: AudioBuffer; buffer?: AudioBuffer;
retime?: RetimeConfig; retime?: RetimeConfig;
} }
@ -114,6 +111,7 @@ interface BaseTimelineElement {
trimEnd: MediaTime; trimEnd: MediaTime;
sourceDuration?: MediaTime; sourceDuration?: MediaTime;
animations?: ElementAnimations; animations?: ElementAnimations;
params: ParamValues;
} }
export interface VideoElement extends BaseTimelineElement { export interface VideoElement extends BaseTimelineElement {
@ -124,9 +122,6 @@ export interface VideoElement extends BaseTimelineElement {
isSourceAudioEnabled?: boolean; isSourceAudioEnabled?: boolean;
hidden?: boolean; hidden?: boolean;
retime?: RetimeConfig; retime?: RetimeConfig;
transform: Transform;
opacity: number;
blendMode?: BlendMode;
effects?: Effect[]; effects?: Effect[];
masks?: Mask[]; masks?: Mask[];
} }
@ -135,9 +130,6 @@ export interface ImageElement extends BaseTimelineElement {
type: "image"; type: "image";
mediaId: string; mediaId: string;
hidden?: boolean; hidden?: boolean;
transform: Transform;
opacity: number;
blendMode?: BlendMode;
effects?: Effect[]; effects?: Effect[];
masks?: Mask[]; masks?: Mask[];
} }
@ -154,21 +146,7 @@ export interface TextBackground {
export interface TextElement extends BaseTimelineElement { export interface TextElement extends BaseTimelineElement {
type: "text"; 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; hidden?: boolean;
transform: Transform;
opacity: number;
blendMode?: BlendMode;
effects?: Effect[]; effects?: Effect[];
} }
@ -179,20 +157,13 @@ export interface StickerElement extends BaseTimelineElement {
intrinsicWidth?: number; intrinsicWidth?: number;
intrinsicHeight?: number; intrinsicHeight?: number;
hidden?: boolean; hidden?: boolean;
transform: Transform;
opacity: number;
blendMode?: BlendMode;
effects?: Effect[]; effects?: Effect[];
} }
export interface GraphicElement extends BaseTimelineElement { export interface GraphicElement extends BaseTimelineElement {
type: "graphic"; type: "graphic";
definitionId: string; definitionId: string;
params: ParamValues;
hidden?: boolean; hidden?: boolean;
transform: Transform;
opacity: number;
blendMode?: BlendMode;
effects?: Effect[]; effects?: Effect[];
masks?: Mask[]; masks?: Mask[];
} }
@ -200,13 +171,9 @@ export interface GraphicElement extends BaseTimelineElement {
export interface EffectElement extends BaseTimelineElement { export interface EffectElement extends BaseTimelineElement {
type: "effect"; type: "effect";
effectType: string; effectType: string;
params: ParamValues;
} }
export type ElementUpdatePatch = export type ElementUpdatePatch = { params?: Partial<ParamValues> };
| { transform: Transform }
| { opacity: number }
| { volume: number };
export type TimelineElement = export type TimelineElement =
| AudioElement | AudioElement

View File

@ -141,7 +141,14 @@ export function applyElementUpdate({
patch: Partial<TimelineElement>; patch: Partial<TimelineElement>;
context: ElementUpdateContext; context: ElementUpdateContext;
}): TimelineElement { }): TimelineElement {
let nextElement = { ...element, ...patch } as TimelineElement; let nextElement = {
...element,
...patch,
params: {
...element.params,
...(patch.params ?? {}),
},
} as TimelineElement;
const changedFields = new Set( const changedFields = new Set(
Object.keys(patch) as ElementUpdateField[], Object.keys(patch) as ElementUpdateField[],
); );