refactor: unify element params
This commit is contained in:
parent
a9b9cf6bf5
commit
2ee0a44735
|
|
@ -1,15 +1,15 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
coerceAnimationParamValue,
|
||||
getAnimationParamDefaultInterpolation,
|
||||
getAnimationParamNumericRange,
|
||||
getAnimationParamValueKind,
|
||||
} from "@/animation/animated-params";
|
||||
coerceParamValue,
|
||||
getParamDefaultInterpolation,
|
||||
getParamNumericRange,
|
||||
getParamValueKind,
|
||||
} from "@/params";
|
||||
|
||||
describe("animated params", () => {
|
||||
test("snaps and clamps number params", () => {
|
||||
expect(
|
||||
coerceAnimationParamValue({
|
||||
coerceParamValue({
|
||||
param: {
|
||||
key: "intensity",
|
||||
label: "Intensity",
|
||||
|
|
@ -24,7 +24,7 @@ describe("animated params", () => {
|
|||
).toBe(0.5);
|
||||
|
||||
expect(
|
||||
coerceAnimationParamValue({
|
||||
coerceParamValue({
|
||||
param: {
|
||||
key: "intensity",
|
||||
label: "Intensity",
|
||||
|
|
@ -49,14 +49,14 @@ describe("animated params", () => {
|
|||
max: 1,
|
||||
step: 0.25,
|
||||
};
|
||||
expect(coerceAnimationParamValue({ param, value: Number.NaN })).toBeNull();
|
||||
expect(coerceAnimationParamValue({ param, value: "0.5" })).toBeNull();
|
||||
expect(coerceAnimationParamValue({ param, value: true })).toBeNull();
|
||||
expect(coerceParamValue({ param, value: Number.NaN })).toBeNull();
|
||||
expect(coerceParamValue({ param, value: "0.5" })).toBeNull();
|
||||
expect(coerceParamValue({ param, value: true })).toBeNull();
|
||||
});
|
||||
|
||||
test("passthrough with step <= 0 guard", () => {
|
||||
expect(
|
||||
coerceAnimationParamValue({
|
||||
coerceParamValue({
|
||||
param: {
|
||||
key: "x",
|
||||
label: "X",
|
||||
|
|
@ -81,13 +81,13 @@ describe("animated params", () => {
|
|||
{ value: "multiply", label: "Multiply" },
|
||||
],
|
||||
};
|
||||
expect(coerceAnimationParamValue({ param, value: "normal" })).toBe("normal");
|
||||
expect(coerceAnimationParamValue({ param, value: "multiply" })).toBe("multiply");
|
||||
expect(coerceParamValue({ param, value: "normal" })).toBe("normal");
|
||||
expect(coerceParamValue({ param, value: "multiply" })).toBe("multiply");
|
||||
});
|
||||
|
||||
test("rejects select values outside the allowed options", () => {
|
||||
expect(
|
||||
coerceAnimationParamValue({
|
||||
coerceParamValue({
|
||||
param: {
|
||||
key: "blend",
|
||||
label: "Blend",
|
||||
|
|
@ -111,9 +111,9 @@ describe("animated params", () => {
|
|||
default: "normal",
|
||||
options: [{ value: "normal", label: "Normal" }],
|
||||
};
|
||||
expect(coerceAnimationParamValue({ param, value: 42 })).toBeNull();
|
||||
expect(coerceAnimationParamValue({ param, value: null })).toBeNull();
|
||||
expect(coerceAnimationParamValue({ param, value: undefined })).toBeNull();
|
||||
expect(coerceParamValue({ param, value: 42 })).toBeNull();
|
||||
expect(coerceParamValue({ param, value: null })).toBeNull();
|
||||
expect(coerceParamValue({ param, value: undefined })).toBeNull();
|
||||
});
|
||||
|
||||
test("boolean params accept booleans and reject other types", () => {
|
||||
|
|
@ -123,10 +123,10 @@ describe("animated params", () => {
|
|||
type: "boolean" as const,
|
||||
default: true,
|
||||
};
|
||||
expect(coerceAnimationParamValue({ param, value: true })).toBe(true);
|
||||
expect(coerceAnimationParamValue({ param, value: false })).toBe(false);
|
||||
expect(coerceAnimationParamValue({ param, value: 1 })).toBeNull();
|
||||
expect(coerceAnimationParamValue({ param, value: "true" })).toBeNull();
|
||||
expect(coerceParamValue({ param, value: true })).toBe(true);
|
||||
expect(coerceParamValue({ param, value: false })).toBe(false);
|
||||
expect(coerceParamValue({ param, value: 1 })).toBeNull();
|
||||
expect(coerceParamValue({ param, value: "true" })).toBeNull();
|
||||
});
|
||||
|
||||
test("color params accept strings and reject other types", () => {
|
||||
|
|
@ -136,14 +136,14 @@ describe("animated params", () => {
|
|||
type: "color" as const,
|
||||
default: "#ffffff",
|
||||
};
|
||||
expect(coerceAnimationParamValue({ param, value: "#ff0000" })).toBe("#ff0000");
|
||||
expect(coerceAnimationParamValue({ param, value: 0xff0000 })).toBeNull();
|
||||
expect(coerceAnimationParamValue({ param, value: null })).toBeNull();
|
||||
expect(coerceParamValue({ param, value: "#ff0000" })).toBe("#ff0000");
|
||||
expect(coerceParamValue({ param, value: 0xff0000 })).toBeNull();
|
||||
expect(coerceParamValue({ param, value: null })).toBeNull();
|
||||
});
|
||||
|
||||
test("getAnimationParamValueKind maps param type to binding kind", () => {
|
||||
expect(
|
||||
getAnimationParamValueKind({
|
||||
getParamValueKind({
|
||||
param: {
|
||||
key: "n",
|
||||
label: "N",
|
||||
|
|
@ -155,17 +155,17 @@ describe("animated params", () => {
|
|||
}),
|
||||
).toBe("number");
|
||||
expect(
|
||||
getAnimationParamValueKind({
|
||||
getParamValueKind({
|
||||
param: { key: "c", label: "C", type: "color", default: "#fff" },
|
||||
}),
|
||||
).toBe("color");
|
||||
expect(
|
||||
getAnimationParamValueKind({
|
||||
getParamValueKind({
|
||||
param: { key: "b", label: "B", type: "boolean", default: false },
|
||||
}),
|
||||
).toBe("discrete");
|
||||
expect(
|
||||
getAnimationParamValueKind({
|
||||
getParamValueKind({
|
||||
param: {
|
||||
key: "s",
|
||||
label: "S",
|
||||
|
|
@ -179,7 +179,7 @@ describe("animated params", () => {
|
|||
|
||||
test("getAnimationParamDefaultInterpolation is linear for continuous, hold for discrete", () => {
|
||||
expect(
|
||||
getAnimationParamDefaultInterpolation({
|
||||
getParamDefaultInterpolation({
|
||||
param: {
|
||||
key: "n",
|
||||
label: "N",
|
||||
|
|
@ -191,17 +191,17 @@ describe("animated params", () => {
|
|||
}),
|
||||
).toBe("linear");
|
||||
expect(
|
||||
getAnimationParamDefaultInterpolation({
|
||||
getParamDefaultInterpolation({
|
||||
param: { key: "c", label: "C", type: "color", default: "#fff" },
|
||||
}),
|
||||
).toBe("linear");
|
||||
expect(
|
||||
getAnimationParamDefaultInterpolation({
|
||||
getParamDefaultInterpolation({
|
||||
param: { key: "b", label: "B", type: "boolean", default: false },
|
||||
}),
|
||||
).toBe("hold");
|
||||
expect(
|
||||
getAnimationParamDefaultInterpolation({
|
||||
getParamDefaultInterpolation({
|
||||
param: {
|
||||
key: "s",
|
||||
label: "S",
|
||||
|
|
@ -215,7 +215,7 @@ describe("animated params", () => {
|
|||
|
||||
test("getAnimationParamNumericRange returns spec for number params, undefined otherwise", () => {
|
||||
expect(
|
||||
getAnimationParamNumericRange({
|
||||
getParamNumericRange({
|
||||
param: {
|
||||
key: "intensity",
|
||||
label: "Intensity",
|
||||
|
|
@ -228,12 +228,12 @@ describe("animated params", () => {
|
|||
}),
|
||||
).toEqual({ min: 0, max: 1, step: 0.1 });
|
||||
expect(
|
||||
getAnimationParamNumericRange({
|
||||
getParamNumericRange({
|
||||
param: { key: "c", label: "C", type: "color", default: "#fff" },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getAnimationParamNumericRange({
|
||||
getParamNumericRange({
|
||||
param: { key: "b", label: "B", type: "boolean", default: false },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -16,7 +16,6 @@ export {
|
|||
setChannel,
|
||||
splitAnimationsAtTime,
|
||||
updateScalarKeyframeCurve,
|
||||
upsertElementKeyframe,
|
||||
upsertPathKeyframe,
|
||||
} from "./keyframes";
|
||||
|
||||
|
|
@ -75,13 +74,4 @@ export {
|
|||
isAnimationPropertyPath,
|
||||
} from "./path";
|
||||
|
||||
export {
|
||||
coerceAnimationValueForProperty,
|
||||
getAnimationPropertyDefinition,
|
||||
getDefaultInterpolationForProperty,
|
||||
getElementBaseValueForProperty,
|
||||
supportsAnimationProperty,
|
||||
type AnimationPropertyDefinition,
|
||||
type NumericSpec,
|
||||
withElementBaseValueForProperty,
|
||||
} from "./property-registry";
|
||||
export type { NumericSpec } from "./types";
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import type {
|
|||
AnimationChannel,
|
||||
AnimationInterpolation,
|
||||
AnimationPath,
|
||||
AnimationPropertyPath,
|
||||
AnimationValue,
|
||||
DiscreteAnimationChannel,
|
||||
DiscreteAnimationKey,
|
||||
|
|
@ -20,7 +19,6 @@ import {
|
|||
decomposeAnimationValue,
|
||||
} from "./binding-values";
|
||||
import {
|
||||
getBezierPoint,
|
||||
getDefaultLeftHandle,
|
||||
getDefaultRightHandle,
|
||||
solveBezierProgressForTime,
|
||||
|
|
@ -30,10 +28,6 @@ import {
|
|||
getScalarSegmentInterpolation,
|
||||
normalizeChannel,
|
||||
} from "./interpolation";
|
||||
import {
|
||||
coerceAnimationValueForProperty,
|
||||
getAnimationPropertyDefinition,
|
||||
} from "./property-registry";
|
||||
import {
|
||||
type MediaTime,
|
||||
roundMediaTime,
|
||||
|
|
@ -533,47 +527,6 @@ export function upsertPathKeyframe({
|
|||
});
|
||||
}
|
||||
|
||||
export function upsertElementKeyframe({
|
||||
animations,
|
||||
propertyPath,
|
||||
time,
|
||||
value,
|
||||
interpolation,
|
||||
keyframeId,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
time: MediaTime;
|
||||
value: AnimationValue;
|
||||
interpolation?: AnimationInterpolation;
|
||||
keyframeId?: string;
|
||||
}): ElementAnimations | undefined {
|
||||
const coercedValue = coerceAnimationValueForProperty({
|
||||
propertyPath,
|
||||
value,
|
||||
});
|
||||
if (coercedValue === null) {
|
||||
return animations;
|
||||
}
|
||||
|
||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||
return upsertPathKeyframe({
|
||||
animations,
|
||||
propertyPath,
|
||||
time,
|
||||
value: coercedValue,
|
||||
interpolation,
|
||||
keyframeId,
|
||||
kind: propertyDefinition.kind,
|
||||
defaultInterpolation: propertyDefinition.defaultInterpolation,
|
||||
coerceValue: ({ value: nextValue }) =>
|
||||
coerceAnimationValueForProperty({
|
||||
propertyPath,
|
||||
value: nextValue,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function upsertKeyframe({
|
||||
channel,
|
||||
time,
|
||||
|
|
|
|||
|
|
@ -3,27 +3,22 @@ import type {
|
|||
AnimationInterpolation,
|
||||
AnimationPropertyPath,
|
||||
AnimationValue,
|
||||
NumericSpec,
|
||||
} from "@/animation/types";
|
||||
import { parseColorToLinearRgba } from "./binding-values";
|
||||
import type { TimelineElement } from "@/timeline";
|
||||
import { MIN_TRANSFORM_SCALE } from "@/animation/transform";
|
||||
import {
|
||||
CORNER_RADIUS_MAX,
|
||||
CORNER_RADIUS_MIN,
|
||||
} from "@/text/background";
|
||||
coerceParamValue,
|
||||
getParamDefaultInterpolation,
|
||||
getParamNumericRange,
|
||||
getParamValueKind,
|
||||
type ParamDefinition,
|
||||
} from "@/params";
|
||||
import {
|
||||
canElementHaveAudio,
|
||||
isVisualElement,
|
||||
} from "@/timeline/element-utils";
|
||||
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
|
||||
import { DEFAULTS } from "@/timeline/defaults";
|
||||
import { snapToStep } from "@/utils/math";
|
||||
|
||||
export interface NumericSpec {
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
}
|
||||
getBuiltInElementParams,
|
||||
getElementParam,
|
||||
readElementParamValue,
|
||||
writeElementParamValue,
|
||||
} from "@/params/registry";
|
||||
import type { ElementType, TimelineElement } from "@/timeline";
|
||||
|
||||
export interface AnimationPropertyDefinition {
|
||||
kind: AnimationBindingKind;
|
||||
|
|
@ -32,10 +27,6 @@ export interface AnimationPropertyDefinition {
|
|||
supportsElement: ({ element }: { element: TimelineElement }) => boolean;
|
||||
getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null;
|
||||
coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null;
|
||||
// Apply `value` to `element` for this property. Coerces the value through
|
||||
// `coerceValue` and verifies element support; returns `element` unchanged
|
||||
// if either fails. Cannot be bypassed — there is no kind-narrow `setValue`
|
||||
// on the public surface, so callers can't apply an unvalidated value.
|
||||
applyValue: ({
|
||||
element,
|
||||
value,
|
||||
|
|
@ -45,323 +36,98 @@ export interface AnimationPropertyDefinition {
|
|||
}) => TimelineElement;
|
||||
}
|
||||
|
||||
function applyNumericSpec({
|
||||
value,
|
||||
numericRange,
|
||||
function getFallbackParam({
|
||||
propertyPath,
|
||||
}: {
|
||||
value: number;
|
||||
numericRange: NumericSpec | undefined;
|
||||
}): number {
|
||||
if (!numericRange) {
|
||||
return value;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
}): ParamDefinition | null {
|
||||
const elementTypes: ElementType[] = [
|
||||
"video",
|
||||
"image",
|
||||
"text",
|
||||
"sticker",
|
||||
"graphic",
|
||||
"audio",
|
||||
];
|
||||
for (const type of elementTypes) {
|
||||
const param =
|
||||
getBuiltInElementParams({ type }).find(
|
||||
(candidate) => candidate.key === propertyPath,
|
||||
) ?? null;
|
||||
if (param) {
|
||||
return param;
|
||||
}
|
||||
}
|
||||
|
||||
const steppedValue =
|
||||
numericRange.step != null
|
||||
? snapToStep({ value, step: numericRange.step })
|
||||
: value;
|
||||
const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY;
|
||||
const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY;
|
||||
return Math.min(maxValue, Math.max(minValue, steppedValue));
|
||||
return null;
|
||||
}
|
||||
|
||||
function coerceNumberValue({
|
||||
value,
|
||||
numericRange,
|
||||
function buildDefinition({
|
||||
propertyPath,
|
||||
element,
|
||||
}: {
|
||||
value: AnimationValue;
|
||||
numericRange?: NumericSpec;
|
||||
}): number | null {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||
propertyPath: AnimationPropertyPath;
|
||||
element?: TimelineElement;
|
||||
}): AnimationPropertyDefinition | null {
|
||||
const param = element
|
||||
? getElementParam({ element, key: propertyPath })
|
||||
: getFallbackParam({ propertyPath });
|
||||
if (!param || param.keyframable === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return applyNumericSpec({ value, numericRange });
|
||||
}
|
||||
const range = getParamNumericRange({ param });
|
||||
|
||||
function coerceColorValue({
|
||||
value,
|
||||
}: {
|
||||
value: AnimationValue;
|
||||
}): string | null {
|
||||
return typeof value === "string" && parseColorToLinearRgba({ color: value })
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
function createNumberPropertyDefinition({
|
||||
numericRange,
|
||||
supportsElement,
|
||||
getValue,
|
||||
setValue,
|
||||
}: {
|
||||
numericRange?: NumericSpec;
|
||||
supportsElement: AnimationPropertyDefinition["supportsElement"];
|
||||
getValue: AnimationPropertyDefinition["getValue"];
|
||||
setValue: ({
|
||||
element,
|
||||
value,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
value: number;
|
||||
}) => TimelineElement;
|
||||
}): AnimationPropertyDefinition {
|
||||
return {
|
||||
kind: "number",
|
||||
defaultInterpolation: "linear",
|
||||
numericRanges: numericRange ? { value: numericRange } : undefined,
|
||||
supportsElement,
|
||||
getValue,
|
||||
coerceValue: ({ value }) => coerceNumberValue({ value, numericRange }),
|
||||
applyValue: ({ element, value }) => {
|
||||
if (!supportsElement({ element })) {
|
||||
return element;
|
||||
kind: getParamValueKind({ param }),
|
||||
defaultInterpolation: getParamDefaultInterpolation({ param }),
|
||||
numericRanges: range ? { value: range } : undefined,
|
||||
supportsElement: ({ element: candidate }) =>
|
||||
getElementParam({ element: candidate, key: propertyPath }) !== null,
|
||||
getValue: ({ element: candidate }) =>
|
||||
readElementParamValue({ element: candidate, param }),
|
||||
coerceValue: ({ value }) => coerceParamValue({ param, value }),
|
||||
applyValue: ({ element: candidate, value }) => {
|
||||
const targetParam = getElementParam({
|
||||
element: candidate,
|
||||
key: propertyPath,
|
||||
});
|
||||
if (!targetParam) {
|
||||
return candidate;
|
||||
}
|
||||
const coerced = coerceNumberValue({ value, numericRange });
|
||||
if (coerced === null) {
|
||||
return element;
|
||||
const coercedValue = coerceParamValue({
|
||||
param: targetParam,
|
||||
value,
|
||||
});
|
||||
if (coercedValue === null) {
|
||||
return candidate;
|
||||
}
|
||||
return setValue({ element, value: coerced });
|
||||
return writeElementParamValue({
|
||||
element: candidate,
|
||||
param: targetParam,
|
||||
value: coercedValue,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createColorPropertyDefinition({
|
||||
supportsElement,
|
||||
getValue,
|
||||
setValue,
|
||||
}: {
|
||||
supportsElement: AnimationPropertyDefinition["supportsElement"];
|
||||
getValue: AnimationPropertyDefinition["getValue"];
|
||||
setValue: ({
|
||||
element,
|
||||
value,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
value: string;
|
||||
}) => TimelineElement;
|
||||
}): AnimationPropertyDefinition {
|
||||
return {
|
||||
kind: "color",
|
||||
defaultInterpolation: "linear",
|
||||
supportsElement,
|
||||
getValue,
|
||||
coerceValue: ({ value }) => coerceColorValue({ value }),
|
||||
applyValue: ({ element, value }) => {
|
||||
if (!supportsElement({ element })) {
|
||||
return element;
|
||||
}
|
||||
const coerced = coerceColorValue({ value });
|
||||
if (coerced === null) {
|
||||
return element;
|
||||
}
|
||||
return setValue({ element, value: coerced });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const ANIMATION_PROPERTY_REGISTRY: Record<
|
||||
AnimationPropertyPath,
|
||||
AnimationPropertyDefinition
|
||||
> = {
|
||||
"transform.positionX": createNumberPropertyDefinition({
|
||||
numericRange: { step: 1 },
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.transform.position.x : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? {
|
||||
...element,
|
||||
transform: {
|
||||
...element.transform,
|
||||
position: { ...element.transform.position, x: value },
|
||||
},
|
||||
}
|
||||
: element,
|
||||
}),
|
||||
"transform.positionY": createNumberPropertyDefinition({
|
||||
numericRange: { step: 1 },
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.transform.position.y : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? {
|
||||
...element,
|
||||
transform: {
|
||||
...element.transform,
|
||||
position: { ...element.transform.position, y: value },
|
||||
},
|
||||
}
|
||||
: element,
|
||||
}),
|
||||
"transform.scaleX": createNumberPropertyDefinition({
|
||||
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.transform.scaleX : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? {
|
||||
...element,
|
||||
transform: { ...element.transform, scaleX: value },
|
||||
}
|
||||
: element,
|
||||
}),
|
||||
"transform.scaleY": createNumberPropertyDefinition({
|
||||
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.transform.scaleY : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? {
|
||||
...element,
|
||||
transform: { ...element.transform, scaleY: value },
|
||||
}
|
||||
: element,
|
||||
}),
|
||||
"transform.rotate": createNumberPropertyDefinition({
|
||||
numericRange: { min: -360, max: 360, step: 1 },
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.transform.rotate : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? {
|
||||
...element,
|
||||
transform: { ...element.transform, rotate: value },
|
||||
}
|
||||
: element,
|
||||
}),
|
||||
opacity: createNumberPropertyDefinition({
|
||||
numericRange: { min: 0, max: 1, step: 0.01 },
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.opacity : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element) ? { ...element, opacity: value } : element,
|
||||
}),
|
||||
volume: createNumberPropertyDefinition({
|
||||
numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 },
|
||||
supportsElement: ({ element }) => canElementHaveAudio(element),
|
||||
getValue: ({ element }) =>
|
||||
canElementHaveAudio(element) ? element.volume ?? 0 : null,
|
||||
setValue: ({ element, value }) =>
|
||||
canElementHaveAudio(element) ? { ...element, volume: value } : element,
|
||||
}),
|
||||
color: createColorPropertyDefinition({
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) => (element.type === "text" ? element.color : null),
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text" ? { ...element, color: value } : element,
|
||||
}),
|
||||
"background.color": createColorPropertyDefinition({
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text" ? element.background.color : null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? {
|
||||
...element,
|
||||
background: { ...element.background, color: value },
|
||||
}
|
||||
: element,
|
||||
}),
|
||||
"background.paddingX": createNumberPropertyDefinition({
|
||||
numericRange: { min: 0, step: 1 },
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text"
|
||||
? (element.background.paddingX ?? DEFAULTS.text.background.paddingX)
|
||||
: null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? {
|
||||
...element,
|
||||
background: { ...element.background, paddingX: value },
|
||||
}
|
||||
: element,
|
||||
}),
|
||||
"background.paddingY": createNumberPropertyDefinition({
|
||||
numericRange: { min: 0, step: 1 },
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text"
|
||||
? (element.background.paddingY ?? DEFAULTS.text.background.paddingY)
|
||||
: null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? {
|
||||
...element,
|
||||
background: { ...element.background, paddingY: value },
|
||||
}
|
||||
: element,
|
||||
}),
|
||||
"background.offsetX": createNumberPropertyDefinition({
|
||||
numericRange: { step: 1 },
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text"
|
||||
? (element.background.offsetX ?? DEFAULTS.text.background.offsetX)
|
||||
: null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? {
|
||||
...element,
|
||||
background: { ...element.background, offsetX: value },
|
||||
}
|
||||
: element,
|
||||
}),
|
||||
"background.offsetY": createNumberPropertyDefinition({
|
||||
numericRange: { step: 1 },
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text"
|
||||
? (element.background.offsetY ?? DEFAULTS.text.background.offsetY)
|
||||
: null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? {
|
||||
...element,
|
||||
background: { ...element.background, offsetY: value },
|
||||
}
|
||||
: element,
|
||||
}),
|
||||
"background.cornerRadius": createNumberPropertyDefinition({
|
||||
numericRange: {
|
||||
min: CORNER_RADIUS_MIN,
|
||||
max: CORNER_RADIUS_MAX,
|
||||
step: 1,
|
||||
},
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text"
|
||||
? (element.background.cornerRadius ?? CORNER_RADIUS_MIN)
|
||||
: null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? {
|
||||
...element,
|
||||
background: { ...element.background, cornerRadius: value },
|
||||
}
|
||||
: element,
|
||||
}),
|
||||
};
|
||||
|
||||
export function isAnimationPropertyPath(
|
||||
propertyPath: string,
|
||||
): propertyPath is AnimationPropertyPath {
|
||||
return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath);
|
||||
return !propertyPath.startsWith("params.") && !propertyPath.startsWith("effects.");
|
||||
}
|
||||
|
||||
export function getAnimationPropertyDefinition({
|
||||
propertyPath,
|
||||
element,
|
||||
}: {
|
||||
propertyPath: AnimationPropertyPath;
|
||||
element?: TimelineElement;
|
||||
}): AnimationPropertyDefinition {
|
||||
return ANIMATION_PROPERTY_REGISTRY[propertyPath];
|
||||
const definition = buildDefinition({ propertyPath, element });
|
||||
if (!definition) {
|
||||
throw new Error(`Unknown animation property for element: ${propertyPath}`);
|
||||
}
|
||||
return definition;
|
||||
}
|
||||
|
||||
export function supportsAnimationProperty({
|
||||
|
|
@ -371,8 +137,7 @@ export function supportsAnimationProperty({
|
|||
element: TimelineElement;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
}): boolean {
|
||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||
return propertyDefinition.supportsElement({ element });
|
||||
return getElementParam({ element, key: propertyPath }) !== null;
|
||||
}
|
||||
|
||||
export function getElementBaseValueForProperty({
|
||||
|
|
@ -382,11 +147,11 @@ export function getElementBaseValueForProperty({
|
|||
element: TimelineElement;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
}): AnimationValue | null {
|
||||
const definition = getAnimationPropertyDefinition({ propertyPath });
|
||||
if (!definition.supportsElement({ element })) {
|
||||
const param = getElementParam({ element, key: propertyPath });
|
||||
if (!param) {
|
||||
return null;
|
||||
}
|
||||
return definition.getValue({ element });
|
||||
return readElementParamValue({ element, param });
|
||||
}
|
||||
|
||||
export function withElementBaseValueForProperty({
|
||||
|
|
@ -398,26 +163,38 @@ export function withElementBaseValueForProperty({
|
|||
propertyPath: AnimationPropertyPath;
|
||||
value: AnimationValue;
|
||||
}): TimelineElement {
|
||||
const definition = getAnimationPropertyDefinition({ propertyPath });
|
||||
return definition.applyValue({ element, value });
|
||||
const param = getElementParam({ element, key: propertyPath });
|
||||
if (!param) {
|
||||
return element;
|
||||
}
|
||||
const coercedValue = coerceParamValue({ param, value });
|
||||
if (coercedValue === null) {
|
||||
return element;
|
||||
}
|
||||
return writeElementParamValue({ element, param, value: coercedValue });
|
||||
}
|
||||
|
||||
export function getDefaultInterpolationForProperty({
|
||||
propertyPath,
|
||||
element,
|
||||
}: {
|
||||
propertyPath: AnimationPropertyPath;
|
||||
element?: TimelineElement;
|
||||
}): AnimationInterpolation {
|
||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||
return propertyDefinition.defaultInterpolation;
|
||||
return getAnimationPropertyDefinition({ propertyPath, element })
|
||||
.defaultInterpolation;
|
||||
}
|
||||
|
||||
export function coerceAnimationValueForProperty({
|
||||
propertyPath,
|
||||
value,
|
||||
element,
|
||||
}: {
|
||||
propertyPath: AnimationPropertyPath;
|
||||
value: AnimationValue;
|
||||
element?: TimelineElement;
|
||||
}): AnimationValue | null {
|
||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||
return propertyDefinition.coerceValue({ value });
|
||||
return getAnimationPropertyDefinition({ propertyPath, element }).coerceValue({
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import type { ParamValues } from "@/params";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export const ANIMATION_PROPERTY_PATHS = [
|
||||
|
|
@ -21,10 +20,7 @@ export const ANIMATION_PROPERTY_PATHS = [
|
|||
export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number];
|
||||
export type GraphicParamPath = `params.${string}`;
|
||||
export type EffectParamPath = `effects.${string}.params.${string}`;
|
||||
export type AnimationPath =
|
||||
| AnimationPropertyPath
|
||||
| GraphicParamPath
|
||||
| EffectParamPath;
|
||||
export type AnimationPath = string;
|
||||
|
||||
export const ANIMATION_PROPERTY_GROUPS = {
|
||||
"transform.scale": ["transform.scaleX", "transform.scaleY"],
|
||||
|
|
@ -63,7 +59,7 @@ export type AnimationValueForPath<TPath extends AnimationPath> =
|
|||
? AnimationPropertyValueMap[TPath]
|
||||
: TPath extends GraphicParamPath | EffectParamPath
|
||||
? DynamicAnimationPathValue
|
||||
: never;
|
||||
: DynamicAnimationPathValue;
|
||||
export type AnimationNumericPropertyPath = {
|
||||
[K in AnimationPropertyPath]: AnimationValueForPath<K> extends number ? K : never;
|
||||
}[AnimationPropertyPath];
|
||||
|
|
|
|||
|
|
@ -189,7 +189,7 @@ export class InsertElementCommand extends Command {
|
|||
}
|
||||
}
|
||||
|
||||
if (element.type === "text" && !element.content) {
|
||||
if (element.type === "text" && !element.params.content) {
|
||||
console.error("Text element must have content");
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import type { ParamDefinition, NumberParamDefinition } from "@/params";
|
||||
import type {
|
||||
ParamDefinition,
|
||||
NumberParamDefinition,
|
||||
ParamValue,
|
||||
} from "@/params";
|
||||
import {
|
||||
formatNumberForDisplay,
|
||||
getFractionDigitsForStep,
|
||||
|
|
@ -19,6 +23,7 @@ import {
|
|||
} from "@/components/ui/select";
|
||||
import { usePropertyDraft } from "../hooks/use-property-draft";
|
||||
import { KeyframeToggle } from "./keyframe-toggle";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
export function PropertyParamField({
|
||||
param,
|
||||
|
|
@ -28,8 +33,8 @@ export function PropertyParamField({
|
|||
keyframe,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
value: number | string | boolean;
|
||||
onPreview: (value: number | string | boolean) => void;
|
||||
value: ParamValue;
|
||||
onPreview: (value: ParamValue) => void;
|
||||
onCommit: () => void;
|
||||
keyframe?: {
|
||||
isActive: boolean;
|
||||
|
|
@ -41,7 +46,7 @@ export function PropertyParamField({
|
|||
<SectionField
|
||||
label={param.label}
|
||||
beforeLabel={
|
||||
keyframe ? (
|
||||
keyframe && param.keyframable !== false ? (
|
||||
<KeyframeToggle
|
||||
isActive={keyframe.isActive}
|
||||
isDisabled={keyframe.isDisabled}
|
||||
|
|
@ -68,8 +73,8 @@ function ParamInput({
|
|||
onCommit,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
value: number | string | boolean;
|
||||
onPreview: (value: number | string | boolean) => void;
|
||||
value: ParamValue;
|
||||
onPreview: (value: ParamValue) => void;
|
||||
onCommit: () => void;
|
||||
}) {
|
||||
if (param.type === "number") {
|
||||
|
|
@ -131,6 +136,27 @@ function ParamInput({
|
|||
);
|
||||
}
|
||||
|
||||
if (param.type === "text") {
|
||||
return (
|
||||
<Textarea
|
||||
value={String(value)}
|
||||
onChange={(event) => onPreview(event.currentTarget.value)}
|
||||
onBlur={onCommit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (param.type === "font") {
|
||||
return (
|
||||
<input
|
||||
className="border-input bg-accent h-9 w-full rounded-md border px-3 text-sm outline-none"
|
||||
value={String(value)}
|
||||
onChange={(event) => onPreview(event.currentTarget.value)}
|
||||
onBlur={onCommit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -8,14 +8,15 @@ import {
|
|||
upsertPathKeyframe,
|
||||
} from "@/animation";
|
||||
import type {
|
||||
AnimationPath,
|
||||
ElementAnimations,
|
||||
} from "@/animation/types";
|
||||
import {
|
||||
coerceAnimationParamValue,
|
||||
getAnimationParamDefaultInterpolation,
|
||||
getAnimationParamValueKind,
|
||||
} from "@/animation/animated-params";
|
||||
import type { ParamDefinition } from "@/params";
|
||||
coerceParamValue,
|
||||
getParamDefaultInterpolation,
|
||||
getParamValueKind,
|
||||
type ParamDefinition,
|
||||
} from "@/params";
|
||||
import type { TimelineElement } from "@/timeline";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
|
|
@ -33,6 +34,7 @@ export function useKeyframedParamProperty({
|
|||
trackId,
|
||||
elementId,
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue,
|
||||
|
|
@ -42,6 +44,7 @@ export function useKeyframedParamProperty({
|
|||
trackId: string;
|
||||
elementId: string;
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath?: AnimationPath;
|
||||
localTime: MediaTime;
|
||||
isPlayheadWithinElementRange: boolean;
|
||||
resolvedValue: number | string | boolean;
|
||||
|
|
@ -52,15 +55,16 @@ export function useKeyframedParamProperty({
|
|||
}) => Partial<TimelineElement>;
|
||||
}): KeyframedParamPropertyResult {
|
||||
const editor = useEditor();
|
||||
const propertyPath = buildGraphicParamPath({ paramKey: param.key });
|
||||
const resolvedPropertyPath =
|
||||
propertyPath ?? buildGraphicParamPath({ paramKey: param.key });
|
||||
const hasAnimatedKeyframes = hasKeyframesForPath({
|
||||
animations,
|
||||
propertyPath,
|
||||
propertyPath: resolvedPropertyPath,
|
||||
});
|
||||
const keyframeAtTime = isPlayheadWithinElementRange
|
||||
? getKeyframeAtTime({
|
||||
animations,
|
||||
propertyPath,
|
||||
propertyPath: resolvedPropertyPath,
|
||||
time: localTime,
|
||||
})
|
||||
: null;
|
||||
|
|
@ -79,15 +83,15 @@ export function useKeyframedParamProperty({
|
|||
updates: {
|
||||
animations: upsertPathKeyframe({
|
||||
animations,
|
||||
propertyPath,
|
||||
propertyPath: resolvedPropertyPath,
|
||||
time: localTime,
|
||||
value,
|
||||
kind: getAnimationParamValueKind({ param }),
|
||||
defaultInterpolation: getAnimationParamDefaultInterpolation({
|
||||
kind: getParamValueKind({ param }),
|
||||
defaultInterpolation: getParamDefaultInterpolation({
|
||||
param,
|
||||
}),
|
||||
coerceValue: ({ value: nextValue }) =>
|
||||
coerceAnimationParamValue({
|
||||
coerceParamValue({
|
||||
param,
|
||||
value: nextValue,
|
||||
}),
|
||||
|
|
@ -121,7 +125,7 @@ export function useKeyframedParamProperty({
|
|||
{
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
propertyPath: resolvedPropertyPath,
|
||||
keyframeId: keyframeIdAtTime,
|
||||
},
|
||||
],
|
||||
|
|
@ -134,7 +138,7 @@ export function useKeyframedParamProperty({
|
|||
{
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
propertyPath: resolvedPropertyPath,
|
||||
time: localTime,
|
||||
value: resolvedValue,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -22,16 +22,43 @@ import {
|
|||
MagicWand05Icon,
|
||||
DashboardSpeed02Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { TransformTab } from "@/rendering/components/transform-tab";
|
||||
import { BlendingTab } from "@/rendering/components/blending-tab";
|
||||
import { AudioTab } from "@/timeline/components/audio-tab";
|
||||
import { TextTab } from "@/text/components/text-tab";
|
||||
import { ElementParamsTab } from "./components/element-params-tab";
|
||||
import { ClipEffectsTab, StandaloneEffectTab } from "@/effects/components/effects-tab";
|
||||
import { MasksTab } from "@/masks/components/masks-tab";
|
||||
import { SpeedTab } from "@/speed/components/speed-tab";
|
||||
import { GraphicTab } from "@/graphics/components/graphic-tab";
|
||||
import { OcShapesIcon } from "@/components/icons";
|
||||
|
||||
const TRANSFORM_PARAM_KEYS = [
|
||||
"transform.positionX",
|
||||
"transform.positionY",
|
||||
"transform.scaleX",
|
||||
"transform.scaleY",
|
||||
"transform.rotate",
|
||||
] as const;
|
||||
|
||||
const BLENDING_PARAM_KEYS = ["opacity", "blendMode"] as const;
|
||||
const AUDIO_PARAM_KEYS = ["volume", "muted"] as const;
|
||||
const TEXT_PARAM_KEYS = [
|
||||
"content",
|
||||
"fontFamily",
|
||||
"fontSize",
|
||||
"color",
|
||||
"textAlign",
|
||||
"fontWeight",
|
||||
"fontStyle",
|
||||
"textDecoration",
|
||||
"letterSpacing",
|
||||
"lineHeight",
|
||||
"background.enabled",
|
||||
"background.color",
|
||||
"background.cornerRadius",
|
||||
"background.paddingX",
|
||||
"background.paddingY",
|
||||
"background.offsetX",
|
||||
"background.offsetY",
|
||||
] as const;
|
||||
|
||||
export type TabContentProps = {
|
||||
trackId: string;
|
||||
};
|
||||
|
|
@ -58,7 +85,12 @@ function buildTransformTab({
|
|||
label: "Transform",
|
||||
icon: <HugeiconsIcon icon={ArrowExpandIcon} size={16} />,
|
||||
content: ({ trackId }) => (
|
||||
<TransformTab element={element} trackId={trackId} />
|
||||
<ElementParamsTab
|
||||
element={element}
|
||||
trackId={trackId}
|
||||
paramKeys={TRANSFORM_PARAM_KEYS}
|
||||
sectionKey="transform"
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
@ -73,7 +105,12 @@ function buildBlendingTab({
|
|||
label: "Blending",
|
||||
icon: <HugeiconsIcon icon={RainDropIcon} size={16} />,
|
||||
content: ({ trackId }) => (
|
||||
<BlendingTab element={element} trackId={trackId} />
|
||||
<ElementParamsTab
|
||||
element={element}
|
||||
trackId={trackId}
|
||||
paramKeys={BLENDING_PARAM_KEYS}
|
||||
sectionKey="blending"
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
@ -87,7 +124,14 @@ function buildAudioTab({
|
|||
id: "audio",
|
||||
label: "Audio",
|
||||
icon: <HugeiconsIcon icon={MusicNote03Icon} size={16} />,
|
||||
content: ({ trackId }) => <AudioTab element={element} trackId={trackId} />,
|
||||
content: ({ trackId }) => (
|
||||
<ElementParamsTab
|
||||
element={element}
|
||||
trackId={trackId}
|
||||
paramKeys={AUDIO_PARAM_KEYS}
|
||||
sectionKey="audio"
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -137,7 +181,14 @@ function buildTextTab({ element }: { element: TextElement }): PropertiesTabDef {
|
|||
id: "text",
|
||||
label: "Text",
|
||||
icon: <HugeiconsIcon icon={TextFontIcon} size={16} />,
|
||||
content: ({ trackId }) => <TextTab element={element} trackId={trackId} />,
|
||||
content: ({ trackId }) => (
|
||||
<ElementParamsTab
|
||||
element={element}
|
||||
trackId={trackId}
|
||||
paramKeys={TEXT_PARAM_KEYS}
|
||||
sectionKey="text"
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
canElementBeHidden,
|
||||
canElementHaveAudio,
|
||||
} from "@/timeline/element-utils";
|
||||
import { isElementMuted } from "@/timeline/audio-state";
|
||||
import type {
|
||||
AnimationPath,
|
||||
AnimationInterpolation,
|
||||
|
|
@ -843,7 +844,7 @@ export class TimelineManager {
|
|||
}): void {
|
||||
const shouldMute = elements.some(({ trackId, elementId }) => {
|
||||
const element = this.getElementByRef({ trackId, elementId });
|
||||
return element && canElementHaveAudio(element) && !element.muted;
|
||||
return element && canElementHaveAudio(element) && !isElementMuted({ element });
|
||||
});
|
||||
|
||||
const nextUpdates = elements.flatMap(({ trackId, elementId }) => {
|
||||
|
|
@ -856,7 +857,7 @@ export class TimelineManager {
|
|||
{
|
||||
trackId,
|
||||
elementId,
|
||||
patch: { muted: shouldMute },
|
||||
patch: { params: { muted: shouldMute } },
|
||||
},
|
||||
];
|
||||
});
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ const PERCENTAGE_DISPLAY = {
|
|||
step: 1,
|
||||
} as const;
|
||||
|
||||
const TEXT_MASK_ALIGNMENT = DEFAULTS.text.element.textAlign;
|
||||
const TEXT_MASK_ALIGNMENT = "center";
|
||||
|
||||
const TEXT_MASK_PARAMS: ParamDefinition<keyof TextMaskParams & string>[] = [
|
||||
{
|
||||
|
|
@ -60,7 +60,7 @@ const TEXT_MASK_PARAMS: ParamDefinition<keyof TextMaskParams & string>[] = [
|
|||
key: "fontSize",
|
||||
label: "Size",
|
||||
type: "number",
|
||||
default: DEFAULTS.text.element.fontSize,
|
||||
default: 15,
|
||||
min: MIN_FONT_SIZE,
|
||||
max: MAX_FONT_SIZE,
|
||||
step: 1,
|
||||
|
|
@ -351,11 +351,11 @@ export const textMaskDefinition: MaskDefinition<TextMaskParams> = {
|
|||
strokeWidth: 0,
|
||||
strokeAlign: "center",
|
||||
content: "Mask",
|
||||
fontSize: DEFAULTS.text.element.fontSize,
|
||||
fontFamily: DEFAULTS.text.element.fontFamily,
|
||||
fontWeight: DEFAULTS.text.element.fontWeight,
|
||||
fontStyle: DEFAULTS.text.element.fontStyle,
|
||||
textDecoration: DEFAULTS.text.element.textDecoration,
|
||||
fontSize: 15,
|
||||
fontFamily: "Arial",
|
||||
fontWeight: "normal",
|
||||
fontStyle: "normal",
|
||||
textDecoration: "none",
|
||||
letterSpacing: DEFAULTS.text.letterSpacing,
|
||||
lineHeight: DEFAULTS.text.lineHeight,
|
||||
centerX: 0,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { applyAudioMasteringToBuffer } from "@/media/audio-mastering";
|
|||
import type { AudioCapableElement } from "@/timeline/audio-state";
|
||||
import {
|
||||
hasAnimatedVolume,
|
||||
isElementMuted,
|
||||
resolveEffectiveAudioGain,
|
||||
} from "@/timeline/audio-state";
|
||||
import { doesElementHaveEnabledAudio } from "@/timeline/audio-separation";
|
||||
|
|
@ -128,7 +129,7 @@ export function timelineHasAudio({
|
|||
mediaAssets: MediaAsset[];
|
||||
}): boolean {
|
||||
return collectAudibleCandidates({ tracks, mediaAssets }).some(
|
||||
({ element }) => element.muted !== true,
|
||||
({ element }) => !isElementMuted({ element }),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -168,7 +169,7 @@ export async function collectAudioElements({
|
|||
trackMuted: false,
|
||||
localTime: 0,
|
||||
}),
|
||||
muted: element.muted === true,
|
||||
muted: isElementMuted({ element }),
|
||||
retime: element.retime,
|
||||
};
|
||||
}),
|
||||
|
|
@ -197,7 +198,7 @@ export async function collectAudioElements({
|
|||
trackMuted: false,
|
||||
localTime: 0,
|
||||
}),
|
||||
muted: element.muted ?? false,
|
||||
muted: isElementMuted({ element }),
|
||||
retime: element.retime,
|
||||
};
|
||||
}),
|
||||
|
|
@ -501,7 +502,7 @@ export async function collectAudioMixSources({
|
|||
|
||||
for (const element of track.elements) {
|
||||
if (!canElementHaveAudio(element)) continue;
|
||||
if (element.muted === true) continue;
|
||||
if (isElementMuted({ element })) continue;
|
||||
const mediaAsset = hasMediaId(element)
|
||||
? (mediaMap.get(element.mediaId) ?? null)
|
||||
: null;
|
||||
|
|
@ -570,9 +571,7 @@ export async function collectAudioClips({
|
|||
: null;
|
||||
if (!doesElementHaveEnabledAudio({ element, mediaAsset })) continue;
|
||||
|
||||
const isElementMuted =
|
||||
"muted" in element ? (element.muted ?? false) : false;
|
||||
const muted = isTrackMuted || isElementMuted;
|
||||
const muted = isTrackMuted || isElementMuted({ element });
|
||||
const volume = resolveEffectiveAudioGain({
|
||||
element,
|
||||
trackMuted: isTrackMuted,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
export type ParamValues = Record<string, number | string | boolean>;
|
||||
import { snapToStep } from "@/utils/math";
|
||||
|
||||
export type ParamValue = number | string | boolean;
|
||||
export type ParamValues = Record<string, ParamValue>;
|
||||
|
||||
export type ParamGroup = "stroke";
|
||||
|
||||
|
|
@ -6,6 +9,8 @@ interface BaseParamDefinition<TKey extends string = string> {
|
|||
key: TKey;
|
||||
label: string;
|
||||
group?: ParamGroup;
|
||||
keyframable?: boolean;
|
||||
dependencies?: Array<{ param: string; equals: ParamValue }>;
|
||||
}
|
||||
|
||||
export interface NumberParamDefinition<TKey extends string = string>
|
||||
|
|
@ -42,9 +47,92 @@ export interface SelectParamDefinition<TKey extends string = string>
|
|||
options: Array<{ value: string; label: string }>;
|
||||
}
|
||||
|
||||
export interface TextParamDefinition<TKey extends string = string>
|
||||
extends BaseParamDefinition<TKey> {
|
||||
type: "text";
|
||||
default: string;
|
||||
}
|
||||
|
||||
export interface FontParamDefinition<TKey extends string = string>
|
||||
extends BaseParamDefinition<TKey> {
|
||||
type: "font";
|
||||
default: string;
|
||||
}
|
||||
|
||||
export type ParamDefinition<TKey extends string = string> =
|
||||
| NumberParamDefinition<TKey>
|
||||
| BooleanParamDefinition<TKey>
|
||||
| ColorParamDefinition<TKey>
|
||||
| SelectParamDefinition<TKey>;
|
||||
| SelectParamDefinition<TKey>
|
||||
| TextParamDefinition<TKey>
|
||||
| FontParamDefinition<TKey>;
|
||||
|
||||
export function getParamValueKind({
|
||||
param,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
}): "number" | "color" | "discrete" {
|
||||
if (param.type === "number") {
|
||||
return "number";
|
||||
}
|
||||
if (param.type === "color") {
|
||||
return "color";
|
||||
}
|
||||
return "discrete";
|
||||
}
|
||||
|
||||
export function getParamDefaultInterpolation({
|
||||
param,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
}): "linear" | "hold" {
|
||||
return param.type === "number" || param.type === "color" ? "linear" : "hold";
|
||||
}
|
||||
|
||||
export function getParamNumericRange({
|
||||
param,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
}): { min?: number; max?: number; step?: number } | undefined {
|
||||
if (param.type !== "number") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
min: param.min,
|
||||
max: param.max,
|
||||
step: param.step,
|
||||
};
|
||||
}
|
||||
|
||||
export function coerceParamValue({
|
||||
param,
|
||||
value,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
value: unknown;
|
||||
}): ParamValue | null {
|
||||
if (param.type === "number") {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const steppedValue = snapToStep({ value, step: param.step });
|
||||
const maxValue = param.max ?? Number.POSITIVE_INFINITY;
|
||||
return Math.min(maxValue, Math.max(param.min, steppedValue));
|
||||
}
|
||||
|
||||
if (param.type === "boolean") {
|
||||
return typeof value === "boolean" ? value : null;
|
||||
}
|
||||
|
||||
if (param.type === "color" || param.type === "text" || param.type === "font") {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return param.options.some((option) => option.value === value) ? value : null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,35 @@
|
|||
import type { ParamDefinition, ParamValues } from "@/params";
|
||||
import type {
|
||||
ParamDefinition,
|
||||
ParamValue,
|
||||
ParamValues,
|
||||
} from "@/params";
|
||||
import { MIN_TRANSFORM_SCALE } from "@/animation/transform";
|
||||
import type { BlendMode } from "@/rendering";
|
||||
import type {
|
||||
ElementType,
|
||||
TimelineElement,
|
||||
} from "@/timeline";
|
||||
import { DEFAULTS } from "@/timeline/defaults";
|
||||
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
|
||||
import {
|
||||
CORNER_RADIUS_MAX,
|
||||
CORNER_RADIUS_MIN,
|
||||
} from "@/text/background";
|
||||
|
||||
export type ElementParamDefinition<TKey extends string = string> =
|
||||
ParamDefinition<TKey> & {
|
||||
read?: ({ element }: { element: TimelineElement }) => ParamValue | null;
|
||||
write?: ({
|
||||
element,
|
||||
value,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
value: ParamValue;
|
||||
}) => TimelineElement;
|
||||
};
|
||||
|
||||
export function buildDefaultParamValues(
|
||||
params: ParamDefinition[],
|
||||
params: readonly ParamDefinition[],
|
||||
): ParamValues {
|
||||
const values: ParamValues = {};
|
||||
for (const param of params) {
|
||||
|
|
@ -44,3 +72,367 @@ export class DefinitionRegistry<TKey extends string, TDefinition> {
|
|||
return Array.from(this.definitions.values());
|
||||
}
|
||||
}
|
||||
|
||||
const BLEND_MODE_OPTIONS: Array<{ value: BlendMode; label: string }> = [
|
||||
{ value: "normal", label: "Normal" },
|
||||
{ value: "darken", label: "Darken" },
|
||||
{ value: "multiply", label: "Multiply" },
|
||||
{ value: "color-burn", label: "Color Burn" },
|
||||
{ value: "lighten", label: "Lighten" },
|
||||
{ value: "screen", label: "Screen" },
|
||||
{ value: "plus-lighter", label: "Plus Lighter" },
|
||||
{ value: "color-dodge", label: "Color Dodge" },
|
||||
{ value: "overlay", label: "Overlay" },
|
||||
{ value: "soft-light", label: "Soft Light" },
|
||||
{ value: "hard-light", label: "Hard Light" },
|
||||
{ value: "difference", label: "Difference" },
|
||||
{ value: "exclusion", label: "Exclusion" },
|
||||
{ value: "hue", label: "Hue" },
|
||||
{ value: "saturation", label: "Saturation" },
|
||||
{ value: "color", label: "Color" },
|
||||
{ value: "luminosity", label: "Luminosity" },
|
||||
];
|
||||
|
||||
const visualElementParams: ElementParamDefinition[] = [
|
||||
{
|
||||
key: "transform.positionX",
|
||||
label: "Position X",
|
||||
type: "number",
|
||||
default: DEFAULTS.element.transform.position.x,
|
||||
min: -100_000,
|
||||
step: 1,
|
||||
},
|
||||
{
|
||||
key: "transform.positionY",
|
||||
label: "Position Y",
|
||||
type: "number",
|
||||
default: DEFAULTS.element.transform.position.y,
|
||||
min: -100_000,
|
||||
step: 1,
|
||||
},
|
||||
{
|
||||
key: "transform.scaleX",
|
||||
label: "Scale X",
|
||||
type: "number",
|
||||
default: DEFAULTS.element.transform.scaleX,
|
||||
min: MIN_TRANSFORM_SCALE,
|
||||
step: 0.01,
|
||||
},
|
||||
{
|
||||
key: "transform.scaleY",
|
||||
label: "Scale Y",
|
||||
type: "number",
|
||||
default: DEFAULTS.element.transform.scaleY,
|
||||
min: MIN_TRANSFORM_SCALE,
|
||||
step: 0.01,
|
||||
},
|
||||
{
|
||||
key: "transform.rotate",
|
||||
label: "Rotate",
|
||||
type: "number",
|
||||
default: DEFAULTS.element.transform.rotate,
|
||||
min: -360,
|
||||
max: 360,
|
||||
step: 1,
|
||||
},
|
||||
{
|
||||
key: "opacity",
|
||||
label: "Opacity",
|
||||
type: "number",
|
||||
default: DEFAULTS.element.opacity,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.01,
|
||||
},
|
||||
{
|
||||
key: "blendMode",
|
||||
label: "Blend Mode",
|
||||
type: "select",
|
||||
default: DEFAULTS.element.blendMode,
|
||||
keyframable: false,
|
||||
options: BLEND_MODE_OPTIONS,
|
||||
},
|
||||
];
|
||||
|
||||
const audioElementParams: ElementParamDefinition[] = [
|
||||
{
|
||||
key: "volume",
|
||||
label: "Volume",
|
||||
type: "number",
|
||||
default: DEFAULTS.element.volume,
|
||||
min: VOLUME_DB_MIN,
|
||||
max: VOLUME_DB_MAX,
|
||||
step: 0.01,
|
||||
},
|
||||
{
|
||||
key: "muted",
|
||||
label: "Muted",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
keyframable: false,
|
||||
},
|
||||
];
|
||||
|
||||
const textElementParams: ElementParamDefinition[] = [
|
||||
{
|
||||
key: "content",
|
||||
label: "Content",
|
||||
type: "text",
|
||||
default: "Default text",
|
||||
keyframable: false,
|
||||
},
|
||||
{
|
||||
key: "fontFamily",
|
||||
label: "Font Family",
|
||||
type: "font",
|
||||
default: "Arial",
|
||||
keyframable: false,
|
||||
},
|
||||
{
|
||||
key: "fontSize",
|
||||
label: "Font Size",
|
||||
type: "number",
|
||||
default: 15,
|
||||
min: 1,
|
||||
step: 1,
|
||||
},
|
||||
{
|
||||
key: "color",
|
||||
label: "Color",
|
||||
type: "color",
|
||||
default: "#ffffff",
|
||||
},
|
||||
{
|
||||
key: "textAlign",
|
||||
label: "Text Align",
|
||||
type: "select",
|
||||
default: "center",
|
||||
keyframable: false,
|
||||
options: [
|
||||
{ value: "left", label: "Left" },
|
||||
{ value: "center", label: "Center" },
|
||||
{ value: "right", label: "Right" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "fontWeight",
|
||||
label: "Font Weight",
|
||||
type: "select",
|
||||
default: "normal",
|
||||
keyframable: false,
|
||||
options: [
|
||||
{ value: "normal", label: "Normal" },
|
||||
{ value: "bold", label: "Bold" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "fontStyle",
|
||||
label: "Font Style",
|
||||
type: "select",
|
||||
default: "normal",
|
||||
keyframable: false,
|
||||
options: [
|
||||
{ value: "normal", label: "Normal" },
|
||||
{ value: "italic", label: "Italic" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "textDecoration",
|
||||
label: "Text Decoration",
|
||||
type: "select",
|
||||
default: "none",
|
||||
keyframable: false,
|
||||
options: [
|
||||
{ value: "none", label: "None" },
|
||||
{ value: "underline", label: "Underline" },
|
||||
{ value: "line-through", label: "Line Through" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "letterSpacing",
|
||||
label: "Letter Spacing",
|
||||
type: "number",
|
||||
default: DEFAULTS.text.letterSpacing,
|
||||
min: -100,
|
||||
step: 0.1,
|
||||
},
|
||||
{
|
||||
key: "lineHeight",
|
||||
label: "Line Height",
|
||||
type: "number",
|
||||
default: DEFAULTS.text.lineHeight,
|
||||
min: 0.1,
|
||||
step: 0.1,
|
||||
},
|
||||
{
|
||||
key: "background.enabled",
|
||||
label: "Background Enabled",
|
||||
type: "boolean",
|
||||
default: DEFAULTS.text.background.enabled,
|
||||
keyframable: false,
|
||||
},
|
||||
{
|
||||
key: "background.color",
|
||||
label: "Background Color",
|
||||
type: "color",
|
||||
default: DEFAULTS.text.background.color,
|
||||
dependencies: [{ param: "background.enabled", equals: true }],
|
||||
},
|
||||
{
|
||||
key: "background.cornerRadius",
|
||||
label: "Background Radius",
|
||||
type: "number",
|
||||
default: DEFAULTS.text.background.cornerRadius,
|
||||
min: CORNER_RADIUS_MIN,
|
||||
max: CORNER_RADIUS_MAX,
|
||||
step: 1,
|
||||
dependencies: [{ param: "background.enabled", equals: true }],
|
||||
},
|
||||
{
|
||||
key: "background.paddingX",
|
||||
label: "Background Padding X",
|
||||
type: "number",
|
||||
default: DEFAULTS.text.background.paddingX,
|
||||
min: 0,
|
||||
step: 1,
|
||||
dependencies: [{ param: "background.enabled", equals: true }],
|
||||
},
|
||||
{
|
||||
key: "background.paddingY",
|
||||
label: "Background Padding Y",
|
||||
type: "number",
|
||||
default: DEFAULTS.text.background.paddingY,
|
||||
min: 0,
|
||||
step: 1,
|
||||
dependencies: [{ param: "background.enabled", equals: true }],
|
||||
},
|
||||
{
|
||||
key: "background.offsetX",
|
||||
label: "Background Offset X",
|
||||
type: "number",
|
||||
default: DEFAULTS.text.background.offsetX,
|
||||
min: -100_000,
|
||||
step: 1,
|
||||
dependencies: [{ param: "background.enabled", equals: true }],
|
||||
},
|
||||
{
|
||||
key: "background.offsetY",
|
||||
label: "Background Offset Y",
|
||||
type: "number",
|
||||
default: DEFAULTS.text.background.offsetY,
|
||||
min: -100_000,
|
||||
step: 1,
|
||||
dependencies: [{ param: "background.enabled", equals: true }],
|
||||
},
|
||||
];
|
||||
|
||||
export const elementParamRegistry = new DefinitionRegistry<
|
||||
ElementType,
|
||||
readonly ElementParamDefinition[]
|
||||
>("element params");
|
||||
|
||||
elementParamRegistry.register({
|
||||
key: "video",
|
||||
definition: [...visualElementParams, ...audioElementParams],
|
||||
});
|
||||
elementParamRegistry.register({ key: "image", definition: visualElementParams });
|
||||
elementParamRegistry.register({
|
||||
key: "text",
|
||||
definition: [...textElementParams, ...visualElementParams],
|
||||
});
|
||||
elementParamRegistry.register({
|
||||
key: "sticker",
|
||||
definition: visualElementParams,
|
||||
});
|
||||
elementParamRegistry.register({
|
||||
key: "graphic",
|
||||
definition: visualElementParams,
|
||||
});
|
||||
elementParamRegistry.register({ key: "audio", definition: audioElementParams });
|
||||
elementParamRegistry.register({ key: "effect", definition: [] });
|
||||
|
||||
export function getElementParams({
|
||||
element,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
}): readonly ElementParamDefinition[] {
|
||||
return elementParamRegistry.has(element.type)
|
||||
? elementParamRegistry.get(element.type)
|
||||
: [];
|
||||
}
|
||||
|
||||
export function getBuiltInElementParams({
|
||||
type,
|
||||
}: {
|
||||
type: ElementType;
|
||||
}): readonly ElementParamDefinition[] {
|
||||
return elementParamRegistry.has(type) ? elementParamRegistry.get(type) : [];
|
||||
}
|
||||
|
||||
export function getElementParam({
|
||||
element,
|
||||
key,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
key: string;
|
||||
}): ElementParamDefinition | null {
|
||||
return (
|
||||
getElementParams({ element }).find((param) => param.key === key) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function readElementParamValue({
|
||||
element,
|
||||
param,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
param: ElementParamDefinition;
|
||||
}): ParamValue | null {
|
||||
if (param.read) {
|
||||
return param.read({ element });
|
||||
}
|
||||
if ("params" in element) {
|
||||
return element.params[param.key] ?? param.default;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function writeElementParamValue({
|
||||
element,
|
||||
param,
|
||||
value,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
param: ElementParamDefinition;
|
||||
value: ParamValue;
|
||||
}): TimelineElement {
|
||||
if (param.write) {
|
||||
return param.write({ element, value });
|
||||
}
|
||||
if ("params" in element) {
|
||||
return {
|
||||
...element,
|
||||
params: {
|
||||
...element.params,
|
||||
[param.key]: value,
|
||||
},
|
||||
};
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
export function buildElementParamValues({
|
||||
element,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
}): ParamValues {
|
||||
const values: ParamValues = {};
|
||||
for (const param of getElementParams({ element })) {
|
||||
const value = readElementParamValue({ element, param });
|
||||
if (value !== null) {
|
||||
values[param.key] = value;
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,12 @@ import {
|
|||
getElementLocalTime,
|
||||
} from "@/animation";
|
||||
import { resolveTransformAtTime } from "@/rendering/animation-values";
|
||||
import { buildTransformFromParams } from "@/rendering";
|
||||
import { resolveTextLayout } from "@/text/primitives";
|
||||
import {
|
||||
buildTextBackgroundFromElement,
|
||||
buildTextLayoutParamsFromElement,
|
||||
} from "@/text/measure-element";
|
||||
|
||||
export function TextEditOverlay({
|
||||
trackId,
|
||||
|
|
@ -42,7 +47,7 @@ export function TextEditOverlay({
|
|||
if (!div) return;
|
||||
const text = div.innerText;
|
||||
editor.timeline.previewElements({
|
||||
updates: [{ trackId, elementId, updates: { content: text } }],
|
||||
updates: [{ trackId, elementId, updates: { params: { content: text } } }],
|
||||
});
|
||||
}, [editor.timeline, trackId, elementId]);
|
||||
|
||||
|
|
@ -69,7 +74,7 @@ export function TextEditOverlay({
|
|||
elementDuration: element.duration,
|
||||
});
|
||||
const transform = resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
baseTransform: buildTransformFromParams({ params: element.params }),
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
|
|
@ -80,26 +85,17 @@ export function TextEditOverlay({
|
|||
});
|
||||
|
||||
const { x: displayScaleX } = viewport.getDisplayScale();
|
||||
const textParams = buildTextLayoutParamsFromElement({ element });
|
||||
const resolvedTextLayout = resolveTextLayout({
|
||||
text: {
|
||||
content: element.content,
|
||||
fontSize: element.fontSize,
|
||||
fontFamily: element.fontFamily,
|
||||
fontWeight: element.fontWeight,
|
||||
fontStyle: element.fontStyle,
|
||||
textAlign: element.textAlign,
|
||||
textDecoration: element.textDecoration,
|
||||
letterSpacing: element.letterSpacing,
|
||||
lineHeight: element.lineHeight,
|
||||
},
|
||||
text: textParams,
|
||||
canvasHeight: canvasSize.height,
|
||||
});
|
||||
|
||||
const lineHeight = element.lineHeight ?? DEFAULTS.text.lineHeight;
|
||||
const canvasLetterSpacing = element.letterSpacing ?? 0;
|
||||
const lineHeight = textParams.lineHeight ?? DEFAULTS.text.lineHeight;
|
||||
const canvasLetterSpacing = textParams.letterSpacing ?? 0;
|
||||
const lineHeightPx = resolvedTextLayout.lineHeightPx;
|
||||
|
||||
const bg = element.background;
|
||||
const bg = buildTextBackgroundFromElement({ element });
|
||||
const shouldShowBackground =
|
||||
bg.enabled && bg.color && bg.color !== "transparent";
|
||||
const fontSizeRatio = resolvedTextLayout.fontSizeRatio;
|
||||
|
|
@ -130,17 +126,20 @@ export function TextEditOverlay({
|
|||
className="cursor-text select-text outline-none whitespace-pre"
|
||||
style={{
|
||||
fontSize: resolvedTextLayout.scaledFontSize,
|
||||
fontFamily: element.fontFamily,
|
||||
fontWeight: element.fontWeight === "bold" ? "bold" : "normal",
|
||||
fontStyle: element.fontStyle === "italic" ? "italic" : "normal",
|
||||
textAlign: element.textAlign,
|
||||
fontFamily: textParams.fontFamily,
|
||||
fontWeight: textParams.fontWeight === "bold" ? "bold" : "normal",
|
||||
fontStyle: textParams.fontStyle === "italic" ? "italic" : "normal",
|
||||
textAlign: textParams.textAlign,
|
||||
letterSpacing: `${canvasLetterSpacing}px`,
|
||||
lineHeight,
|
||||
color: "transparent",
|
||||
caretColor: element.color,
|
||||
caretColor:
|
||||
typeof element.params.color === "string"
|
||||
? element.params.color
|
||||
: "#ffffff",
|
||||
backgroundColor: shouldShowBackground ? bg.color : "transparent",
|
||||
minHeight: lineHeightPx,
|
||||
textDecoration: element.textDecoration ?? "none",
|
||||
textDecoration: textParams.textDecoration ?? "none",
|
||||
padding: shouldShowBackground
|
||||
? `${canvasPaddingY}px ${canvasPaddingX}px`
|
||||
: 0,
|
||||
|
|
@ -150,7 +149,7 @@ export function TextEditOverlay({
|
|||
onBlur={onCommit}
|
||||
onKeyDown={(event) => handleKeyDown({ event })}
|
||||
>
|
||||
{element.content || ""}
|
||||
{textParams.content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ import {
|
|||
type SnapLine,
|
||||
} from "@/preview/preview-snap";
|
||||
import type { TCanvasSize } from "@/project/types";
|
||||
import type { Transform } from "@/rendering";
|
||||
import type { ParamValues } from "@/params";
|
||||
import { buildTransformFromParams, type Transform } from "@/rendering";
|
||||
import { isVisualElement } from "@/timeline/element-utils";
|
||||
import type {
|
||||
ElementRef,
|
||||
|
|
@ -51,6 +52,7 @@ interface DragElementSnapshot {
|
|||
readonly trackId: string;
|
||||
readonly elementId: string;
|
||||
readonly initialTransform: Transform;
|
||||
readonly initialParams: ParamValues;
|
||||
}
|
||||
|
||||
interface DraggingGesture extends CapturedPointerState {
|
||||
|
|
@ -218,7 +220,8 @@ function toDragElementSnapshots({
|
|||
.map(({ track, element }) => ({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
initialTransform: element.transform,
|
||||
initialTransform: buildTransformFromParams({ params: element.params }),
|
||||
initialParams: element.params,
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
@ -565,16 +568,14 @@ export class PreviewInteractionController {
|
|||
snappedPosition.y - firstElement.initialTransform.position.y;
|
||||
|
||||
this.deps.timeline.previewElements(
|
||||
drag.elements.map(({ trackId, elementId, initialTransform }) => ({
|
||||
drag.elements.map(({ trackId, elementId, initialTransform, initialParams }) => ({
|
||||
trackId,
|
||||
elementId,
|
||||
updates: {
|
||||
transform: {
|
||||
...initialTransform,
|
||||
position: {
|
||||
x: initialTransform.position.x + deltaSnappedX,
|
||||
y: initialTransform.position.y + deltaSnappedY,
|
||||
},
|
||||
params: {
|
||||
...initialParams,
|
||||
"transform.positionX": initialTransform.position.x + deltaSnappedX,
|
||||
"transform.positionY": initialTransform.position.y + deltaSnappedY,
|
||||
},
|
||||
},
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ import {
|
|||
setChannel,
|
||||
} from "@/animation";
|
||||
import type { ElementAnimations } from "@/animation/types";
|
||||
import type { Transform } from "@/rendering";
|
||||
import type { ParamValues } from "@/params";
|
||||
import { buildTransformFromParams, type Transform } from "@/rendering";
|
||||
import { resolveTransformAtTime } from "@/rendering/animation-values";
|
||||
import type {
|
||||
ElementRef,
|
||||
|
|
@ -47,6 +48,7 @@ interface CornerScaleSession extends CapturedPointerState {
|
|||
readonly trackId: string;
|
||||
readonly elementId: string;
|
||||
readonly initialTransform: Transform;
|
||||
readonly initialParams: ParamValues;
|
||||
readonly initialDistance: number;
|
||||
readonly initialBoundsCx: number;
|
||||
readonly initialBoundsCy: number;
|
||||
|
|
@ -62,6 +64,7 @@ interface EdgeScaleSession extends CapturedPointerState {
|
|||
readonly trackId: string;
|
||||
readonly elementId: string;
|
||||
readonly initialTransform: Transform;
|
||||
readonly initialParams: ParamValues;
|
||||
readonly initialBoundsCx: number;
|
||||
readonly initialBoundsCy: number;
|
||||
readonly baseWidth: number;
|
||||
|
|
@ -76,6 +79,7 @@ interface RotationSession extends CapturedPointerState {
|
|||
readonly trackId: string;
|
||||
readonly elementId: string;
|
||||
readonly initialTransform: Transform;
|
||||
readonly initialParams: ParamValues;
|
||||
readonly initialAngle: number;
|
||||
readonly initialBoundsCx: number;
|
||||
readonly initialBoundsCy: number;
|
||||
|
|
@ -369,6 +373,7 @@ export class TransformHandleController {
|
|||
trackId: context.trackId,
|
||||
elementId: context.elementId,
|
||||
initialTransform: context.resolvedTransform,
|
||||
initialParams: context.element.params,
|
||||
initialDistance: getCornerDistance({
|
||||
bounds: context.bounds,
|
||||
corner,
|
||||
|
|
@ -410,6 +415,7 @@ export class TransformHandleController {
|
|||
trackId: context.trackId,
|
||||
elementId: context.elementId,
|
||||
initialTransform: context.resolvedTransform,
|
||||
initialParams: context.element.params,
|
||||
initialAngle,
|
||||
initialBoundsCx: context.bounds.cx,
|
||||
initialBoundsCy: context.bounds.cy,
|
||||
|
|
@ -447,6 +453,7 @@ export class TransformHandleController {
|
|||
trackId: context.trackId,
|
||||
elementId: context.elementId,
|
||||
initialTransform: context.resolvedTransform,
|
||||
initialParams: context.element.params,
|
||||
initialBoundsCx: context.bounds.cx,
|
||||
initialBoundsCy: context.bounds.cy,
|
||||
baseWidth: context.bounds.width / context.resolvedTransform.scaleX,
|
||||
|
|
@ -561,7 +568,9 @@ export class TransformHandleController {
|
|||
element: selectedWithBounds.element,
|
||||
bounds: selectedWithBounds.bounds,
|
||||
resolvedTransform: resolveTransformAtTime({
|
||||
baseTransform: selectedWithBounds.element.transform,
|
||||
baseTransform: buildTransformFromParams({
|
||||
params: selectedWithBounds.element.params,
|
||||
}),
|
||||
animations: selectedWithBounds.element.animations,
|
||||
localTime,
|
||||
}),
|
||||
|
|
@ -608,15 +617,18 @@ export class TransformHandleController {
|
|||
trackId: session.trackId,
|
||||
elementId: session.elementId,
|
||||
updates: {
|
||||
transform: {
|
||||
...session.initialTransform,
|
||||
scaleX: clampScaleNonZero(
|
||||
session.initialTransform.scaleX * snappedScale,
|
||||
),
|
||||
scaleY: clampScaleNonZero(
|
||||
session.initialTransform.scaleY * snappedScale,
|
||||
),
|
||||
},
|
||||
params: buildParamsWithTransform({
|
||||
params: session.initialParams,
|
||||
transform: {
|
||||
...session.initialTransform,
|
||||
scaleX: clampScaleNonZero(
|
||||
session.initialTransform.scaleX * snappedScale,
|
||||
),
|
||||
scaleY: clampScaleNonZero(
|
||||
session.initialTransform.scaleY * snappedScale,
|
||||
),
|
||||
},
|
||||
}),
|
||||
...(session.shouldClearScaleAnimation && {
|
||||
animations: session.animationsWithoutScale,
|
||||
}),
|
||||
|
|
@ -699,17 +711,20 @@ export class TransformHandleController {
|
|||
trackId: session.trackId,
|
||||
elementId: session.elementId,
|
||||
updates: {
|
||||
transform: {
|
||||
...session.initialTransform,
|
||||
scaleX:
|
||||
session.edge === "right" || session.edge === "left"
|
||||
? xSnap.snappedScale
|
||||
: session.initialTransform.scaleX,
|
||||
scaleY:
|
||||
session.edge === "bottom"
|
||||
? ySnap.snappedScale
|
||||
: session.initialTransform.scaleY,
|
||||
},
|
||||
params: buildParamsWithTransform({
|
||||
params: session.initialParams,
|
||||
transform: {
|
||||
...session.initialTransform,
|
||||
scaleX:
|
||||
session.edge === "right" || session.edge === "left"
|
||||
? xSnap.snappedScale
|
||||
: session.initialTransform.scaleX,
|
||||
scaleY:
|
||||
session.edge === "bottom"
|
||||
? ySnap.snappedScale
|
||||
: session.initialTransform.scaleY,
|
||||
},
|
||||
}),
|
||||
...(session.shouldClearScaleAnimation && {
|
||||
animations: session.animationsWithoutScale,
|
||||
}),
|
||||
|
|
@ -742,12 +757,32 @@ export class TransformHandleController {
|
|||
trackId: session.trackId,
|
||||
elementId: session.elementId,
|
||||
updates: {
|
||||
transform: {
|
||||
...session.initialTransform,
|
||||
rotate: snappedRotation,
|
||||
},
|
||||
params: buildParamsWithTransform({
|
||||
params: session.initialParams,
|
||||
transform: {
|
||||
...session.initialTransform,
|
||||
rotate: snappedRotation,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
function buildParamsWithTransform({
|
||||
params,
|
||||
transform,
|
||||
}: {
|
||||
params: ParamValues;
|
||||
transform: Transform;
|
||||
}): ParamValues {
|
||||
return {
|
||||
...params,
|
||||
"transform.positionX": transform.position.x,
|
||||
"transform.positionY": transform.position.y,
|
||||
"transform.scaleX": transform.scaleX,
|
||||
"transform.scaleY": transform.scaleY,
|
||||
"transform.rotate": transform.rotate,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
getElementLocalTime,
|
||||
} from "@/animation";
|
||||
import { resolveTransformAtTime } from "@/rendering/animation-values";
|
||||
import { buildTransformFromParams } from "@/rendering";
|
||||
|
||||
export interface ElementBounds {
|
||||
cx: number;
|
||||
|
|
@ -123,7 +124,7 @@ function getElementBounds({
|
|||
|
||||
if (element.type === "video" || element.type === "image") {
|
||||
const transform = resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
baseTransform: buildTransformFromParams({ params: element.params }),
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
|
|
@ -140,7 +141,7 @@ function getElementBounds({
|
|||
|
||||
if (element.type === "sticker") {
|
||||
const transform = resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
baseTransform: buildTransformFromParams({ params: element.params }),
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
|
|
@ -155,7 +156,7 @@ function getElementBounds({
|
|||
|
||||
if (element.type === "graphic") {
|
||||
const transform = resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
baseTransform: buildTransformFromParams({ params: element.params }),
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
|
|
@ -170,7 +171,7 @@ function getElementBounds({
|
|||
|
||||
if (element.type === "text") {
|
||||
const transform = resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
baseTransform: buildTransformFromParams({ params: element.params }),
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { ParamValues } from "@/params";
|
||||
|
||||
export interface Transform {
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
|
|
@ -26,3 +28,71 @@ export type BlendMode =
|
|||
| "saturation"
|
||||
| "color"
|
||||
| "luminosity";
|
||||
|
||||
export function buildTransformFromParams({
|
||||
params,
|
||||
}: {
|
||||
params: ParamValues;
|
||||
}): Transform {
|
||||
return {
|
||||
scaleX: readNumberParam({ params, key: "transform.scaleX", fallback: 1 }),
|
||||
scaleY: readNumberParam({ params, key: "transform.scaleY", fallback: 1 }),
|
||||
position: {
|
||||
x: readNumberParam({ params, key: "transform.positionX", fallback: 0 }),
|
||||
y: readNumberParam({ params, key: "transform.positionY", fallback: 0 }),
|
||||
},
|
||||
rotate: readNumberParam({ params, key: "transform.rotate", fallback: 0 }),
|
||||
};
|
||||
}
|
||||
|
||||
export function readOpacityFromParams({
|
||||
params,
|
||||
}: {
|
||||
params: ParamValues;
|
||||
}): number {
|
||||
return readNumberParam({ params, key: "opacity", fallback: 1 });
|
||||
}
|
||||
|
||||
export function readBlendModeFromParams({
|
||||
params,
|
||||
}: {
|
||||
params: ParamValues;
|
||||
}): BlendMode {
|
||||
const value = params.blendMode;
|
||||
return typeof value === "string" && isBlendMode(value) ? value : "normal";
|
||||
}
|
||||
|
||||
function readNumberParam({
|
||||
params,
|
||||
key,
|
||||
fallback,
|
||||
}: {
|
||||
params: ParamValues;
|
||||
key: string;
|
||||
fallback: number;
|
||||
}): number {
|
||||
const value = params[key];
|
||||
return typeof value === "number" ? value : fallback;
|
||||
}
|
||||
|
||||
function isBlendMode(value: string): value is BlendMode {
|
||||
return (
|
||||
value === "normal" ||
|
||||
value === "darken" ||
|
||||
value === "multiply" ||
|
||||
value === "color-burn" ||
|
||||
value === "lighten" ||
|
||||
value === "screen" ||
|
||||
value === "plus-lighter" ||
|
||||
value === "color-dodge" ||
|
||||
value === "overlay" ||
|
||||
value === "soft-light" ||
|
||||
value === "hard-light" ||
|
||||
value === "difference" ||
|
||||
value === "exclusion" ||
|
||||
value === "hue" ||
|
||||
value === "saturation" ||
|
||||
value === "color" ||
|
||||
value === "luminosity"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import { BaseNode } from "./base-node";
|
||||
import type { TextElement } from "@/timeline";
|
||||
import type { EffectPass } from "@/effects/types";
|
||||
import type { Transform } from "@/rendering";
|
||||
import type { BlendMode, Transform } from "@/rendering";
|
||||
import { drawMeasuredTextLayout } from "@/text/primitives";
|
||||
import type { MeasuredTextElement } from "@/text/measure-element";
|
||||
|
||||
export type TextNodeParams = TextElement & {
|
||||
transform: Transform;
|
||||
opacity: number;
|
||||
blendMode?: BlendMode;
|
||||
canvasCenter: { x: number; y: number };
|
||||
canvasHeight: number;
|
||||
textBaseline?: CanvasTextBaseline;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
resolveGraphicElementParamsAtTime,
|
||||
} from "@/graphics";
|
||||
import {
|
||||
buildTextBackgroundFromElement,
|
||||
getTextMeasurementContext,
|
||||
measureTextElement,
|
||||
} from "@/text/measure-element";
|
||||
|
|
@ -330,6 +331,7 @@ function resolveTextNode({
|
|||
elementStartTime: node.params.startTime,
|
||||
elementDuration: node.params.duration,
|
||||
});
|
||||
const background = buildTextBackgroundFromElement({ element: node.params });
|
||||
|
||||
return {
|
||||
transform: resolveTransformAtTime({
|
||||
|
|
@ -343,13 +345,16 @@ function resolveTextNode({
|
|||
localTime,
|
||||
}),
|
||||
textColor: resolveColorAtTime({
|
||||
baseColor: node.params.color,
|
||||
baseColor:
|
||||
typeof node.params.params.color === "string"
|
||||
? node.params.params.color
|
||||
: "#ffffff",
|
||||
animations: node.params.animations,
|
||||
propertyPath: "color",
|
||||
localTime,
|
||||
}),
|
||||
backgroundColor: resolveColorAtTime({
|
||||
baseColor: node.params.background.color,
|
||||
baseColor: background.color,
|
||||
animations: node.params.animations,
|
||||
propertyPath: "background.color",
|
||||
localTime,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@ import { EffectLayerNode } from "./nodes/effect-layer-node";
|
|||
import type { AnyBaseNode } from "./nodes/base-node";
|
||||
import type { TBackground, TCanvasSize } from "@/project/types";
|
||||
import { DEFAULT_BACKGROUND_BLUR_INTENSITY } from "@/background/blur";
|
||||
import {
|
||||
buildTransformFromParams,
|
||||
readBlendModeFromParams,
|
||||
readOpacityFromParams,
|
||||
} from "@/rendering";
|
||||
|
||||
const PREVIEW_MAX_IMAGE_SIZE = 2048;
|
||||
|
||||
|
|
@ -71,10 +76,10 @@ function buildTrackNodes({
|
|||
trimStart: element.trimStart,
|
||||
trimEnd: element.trimEnd,
|
||||
retime: element.retime,
|
||||
transform: element.transform,
|
||||
transform: buildTransformFromParams({ params: element.params }),
|
||||
animations: element.animations,
|
||||
opacity: element.opacity,
|
||||
blendMode: element.blendMode,
|
||||
opacity: readOpacityFromParams({ params: element.params }),
|
||||
blendMode: readBlendModeFromParams({ params: element.params }),
|
||||
effects: element.effects ?? [],
|
||||
masks: element.masks ?? [],
|
||||
}),
|
||||
|
|
@ -88,10 +93,10 @@ function buildTrackNodes({
|
|||
timeOffset: element.startTime,
|
||||
trimStart: element.trimStart,
|
||||
trimEnd: element.trimEnd,
|
||||
transform: element.transform,
|
||||
transform: buildTransformFromParams({ params: element.params }),
|
||||
animations: element.animations,
|
||||
opacity: element.opacity,
|
||||
blendMode: element.blendMode,
|
||||
opacity: readOpacityFromParams({ params: element.params }),
|
||||
blendMode: readBlendModeFromParams({ params: element.params }),
|
||||
effects: element.effects ?? [],
|
||||
masks: element.masks ?? [],
|
||||
...(isPreview && {
|
||||
|
|
@ -106,6 +111,9 @@ function buildTrackNodes({
|
|||
nodes.push(
|
||||
new TextNode({
|
||||
...element,
|
||||
transform: buildTransformFromParams({ params: element.params }),
|
||||
opacity: readOpacityFromParams({ params: element.params }),
|
||||
blendMode: readBlendModeFromParams({ params: element.params }),
|
||||
canvasCenter: { x: canvasSize.width / 2, y: canvasSize.height / 2 },
|
||||
canvasHeight: canvasSize.height,
|
||||
textBaseline: "middle",
|
||||
|
|
@ -124,10 +132,10 @@ function buildTrackNodes({
|
|||
timeOffset: element.startTime,
|
||||
trimStart: element.trimStart,
|
||||
trimEnd: element.trimEnd,
|
||||
transform: element.transform,
|
||||
transform: buildTransformFromParams({ params: element.params }),
|
||||
animations: element.animations,
|
||||
opacity: element.opacity,
|
||||
blendMode: element.blendMode,
|
||||
opacity: readOpacityFromParams({ params: element.params }),
|
||||
blendMode: readBlendModeFromParams({ params: element.params }),
|
||||
effects: element.effects ?? [],
|
||||
}),
|
||||
);
|
||||
|
|
@ -142,10 +150,10 @@ function buildTrackNodes({
|
|||
timeOffset: element.startTime,
|
||||
trimStart: element.trimStart,
|
||||
trimEnd: element.trimEnd,
|
||||
transform: element.transform,
|
||||
transform: buildTransformFromParams({ params: element.params }),
|
||||
animations: element.animations,
|
||||
opacity: element.opacity,
|
||||
blendMode: element.blendMode,
|
||||
opacity: readOpacityFromParams({ params: element.params }),
|
||||
blendMode: readBlendModeFromParams({ params: element.params }),
|
||||
effects: element.effects ?? [],
|
||||
masks: element.masks ?? [],
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -27,10 +27,11 @@ import { V24toV25Migration } from "./v24-to-v25";
|
|||
import { V25toV26Migration } from "./v25-to-v26";
|
||||
import { V26toV27Migration } from "./v26-to-v27";
|
||||
import { V27toV28Migration } from "./v27-to-v28";
|
||||
import { V28toV29Migration } from "./v28-to-v29";
|
||||
export { runStorageMigrations } from "./runner";
|
||||
export type { MigrationProgress } from "./runner";
|
||||
|
||||
export const CURRENT_PROJECT_VERSION = 28;
|
||||
export const CURRENT_PROJECT_VERSION = 29;
|
||||
|
||||
export const migrations = [
|
||||
new V0toV1Migration(),
|
||||
|
|
@ -61,4 +62,5 @@ export const migrations = [
|
|||
new V25toV26Migration(),
|
||||
new V26toV27Migration(),
|
||||
new V27toV28Migration(),
|
||||
new V28toV29Migration(),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -4,4 +4,5 @@ export { transformProjectV2ToV3 } from "./v2-to-v3";
|
|||
export { transformProjectV3ToV4 } from "./v3-to-v4";
|
||||
export { transformProjectV4ToV5 } from "./v4-to-v5";
|
||||
export { transformProjectV17ToV18 } from "./v17-to-v18";
|
||||
export { transformProjectV28ToV29 } from "./v28-to-v29";
|
||||
export type { MigrationResult, ProjectRecord } from "./types";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,13 @@ import {
|
|||
} from "@/text/layout";
|
||||
import { DEFAULTS } from "@/timeline/defaults";
|
||||
import { mediaTimeFromSeconds } from "@/wasm";
|
||||
import type { CreateTextElement } from "@/timeline";
|
||||
import type { CreateTextElement, TextBackground } from "@/timeline";
|
||||
import type {
|
||||
TextAlign,
|
||||
TextDecoration,
|
||||
TextFontStyle,
|
||||
TextFontWeight,
|
||||
} from "@/text/primitives";
|
||||
import type { SubtitleCue, SubtitleStyleOverrides } from "./types";
|
||||
|
||||
const SUBTITLE_MAX_WIDTH_RATIO = 0.8;
|
||||
|
|
@ -89,8 +95,8 @@ function measureWrappedTextBlock({
|
|||
ctx: CanvasRenderingContext2D;
|
||||
content: string;
|
||||
canvasHeight: number;
|
||||
textAlign: CreateTextElement["textAlign"];
|
||||
background: CreateTextElement["background"];
|
||||
textAlign: TextAlign;
|
||||
background: TextBackground;
|
||||
fontSize: number;
|
||||
lineHeight: number;
|
||||
}) {
|
||||
|
|
@ -107,7 +113,7 @@ function measureWrappedTextBlock({
|
|||
textAlign,
|
||||
block,
|
||||
background,
|
||||
fontSizeRatio: fontSize / DEFAULTS.text.element.fontSize,
|
||||
fontSizeRatio: fontSize / 15,
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
@ -124,13 +130,13 @@ function resolveSubtitleStyle({
|
|||
fontFamily: string;
|
||||
fontSize: number;
|
||||
color: string;
|
||||
textAlign: CreateTextElement["textAlign"];
|
||||
fontWeight: CreateTextElement["fontWeight"];
|
||||
fontStyle: CreateTextElement["fontStyle"];
|
||||
textDecoration: CreateTextElement["textDecoration"];
|
||||
textAlign: TextAlign;
|
||||
fontWeight: TextFontWeight;
|
||||
fontStyle: TextFontStyle;
|
||||
textDecoration: TextDecoration;
|
||||
letterSpacing: number;
|
||||
lineHeight: number;
|
||||
background: CreateTextElement["background"];
|
||||
background: TextBackground;
|
||||
placement: NonNullable<SubtitleStyleOverrides["placement"]>;
|
||||
} {
|
||||
const fontSize =
|
||||
|
|
@ -139,18 +145,17 @@ function resolveSubtitleStyle({
|
|||
: (style?.fontSize ?? SUBTITLE_FONT_SIZE);
|
||||
|
||||
return {
|
||||
fontFamily: style?.fontFamily ?? DEFAULTS.text.element.fontFamily,
|
||||
fontFamily: style?.fontFamily ?? "Arial",
|
||||
fontSize,
|
||||
color: style?.color ?? DEFAULTS.text.element.color,
|
||||
color: style?.color ?? "#ffffff",
|
||||
textAlign: style?.textAlign ?? "center",
|
||||
fontWeight: style?.fontWeight ?? "bold",
|
||||
fontStyle: style?.fontStyle ?? DEFAULTS.text.element.fontStyle,
|
||||
textDecoration:
|
||||
style?.textDecoration ?? DEFAULTS.text.element.textDecoration,
|
||||
fontStyle: style?.fontStyle ?? "normal",
|
||||
textDecoration: style?.textDecoration ?? "none",
|
||||
letterSpacing: style?.letterSpacing ?? DEFAULTS.text.letterSpacing,
|
||||
lineHeight: style?.lineHeight ?? DEFAULTS.text.lineHeight,
|
||||
background: {
|
||||
...DEFAULTS.text.element.background,
|
||||
...DEFAULTS.text.background,
|
||||
enabled: false,
|
||||
...(style?.background ?? {}),
|
||||
},
|
||||
|
|
@ -188,7 +193,7 @@ function resolvePositionX({
|
|||
visualRect,
|
||||
}: {
|
||||
canvasWidth: number;
|
||||
textAlign: CreateTextElement["textAlign"];
|
||||
textAlign: TextAlign;
|
||||
placement: ReturnType<typeof resolveSubtitleStyle>["placement"];
|
||||
visualRect: { left: number; width: number };
|
||||
}): number {
|
||||
|
|
@ -309,25 +314,34 @@ export function buildSubtitleTextElement({
|
|||
return {
|
||||
...DEFAULTS.text.element,
|
||||
name: `Caption ${index + 1}`,
|
||||
content,
|
||||
duration: mediaTimeFromSeconds({ seconds: caption.duration }),
|
||||
startTime: mediaTimeFromSeconds({ seconds: caption.startTime }),
|
||||
fontSize: style.fontSize,
|
||||
fontFamily: style.fontFamily,
|
||||
color: style.color,
|
||||
textAlign: style.textAlign,
|
||||
fontWeight: style.fontWeight,
|
||||
fontStyle: style.fontStyle,
|
||||
textDecoration: style.textDecoration,
|
||||
letterSpacing: style.letterSpacing,
|
||||
lineHeight: style.lineHeight,
|
||||
background: style.background,
|
||||
transform: {
|
||||
...DEFAULTS.text.element.transform,
|
||||
position: {
|
||||
x: positionX,
|
||||
y: positionY,
|
||||
},
|
||||
params: {
|
||||
...DEFAULTS.text.element.params,
|
||||
content,
|
||||
fontSize: style.fontSize,
|
||||
fontFamily: style.fontFamily,
|
||||
color: style.color,
|
||||
textAlign: style.textAlign,
|
||||
fontWeight: style.fontWeight,
|
||||
fontStyle: style.fontStyle,
|
||||
textDecoration: style.textDecoration,
|
||||
letterSpacing: style.letterSpacing,
|
||||
lineHeight: style.lineHeight,
|
||||
"background.enabled": style.background.enabled,
|
||||
"background.color": style.background.color,
|
||||
"background.cornerRadius":
|
||||
style.background.cornerRadius ?? DEFAULTS.text.background.cornerRadius,
|
||||
"background.paddingX":
|
||||
style.background.paddingX ?? DEFAULTS.text.background.paddingX,
|
||||
"background.paddingY":
|
||||
style.background.paddingY ?? DEFAULTS.text.background.paddingY,
|
||||
"background.offsetX":
|
||||
style.background.offsetX ?? DEFAULTS.text.background.offsetX,
|
||||
"background.offsetY":
|
||||
style.background.offsetY ?? DEFAULTS.text.background.offsetY,
|
||||
"transform.positionX": positionX,
|
||||
"transform.positionY": positionY,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
import type { TextBackground, TextElement } from "@/timeline";
|
||||
import type { TextBackground } from "@/timeline";
|
||||
import type {
|
||||
TextAlign,
|
||||
TextDecoration,
|
||||
TextFontStyle,
|
||||
TextFontWeight,
|
||||
} from "@/text/primitives";
|
||||
import type { CaptionChunk } from "@/transcription/types";
|
||||
|
||||
export interface SubtitlePlacementStyle {
|
||||
|
|
@ -26,10 +32,10 @@ export interface SubtitleStyleOverrides {
|
|||
color?: string;
|
||||
background?: Pick<TextBackground, "enabled" | "color"> &
|
||||
Partial<Omit<TextBackground, "enabled" | "color">>;
|
||||
textAlign?: TextElement["textAlign"];
|
||||
fontWeight?: TextElement["fontWeight"];
|
||||
fontStyle?: TextElement["fontStyle"];
|
||||
textDecoration?: TextElement["textDecoration"];
|
||||
textAlign?: TextAlign;
|
||||
fontWeight?: TextFontWeight;
|
||||
fontStyle?: TextFontStyle;
|
||||
textDecoration?: TextDecoration;
|
||||
letterSpacing?: number;
|
||||
lineHeight?: number;
|
||||
placement?: SubtitlePlacementStyle;
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export function TextView() {
|
|||
id: "temp-text-id",
|
||||
type: DEFAULTS.text.element.type,
|
||||
name: DEFAULTS.text.element.name,
|
||||
content: DEFAULTS.text.element.content,
|
||||
content: "Default text",
|
||||
}}
|
||||
aspectRatio={1}
|
||||
onAddToTimeline={handleAddToTimeline}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { TextBackground, TextElement } from "@/timeline";
|
||||
import type { TextBackground } from "@/timeline";
|
||||
import { DEFAULTS } from "@/timeline/defaults";
|
||||
import type { TextAlign } from "@/text/primitives";
|
||||
|
||||
type TextRect = {
|
||||
left: number;
|
||||
|
|
@ -78,7 +79,7 @@ function getTextRect({
|
|||
textAlign,
|
||||
block,
|
||||
}: {
|
||||
textAlign: TextElement["textAlign"];
|
||||
textAlign: TextAlign;
|
||||
block: TextBlockMeasurement;
|
||||
}): TextRect {
|
||||
const textAlignToLeft: Record<typeof textAlign, number> = {
|
||||
|
|
@ -114,7 +115,7 @@ export function getTextBackgroundRect({
|
|||
background,
|
||||
fontSizeRatio = 1,
|
||||
}: {
|
||||
textAlign: TextElement["textAlign"];
|
||||
textAlign: TextAlign;
|
||||
block: TextBlockMeasurement;
|
||||
background: TextBackground;
|
||||
fontSizeRatio?: number;
|
||||
|
|
@ -145,7 +146,7 @@ export function getTextVisualRect({
|
|||
background,
|
||||
fontSizeRatio = 1,
|
||||
}: {
|
||||
textAlign: TextElement["textAlign"];
|
||||
textAlign: TextAlign;
|
||||
block: TextBlockMeasurement;
|
||||
background: TextBackground;
|
||||
fontSizeRatio?: number;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,11 @@ import {
|
|||
import {
|
||||
measureTextLayout,
|
||||
type MeasuredTextLayout,
|
||||
type TextAlign,
|
||||
type TextDecoration,
|
||||
type TextFontStyle,
|
||||
type TextFontWeight,
|
||||
type TextLayoutParams,
|
||||
} from "./primitives";
|
||||
|
||||
export interface ResolvedTextBackground extends TextBackground {
|
||||
|
|
@ -67,23 +72,14 @@ export function measureTextElement({
|
|||
localTime: number;
|
||||
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
|
||||
}): MeasuredTextElement {
|
||||
const text = buildTextLayoutParamsFromElement({ element });
|
||||
const measuredLayout = measureTextLayout({
|
||||
text: {
|
||||
content: element.content,
|
||||
fontSize: element.fontSize,
|
||||
fontFamily: element.fontFamily,
|
||||
fontWeight: element.fontWeight,
|
||||
fontStyle: element.fontStyle,
|
||||
textAlign: element.textAlign,
|
||||
textDecoration: element.textDecoration,
|
||||
letterSpacing: element.letterSpacing,
|
||||
lineHeight: element.lineHeight,
|
||||
},
|
||||
text,
|
||||
canvasHeight,
|
||||
ctx,
|
||||
});
|
||||
|
||||
const bg = element.background;
|
||||
const bg = buildTextBackgroundFromElement({ element });
|
||||
const resolvedBackground: ResolvedTextBackground = {
|
||||
...bg,
|
||||
paddingX: resolveNumberAtTime({
|
||||
|
|
@ -119,7 +115,7 @@ export function measureTextElement({
|
|||
};
|
||||
|
||||
const visualRect = getTextVisualRect({
|
||||
textAlign: element.textAlign,
|
||||
textAlign: text.textAlign,
|
||||
block: measuredLayout.block,
|
||||
background: resolvedBackground,
|
||||
fontSizeRatio: measuredLayout.fontSizeRatio,
|
||||
|
|
@ -131,3 +127,180 @@ export function measureTextElement({
|
|||
visualRect,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTextLayoutParamsFromElement({
|
||||
element,
|
||||
}: {
|
||||
element: TextElement;
|
||||
}): TextLayoutParams {
|
||||
return {
|
||||
content: readStringParam({
|
||||
params: element.params,
|
||||
key: "content",
|
||||
fallback: "Default text",
|
||||
}),
|
||||
fontSize: readNumberParam({
|
||||
params: element.params,
|
||||
key: "fontSize",
|
||||
fallback: 15,
|
||||
}),
|
||||
fontFamily: readStringParam({
|
||||
params: element.params,
|
||||
key: "fontFamily",
|
||||
fallback: "Arial",
|
||||
}),
|
||||
fontWeight: readFontWeight({
|
||||
value: element.params.fontWeight,
|
||||
fallback: "normal",
|
||||
}),
|
||||
fontStyle: readFontStyle({
|
||||
value: element.params.fontStyle,
|
||||
fallback: "normal",
|
||||
}),
|
||||
textAlign: readTextAlign({
|
||||
value: element.params.textAlign,
|
||||
fallback: "center",
|
||||
}),
|
||||
textDecoration: readTextDecoration({
|
||||
value: element.params.textDecoration,
|
||||
fallback: "none",
|
||||
}),
|
||||
letterSpacing: readNumberParam({
|
||||
params: element.params,
|
||||
key: "letterSpacing",
|
||||
fallback: DEFAULTS.text.letterSpacing,
|
||||
}),
|
||||
lineHeight: readNumberParam({
|
||||
params: element.params,
|
||||
key: "lineHeight",
|
||||
fallback: DEFAULTS.text.lineHeight,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTextBackgroundFromElement({
|
||||
element,
|
||||
}: {
|
||||
element: TextElement;
|
||||
}): TextBackground {
|
||||
return {
|
||||
enabled: readBooleanParam({
|
||||
params: element.params,
|
||||
key: "background.enabled",
|
||||
fallback: DEFAULTS.text.background.enabled,
|
||||
}),
|
||||
color: readStringParam({
|
||||
params: element.params,
|
||||
key: "background.color",
|
||||
fallback: DEFAULTS.text.background.color,
|
||||
}),
|
||||
cornerRadius: readNumberParam({
|
||||
params: element.params,
|
||||
key: "background.cornerRadius",
|
||||
fallback: DEFAULTS.text.background.cornerRadius,
|
||||
}),
|
||||
paddingX: readNumberParam({
|
||||
params: element.params,
|
||||
key: "background.paddingX",
|
||||
fallback: DEFAULTS.text.background.paddingX,
|
||||
}),
|
||||
paddingY: readNumberParam({
|
||||
params: element.params,
|
||||
key: "background.paddingY",
|
||||
fallback: DEFAULTS.text.background.paddingY,
|
||||
}),
|
||||
offsetX: readNumberParam({
|
||||
params: element.params,
|
||||
key: "background.offsetX",
|
||||
fallback: DEFAULTS.text.background.offsetX,
|
||||
}),
|
||||
offsetY: readNumberParam({
|
||||
params: element.params,
|
||||
key: "background.offsetY",
|
||||
fallback: DEFAULTS.text.background.offsetY,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function readStringParam({
|
||||
params,
|
||||
key,
|
||||
fallback,
|
||||
}: {
|
||||
params: TextElement["params"];
|
||||
key: string;
|
||||
fallback: string;
|
||||
}): string {
|
||||
const value = params[key];
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function readNumberParam({
|
||||
params,
|
||||
key,
|
||||
fallback,
|
||||
}: {
|
||||
params: TextElement["params"];
|
||||
key: string;
|
||||
fallback: number;
|
||||
}): number {
|
||||
const value = params[key];
|
||||
return typeof value === "number" ? value : fallback;
|
||||
}
|
||||
|
||||
function readBooleanParam({
|
||||
params,
|
||||
key,
|
||||
fallback,
|
||||
}: {
|
||||
params: TextElement["params"];
|
||||
key: string;
|
||||
fallback: boolean;
|
||||
}): boolean {
|
||||
const value = params[key];
|
||||
return typeof value === "boolean" ? value : fallback;
|
||||
}
|
||||
|
||||
function readTextAlign({
|
||||
value,
|
||||
fallback,
|
||||
}: {
|
||||
value: unknown;
|
||||
fallback: TextAlign;
|
||||
}): TextAlign {
|
||||
return value === "left" || value === "center" || value === "right"
|
||||
? value
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function readFontWeight({
|
||||
value,
|
||||
fallback,
|
||||
}: {
|
||||
value: unknown;
|
||||
fallback: TextFontWeight;
|
||||
}): TextFontWeight {
|
||||
return value === "bold" || value === "normal" ? value : fallback;
|
||||
}
|
||||
|
||||
function readFontStyle({
|
||||
value,
|
||||
fallback,
|
||||
}: {
|
||||
value: unknown;
|
||||
fallback: TextFontStyle;
|
||||
}): TextFontStyle {
|
||||
return value === "italic" || value === "normal" ? value : fallback;
|
||||
}
|
||||
|
||||
function readTextDecoration({
|
||||
value,
|
||||
fallback,
|
||||
}: {
|
||||
value: unknown;
|
||||
fallback: TextDecoration;
|
||||
}): TextDecoration {
|
||||
return value === "none" || value === "underline" || value === "line-through"
|
||||
? value
|
||||
: fallback;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ export function resolveTextLayout({
|
|||
const letterSpacing = text.letterSpacing ?? DEFAULTS.text.letterSpacing;
|
||||
const lineHeightPx =
|
||||
scaledFontSize * (text.lineHeight ?? DEFAULTS.text.lineHeight);
|
||||
const fontSizeRatio = text.fontSize / DEFAULTS.text.element.fontSize;
|
||||
const fontSizeRatio = text.fontSize / 15;
|
||||
|
||||
return {
|
||||
scaledFontSize,
|
||||
|
|
|
|||
|
|
@ -23,8 +23,14 @@ function buildVideoElement(overrides: Partial<VideoElement> = {}): VideoElement
|
|||
trimStart: ZERO_MEDIA_TIME,
|
||||
trimEnd: ZERO_MEDIA_TIME,
|
||||
mediaId: "media-1",
|
||||
transform: buildTransform(),
|
||||
opacity: 1,
|
||||
params: {
|
||||
"transform.positionX": 0,
|
||||
"transform.positionY": 0,
|
||||
"transform.scaleX": 1,
|
||||
"transform.scaleY": 1,
|
||||
"transform.rotate": 0,
|
||||
opacity: 1,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,12 +5,6 @@ import type {
|
|||
AnimationValue,
|
||||
NumericSpec,
|
||||
} from "@/animation/types";
|
||||
import {
|
||||
coerceAnimationParamValue,
|
||||
getAnimationParamDefaultInterpolation,
|
||||
getAnimationParamNumericRange,
|
||||
getAnimationParamValueKind,
|
||||
} from "@/animation/animated-params";
|
||||
import {
|
||||
parseEffectParamPath,
|
||||
} from "@/animation/effect-param-channel";
|
||||
|
|
@ -19,16 +13,22 @@ import {
|
|||
} from "@/animation/graphic-param-channel";
|
||||
import { effectsRegistry, registerDefaultEffects } from "@/effects";
|
||||
import { getGraphicDefinition } from "@/graphics";
|
||||
import type { ParamDefinition } from "@/params";
|
||||
import {
|
||||
coerceParamValue,
|
||||
getParamDefaultInterpolation,
|
||||
getParamNumericRange,
|
||||
getParamValueKind,
|
||||
type ParamDefinition,
|
||||
type ParamValues,
|
||||
} from "@/params";
|
||||
import {
|
||||
getElementParam,
|
||||
readElementParamValue,
|
||||
writeElementParamValue,
|
||||
type ElementParamDefinition,
|
||||
} from "@/params/registry";
|
||||
import type { TimelineElement } from "@/timeline";
|
||||
import { isVisualElement } from "@/timeline/element-utils";
|
||||
import { isAnimationPropertyPath } from "@/animation/path";
|
||||
import {
|
||||
coerceAnimationValueForProperty,
|
||||
getAnimationPropertyDefinition,
|
||||
getElementBaseValueForProperty,
|
||||
withElementBaseValueForProperty,
|
||||
} from "@/animation";
|
||||
|
||||
export interface AnimationPathDescriptor {
|
||||
kind: AnimationBindingKind;
|
||||
|
|
@ -47,10 +47,86 @@ function paramNumericRanges({
|
|||
}: {
|
||||
param: ParamDefinition;
|
||||
}): Partial<Record<string, NumericSpec>> | undefined {
|
||||
const range = getAnimationParamNumericRange({ param });
|
||||
const range = getParamNumericRange({ param });
|
||||
return range ? { value: range } : undefined;
|
||||
}
|
||||
|
||||
function buildParamDescriptor({
|
||||
param,
|
||||
baseParams,
|
||||
setParams,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
baseParams: ParamValues;
|
||||
setParams: (params: ParamValues) => TimelineElement;
|
||||
}): AnimationPathDescriptor | null {
|
||||
if (param.keyframable === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: getParamValueKind({ param }),
|
||||
defaultInterpolation: getParamDefaultInterpolation({ param }),
|
||||
numericRanges: paramNumericRanges({ param }),
|
||||
coerceValue: ({ value }) => coerceParamValue({ param, value }),
|
||||
getBaseValue: () => baseParams[param.key] ?? param.default,
|
||||
setBaseValue: ({ value }) => {
|
||||
const coercedValue = coerceParamValue({ param, value });
|
||||
if (coercedValue === null) {
|
||||
return setParams(baseParams);
|
||||
}
|
||||
|
||||
return setParams({
|
||||
...baseParams,
|
||||
[param.key]: coercedValue,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildElementParamDescriptor({
|
||||
element,
|
||||
paramKey,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
paramKey: string;
|
||||
}): AnimationPathDescriptor | null {
|
||||
const param = getElementParam({ element, key: paramKey });
|
||||
if (!param) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildTimelineElementParamDescriptor({ element, param });
|
||||
}
|
||||
|
||||
function buildTimelineElementParamDescriptor({
|
||||
element,
|
||||
param,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
param: ElementParamDefinition;
|
||||
}): AnimationPathDescriptor | null {
|
||||
if (param.keyframable === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: getParamValueKind({ param }),
|
||||
defaultInterpolation: getParamDefaultInterpolation({ param }),
|
||||
numericRanges: paramNumericRanges({ param }),
|
||||
coerceValue: ({ value }) => coerceParamValue({ param, value }),
|
||||
getBaseValue: () => readElementParamValue({ element, param }),
|
||||
setBaseValue: ({ value }) => {
|
||||
const coercedValue = coerceParamValue({ param, value });
|
||||
if (coercedValue === null) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return writeElementParamValue({ element, param, value: coercedValue });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildGraphicParamDescriptor({
|
||||
element,
|
||||
paramKey,
|
||||
|
|
@ -70,27 +146,14 @@ function buildGraphicParamDescriptor({
|
|||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: getAnimationParamValueKind({ param }),
|
||||
defaultInterpolation: getAnimationParamDefaultInterpolation({ param }),
|
||||
numericRanges: paramNumericRanges({ param }),
|
||||
coerceValue: ({ value }) => coerceAnimationParamValue({ param, value }),
|
||||
getBaseValue: () => element.params[param.key] ?? param.default,
|
||||
setBaseValue: ({ value }) => {
|
||||
const coercedValue = coerceAnimationParamValue({ param, value });
|
||||
if (coercedValue === null) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return {
|
||||
...element,
|
||||
params: {
|
||||
...element.params,
|
||||
[param.key]: coercedValue,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
return buildParamDescriptor({
|
||||
param,
|
||||
baseParams: element.params,
|
||||
setParams: (params) => ({
|
||||
...element,
|
||||
params,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function buildEffectParamDescriptor({
|
||||
|
|
@ -118,35 +181,22 @@ function buildEffectParamDescriptor({
|
|||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: getAnimationParamValueKind({ param }),
|
||||
defaultInterpolation: getAnimationParamDefaultInterpolation({ param }),
|
||||
numericRanges: paramNumericRanges({ param }),
|
||||
coerceValue: ({ value }) => coerceAnimationParamValue({ param, value }),
|
||||
getBaseValue: () => effect.params[param.key] ?? param.default,
|
||||
setBaseValue: ({ value }) => {
|
||||
const coercedValue = coerceAnimationParamValue({ param, value });
|
||||
if (coercedValue === null) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return {
|
||||
...element,
|
||||
effects:
|
||||
element.effects?.map((candidate) =>
|
||||
candidate.id !== effectId
|
||||
? candidate
|
||||
: {
|
||||
...candidate,
|
||||
params: {
|
||||
...candidate.params,
|
||||
[param.key]: coercedValue,
|
||||
},
|
||||
},
|
||||
) ?? element.effects,
|
||||
};
|
||||
},
|
||||
};
|
||||
return buildParamDescriptor({
|
||||
param,
|
||||
baseParams: effect.params,
|
||||
setParams: (params) => ({
|
||||
...element,
|
||||
effects:
|
||||
element.effects?.map((candidate) =>
|
||||
candidate.id !== effectId
|
||||
? candidate
|
||||
: {
|
||||
...candidate,
|
||||
params,
|
||||
},
|
||||
) ?? element.effects,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveAnimationTarget({
|
||||
|
|
@ -156,41 +206,12 @@ export function resolveAnimationTarget({
|
|||
element: TimelineElement;
|
||||
path: AnimationPath;
|
||||
}): AnimationPathDescriptor | null {
|
||||
if (isAnimationPropertyPath(path)) {
|
||||
const propertyDefinition = getAnimationPropertyDefinition({
|
||||
propertyPath: path,
|
||||
});
|
||||
if (!propertyDefinition.supportsElement({ element })) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: propertyDefinition.kind,
|
||||
defaultInterpolation: propertyDefinition.defaultInterpolation,
|
||||
numericRanges: propertyDefinition.numericRanges,
|
||||
coerceValue: ({ value }) =>
|
||||
coerceAnimationValueForProperty({
|
||||
propertyPath: path,
|
||||
value,
|
||||
}),
|
||||
getBaseValue: () =>
|
||||
getElementBaseValueForProperty({
|
||||
element,
|
||||
propertyPath: path,
|
||||
}),
|
||||
setBaseValue: ({ value }) => {
|
||||
const coercedValue = propertyDefinition.coerceValue({ value });
|
||||
if (coercedValue === null) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return withElementBaseValueForProperty({
|
||||
element,
|
||||
propertyPath: path,
|
||||
value: coercedValue,
|
||||
});
|
||||
},
|
||||
};
|
||||
const elementParamTarget = buildElementParamDescriptor({
|
||||
element,
|
||||
paramKey: path,
|
||||
});
|
||||
if (elementParamTarget) {
|
||||
return elementParamTarget;
|
||||
}
|
||||
|
||||
const graphicParamTarget = parseGraphicParamPath({ propertyPath: path });
|
||||
|
|
|
|||
|
|
@ -87,8 +87,13 @@ export function buildSeparatedAudioElement({
|
|||
trimStart: sourceElement.trimStart,
|
||||
trimEnd: sourceElement.trimEnd,
|
||||
sourceDuration: sourceElement.sourceDuration,
|
||||
volume: sourceElement.volume ?? DEFAULTS.element.volume,
|
||||
muted: sourceElement.muted ?? false,
|
||||
params: {
|
||||
volume:
|
||||
typeof sourceElement.params.volume === "number"
|
||||
? sourceElement.params.volume
|
||||
: DEFAULTS.element.volume,
|
||||
muted: sourceElement.params.muted === true,
|
||||
},
|
||||
retime: sourceElement.retime
|
||||
? {
|
||||
rate: sourceElement.retime.rate,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,23 @@ export function dBToLinear(db: number): number {
|
|||
return 10 ** (clampDb(db) / 20);
|
||||
}
|
||||
|
||||
export function getElementVolume({
|
||||
element,
|
||||
}: {
|
||||
element: AudioCapableElement;
|
||||
}): number {
|
||||
const value = element.params.volume;
|
||||
return typeof value === "number" ? value : 0;
|
||||
}
|
||||
|
||||
export function isElementMuted({
|
||||
element,
|
||||
}: {
|
||||
element: AudioCapableElement;
|
||||
}): boolean {
|
||||
return element.params.muted === true;
|
||||
}
|
||||
|
||||
export function hasAnimatedVolume({
|
||||
element,
|
||||
}: {
|
||||
|
|
@ -43,12 +60,12 @@ export function resolveEffectiveAudioGain({
|
|||
trackMuted?: boolean;
|
||||
localTime: number;
|
||||
}): number {
|
||||
if (trackMuted || element.muted === true) {
|
||||
if (trackMuted || isElementMuted({ element })) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const resolvedDb = resolveNumberAtTime({
|
||||
baseValue: element.volume ?? 0,
|
||||
baseValue: getElementVolume({ element }),
|
||||
animations: element.animations,
|
||||
propertyPath: "volume",
|
||||
localTime: Math.round(localTime * TICKS_PER_SECOND),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,13 +3,12 @@
|
|||
import { useCallback, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { DEFAULTS } from "@/timeline/defaults";
|
||||
import {
|
||||
getDbFromLinePos,
|
||||
getLinePosFromDb,
|
||||
} from "@/timeline/audio-display";
|
||||
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
|
||||
import { hasAnimatedVolume } from "@/timeline/audio-state";
|
||||
import { getElementVolume, hasAnimatedVolume } from "@/timeline/audio-state";
|
||||
import type { AudioElement } from "@/timeline/types";
|
||||
import {
|
||||
clamp,
|
||||
|
|
@ -60,10 +59,8 @@ export function AudioVolumeLine({
|
|||
const editor = useEditor();
|
||||
const surfaceRef = useRef<HTMLDivElement>(null);
|
||||
const activePointerIdRef = useRef<number | null>(null);
|
||||
const startVolumeRef = useRef(element.volume ?? DEFAULTS.element.volume);
|
||||
const lastPreviewVolumeRef = useRef(
|
||||
element.volume ?? DEFAULTS.element.volume,
|
||||
);
|
||||
const startVolumeRef = useRef(getElementVolume({ element }));
|
||||
const lastPreviewVolumeRef = useRef(getElementVolume({ element }));
|
||||
const hasChangedRef = useRef(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [tooltipClientPos, setTooltipClientPos] = useState<{
|
||||
|
|
@ -72,7 +69,7 @@ export function AudioVolumeLine({
|
|||
} | null>(null);
|
||||
|
||||
const hasAnimatedEnvelope = hasAnimatedVolume({ element });
|
||||
const currentVolume = element.volume ?? DEFAULTS.element.volume;
|
||||
const currentVolume = getElementVolume({ element });
|
||||
const lineTop = `${getLinePosFromDb({ db: currentVolume })}%`;
|
||||
|
||||
const volumeLabel = `${formatNumberForDisplay({
|
||||
|
|
@ -96,7 +93,7 @@ export function AudioVolumeLine({
|
|||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { volume: nextVolume },
|
||||
updates: { params: { volume: nextVolume } },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ import {
|
|||
getSourceAudioActionLabel,
|
||||
isSourceAudioSeparated,
|
||||
} from "@/timeline/audio-separation";
|
||||
import { buildWaveformGainSamples } from "@/timeline/audio-state";
|
||||
import { buildWaveformGainSamples, isElementMuted } from "@/timeline/audio-state";
|
||||
import { getTimelinePixelsPerSecond } from "@/timeline";
|
||||
import { buildWaveformSourceKey } from "@/media/waveform-summary";
|
||||
import { addMediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm";
|
||||
|
|
@ -335,7 +335,7 @@ export function TimelineElement({
|
|||
}
|
||||
};
|
||||
|
||||
const isMuted = canElementHaveAudio(element) && element.muted === true;
|
||||
const isMuted = canElementHaveAudio(element) && isElementMuted({ element });
|
||||
const canToggleCurrentSourceAudio =
|
||||
selectedElements.length === 1 &&
|
||||
isCurrentElementSelected &&
|
||||
|
|
@ -908,7 +908,9 @@ function TextElementContent({
|
|||
}) {
|
||||
return (
|
||||
<div className="flex size-full items-center justify-start pl-2">
|
||||
<span className="truncate text-xs text-white">{element.content}</span>
|
||||
<span className="truncate text-xs text-white">
|
||||
{typeof element.params.content === "string" ? element.params.content : ""}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -385,7 +385,10 @@ export class DragDropController {
|
|||
dragData: Extract<TimelineDragData, { type: "text" }>;
|
||||
}): void {
|
||||
const element = buildTextElement({
|
||||
raw: { name: dragData.name ?? "", content: dragData.content ?? "" },
|
||||
raw: {
|
||||
name: dragData.name ?? "",
|
||||
params: { content: dragData.content ?? "" },
|
||||
},
|
||||
startTime: target.xPosition,
|
||||
});
|
||||
this.insertAtTarget({ element, target, trackType: "text" });
|
||||
|
|
|
|||
|
|
@ -31,26 +31,36 @@ const defaultTextBackground = {
|
|||
const defaultTextElement: Omit<TextElement, "id"> = {
|
||||
type: "text",
|
||||
name: "Text",
|
||||
content: "Default text",
|
||||
fontSize: 15,
|
||||
fontFamily: "Arial",
|
||||
color: "#ffffff",
|
||||
background: { ...defaultTextBackground },
|
||||
textAlign: "center",
|
||||
fontWeight: "normal",
|
||||
fontStyle: "normal",
|
||||
textDecoration: "none",
|
||||
letterSpacing: defaultTextLetterSpacing,
|
||||
lineHeight: defaultTextLineHeight,
|
||||
duration: DEFAULT_NEW_ELEMENT_DURATION,
|
||||
startTime: ZERO_MEDIA_TIME,
|
||||
trimStart: ZERO_MEDIA_TIME,
|
||||
trimEnd: ZERO_MEDIA_TIME,
|
||||
transform: {
|
||||
...defaultTransform,
|
||||
position: { ...defaultTransform.position },
|
||||
params: {
|
||||
content: "Default text",
|
||||
fontSize: 15,
|
||||
fontFamily: "Arial",
|
||||
color: "#ffffff",
|
||||
textAlign: "center",
|
||||
fontWeight: "normal",
|
||||
fontStyle: "normal",
|
||||
textDecoration: "none",
|
||||
letterSpacing: defaultTextLetterSpacing,
|
||||
lineHeight: defaultTextLineHeight,
|
||||
"background.enabled": defaultTextBackground.enabled,
|
||||
"background.color": defaultTextBackground.color,
|
||||
"background.cornerRadius": defaultTextBackground.cornerRadius,
|
||||
"background.paddingX": defaultTextBackground.paddingX,
|
||||
"background.paddingY": defaultTextBackground.paddingY,
|
||||
"background.offsetX": defaultTextBackground.offsetX,
|
||||
"background.offsetY": defaultTextBackground.offsetY,
|
||||
"transform.positionX": defaultTransform.position.x,
|
||||
"transform.positionY": defaultTransform.position.y,
|
||||
"transform.scaleX": defaultTransform.scaleX,
|
||||
"transform.scaleY": defaultTransform.scaleY,
|
||||
"transform.rotate": defaultTransform.rotate,
|
||||
opacity: defaultOpacity,
|
||||
blendMode: defaultBlendMode,
|
||||
},
|
||||
opacity: defaultOpacity,
|
||||
};
|
||||
|
||||
const defaultTimelineViewState: TTimelineViewState = {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import {
|
|||
type CreateStickerElement,
|
||||
type CreateUploadAudioElement,
|
||||
type CreateLibraryAudioElement,
|
||||
type TextBackground,
|
||||
type TextElement,
|
||||
type SceneTracks,
|
||||
type TimelineElement,
|
||||
|
|
@ -28,6 +27,10 @@ import type { MediaType } from "@/media/types";
|
|||
import { buildDefaultEffectInstance } from "@/effects";
|
||||
import { buildDefaultGraphicInstance } from "@/graphics";
|
||||
import type { ParamValues } from "@/params";
|
||||
import {
|
||||
buildDefaultParamValues,
|
||||
getBuiltInElementParams,
|
||||
} from "@/params/registry";
|
||||
import { capitalizeFirstLetter } from "@/utils/string";
|
||||
import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
|
||||
|
||||
|
|
@ -87,20 +90,12 @@ export function requiresMediaId({
|
|||
);
|
||||
}
|
||||
|
||||
function buildTextBackground(
|
||||
raw: Partial<TextBackground> | undefined,
|
||||
): TextBackground {
|
||||
const color = raw?.color ?? DEFAULTS.text.element.background.color;
|
||||
const enabled = raw?.enabled ?? color !== "transparent";
|
||||
return {
|
||||
enabled,
|
||||
color,
|
||||
cornerRadius: raw?.cornerRadius,
|
||||
paddingX: raw?.paddingX,
|
||||
paddingY: raw?.paddingY,
|
||||
offsetX: raw?.offsetX,
|
||||
offsetY: raw?.offsetY,
|
||||
};
|
||||
function buildDefaultElementParams({
|
||||
type,
|
||||
}: {
|
||||
type: TimelineElement["type"];
|
||||
}): ParamValues {
|
||||
return buildDefaultParamValues(getBuiltInElementParams({ type }));
|
||||
}
|
||||
|
||||
export function buildTextElement({
|
||||
|
|
@ -115,24 +110,14 @@ export function buildTextElement({
|
|||
return {
|
||||
type: "text",
|
||||
name: t.name ?? DEFAULTS.text.element.name,
|
||||
content: t.content ?? DEFAULTS.text.element.content,
|
||||
duration: t.duration ?? DEFAULT_NEW_ELEMENT_DURATION,
|
||||
startTime,
|
||||
trimStart: ZERO_MEDIA_TIME,
|
||||
trimEnd: ZERO_MEDIA_TIME,
|
||||
fontSize: t.fontSize ?? DEFAULTS.text.element.fontSize,
|
||||
fontFamily: t.fontFamily ?? DEFAULTS.text.element.fontFamily,
|
||||
color: t.color ?? DEFAULTS.text.element.color,
|
||||
background: buildTextBackground(t.background),
|
||||
textAlign: t.textAlign ?? DEFAULTS.text.element.textAlign,
|
||||
fontWeight: t.fontWeight ?? DEFAULTS.text.element.fontWeight,
|
||||
fontStyle: t.fontStyle ?? DEFAULTS.text.element.fontStyle,
|
||||
textDecoration: t.textDecoration ?? DEFAULTS.text.element.textDecoration,
|
||||
letterSpacing: t.letterSpacing ?? DEFAULTS.text.element.letterSpacing,
|
||||
lineHeight: t.lineHeight ?? DEFAULTS.text.element.lineHeight,
|
||||
transform: t.transform ?? DEFAULTS.text.element.transform,
|
||||
opacity: t.opacity ?? DEFAULTS.text.element.opacity,
|
||||
blendMode: t.blendMode ?? DEFAULTS.element.blendMode,
|
||||
params: {
|
||||
...buildDefaultElementParams({ type: "text" }),
|
||||
...(t.params ?? {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -183,12 +168,7 @@ export function buildStickerElement({
|
|||
startTime,
|
||||
trimStart: ZERO_MEDIA_TIME,
|
||||
trimEnd: ZERO_MEDIA_TIME,
|
||||
transform: {
|
||||
...DEFAULTS.element.transform,
|
||||
position: { ...DEFAULTS.element.transform.position },
|
||||
},
|
||||
opacity: DEFAULTS.element.opacity,
|
||||
blendMode: DEFAULTS.element.blendMode,
|
||||
params: buildDefaultElementParams({ type: "sticker" }),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -208,20 +188,36 @@ export function buildGraphicElement({
|
|||
type: "graphic",
|
||||
name: name ?? capitalizeFirstLetter({ string: instance.definitionId }),
|
||||
definitionId: instance.definitionId,
|
||||
params: { ...instance.params, ...(params ?? {}) } as ParamValues,
|
||||
params: mergeParamValues({
|
||||
base: {
|
||||
...buildDefaultElementParams({ type: "graphic" }),
|
||||
...instance.params,
|
||||
},
|
||||
overrides: params,
|
||||
}),
|
||||
duration: DEFAULT_NEW_ELEMENT_DURATION,
|
||||
startTime,
|
||||
trimStart: ZERO_MEDIA_TIME,
|
||||
trimEnd: ZERO_MEDIA_TIME,
|
||||
transform: {
|
||||
...DEFAULTS.element.transform,
|
||||
position: { ...DEFAULTS.element.transform.position },
|
||||
},
|
||||
opacity: DEFAULTS.element.opacity,
|
||||
blendMode: DEFAULTS.element.blendMode,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeParamValues({
|
||||
base,
|
||||
overrides,
|
||||
}: {
|
||||
base: ParamValues;
|
||||
overrides?: Partial<ParamValues>;
|
||||
}): ParamValues {
|
||||
const result: ParamValues = { ...base };
|
||||
for (const [key, value] of Object.entries(overrides ?? {})) {
|
||||
if (value !== undefined) {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildVideoElement({
|
||||
mediaId,
|
||||
name,
|
||||
|
|
@ -242,16 +238,9 @@ function buildVideoElement({
|
|||
trimStart: ZERO_MEDIA_TIME,
|
||||
trimEnd: ZERO_MEDIA_TIME,
|
||||
sourceDuration: duration,
|
||||
muted: false,
|
||||
isSourceAudioEnabled: true,
|
||||
hidden: false,
|
||||
transform: {
|
||||
...DEFAULTS.element.transform,
|
||||
position: { ...DEFAULTS.element.transform.position },
|
||||
},
|
||||
opacity: DEFAULTS.element.opacity,
|
||||
blendMode: DEFAULTS.element.blendMode,
|
||||
volume: DEFAULTS.element.volume,
|
||||
params: buildDefaultElementParams({ type: "video" }),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -275,12 +264,7 @@ function buildImageElement({
|
|||
trimStart: ZERO_MEDIA_TIME,
|
||||
trimEnd: ZERO_MEDIA_TIME,
|
||||
hidden: false,
|
||||
transform: {
|
||||
...DEFAULTS.element.transform,
|
||||
position: { ...DEFAULTS.element.transform.position },
|
||||
},
|
||||
opacity: DEFAULTS.element.opacity,
|
||||
blendMode: DEFAULTS.element.blendMode,
|
||||
params: buildDefaultElementParams({ type: "image" }),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -307,8 +291,7 @@ function buildUploadAudioElement({
|
|||
trimStart: ZERO_MEDIA_TIME,
|
||||
trimEnd: ZERO_MEDIA_TIME,
|
||||
sourceDuration: duration,
|
||||
volume: DEFAULTS.element.volume,
|
||||
muted: false,
|
||||
params: buildDefaultElementParams({ type: "audio" }),
|
||||
};
|
||||
if (buffer) {
|
||||
element.buffer = buffer;
|
||||
|
|
@ -370,8 +353,7 @@ export function buildLibraryAudioElement({
|
|||
trimStart: ZERO_MEDIA_TIME,
|
||||
trimEnd: ZERO_MEDIA_TIME,
|
||||
sourceDuration: duration,
|
||||
volume: DEFAULTS.element.volume,
|
||||
muted: false,
|
||||
params: buildDefaultElementParams({ type: "audio" }),
|
||||
};
|
||||
if (buffer) {
|
||||
element.buffer = buffer;
|
||||
|
|
@ -411,8 +393,8 @@ export function getElementFontFamilies({
|
|||
const families = new Set<string>();
|
||||
for (const track of [...tracks.overlay, tracks.main, ...tracks.audio]) {
|
||||
for (const element of track.elements) {
|
||||
if (element.type === "text" && element.fontFamily) {
|
||||
families.add(element.fontFamily);
|
||||
if (element.type === "text" && typeof element.params.fontFamily === "string") {
|
||||
families.add(element.params.fontFamily);
|
||||
}
|
||||
if ("masks" in element) {
|
||||
for (const mask of element.masks ?? []) {
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ function buildElement({
|
|||
duration: mediaTime({ ticks: duration }),
|
||||
trimStart: ZERO_MEDIA_TIME,
|
||||
trimEnd: ZERO_MEDIA_TIME,
|
||||
volume: 1,
|
||||
params: { volume: 1, muted: false },
|
||||
sourceType: "upload",
|
||||
mediaId: `media-${id}`,
|
||||
} satisfies AudioElement;
|
||||
|
|
@ -86,9 +86,14 @@ function buildElement({
|
|||
trimStart: ZERO_MEDIA_TIME,
|
||||
trimEnd: ZERO_MEDIA_TIME,
|
||||
definitionId: `graphic-${id}`,
|
||||
params: {},
|
||||
transform: buildTransform(),
|
||||
opacity: 1,
|
||||
params: {
|
||||
"transform.positionX": 0,
|
||||
"transform.positionY": 0,
|
||||
"transform.scaleX": 1,
|
||||
"transform.scaleY": 1,
|
||||
"transform.rotate": 0,
|
||||
opacity: 1,
|
||||
},
|
||||
} satisfies GraphicElement;
|
||||
case "text":
|
||||
return {
|
||||
|
|
@ -99,20 +104,24 @@ function buildElement({
|
|||
duration: mediaTime({ ticks: duration }),
|
||||
trimStart: ZERO_MEDIA_TIME,
|
||||
trimEnd: ZERO_MEDIA_TIME,
|
||||
content: id,
|
||||
fontSize: 32,
|
||||
fontFamily: "sans-serif",
|
||||
color: "#ffffff",
|
||||
background: {
|
||||
enabled: false,
|
||||
color: "#000000",
|
||||
params: {
|
||||
content: id,
|
||||
fontSize: 32,
|
||||
fontFamily: "sans-serif",
|
||||
color: "#ffffff",
|
||||
"background.enabled": false,
|
||||
"background.color": "#000000",
|
||||
textAlign: "left",
|
||||
fontWeight: "normal",
|
||||
fontStyle: "normal",
|
||||
textDecoration: "none",
|
||||
"transform.positionX": 0,
|
||||
"transform.positionY": 0,
|
||||
"transform.scaleX": 1,
|
||||
"transform.scaleY": 1,
|
||||
"transform.rotate": 0,
|
||||
opacity: 1,
|
||||
},
|
||||
textAlign: "left",
|
||||
fontWeight: "normal",
|
||||
fontStyle: "normal",
|
||||
textDecoration: "none",
|
||||
transform: buildTransform(),
|
||||
opacity: 1,
|
||||
} satisfies TextElement;
|
||||
case "video":
|
||||
return {
|
||||
|
|
@ -124,8 +133,14 @@ function buildElement({
|
|||
trimStart: ZERO_MEDIA_TIME,
|
||||
trimEnd: ZERO_MEDIA_TIME,
|
||||
mediaId: `media-${id}`,
|
||||
transform: buildTransform(),
|
||||
opacity: 1,
|
||||
params: {
|
||||
"transform.positionX": 0,
|
||||
"transform.positionY": 0,
|
||||
"transform.scaleX": 1,
|
||||
"transform.scaleY": 1,
|
||||
"transform.rotate": 0,
|
||||
opacity: 1,
|
||||
},
|
||||
} satisfies VideoElement;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import type { ElementAnimations } from "@/animation/types";
|
|||
import type { Effect } from "@/effects/types";
|
||||
import type { Mask } from "@/masks/types";
|
||||
import type { ParamValues } from "@/params";
|
||||
import type { BlendMode, Transform } from "@/rendering";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export type ElementRef = {
|
||||
|
|
@ -87,8 +86,6 @@ export interface RetimeConfig {
|
|||
|
||||
interface BaseAudioElement extends BaseTimelineElement {
|
||||
type: "audio";
|
||||
volume: number;
|
||||
muted?: boolean;
|
||||
buffer?: AudioBuffer;
|
||||
retime?: RetimeConfig;
|
||||
}
|
||||
|
|
@ -114,6 +111,7 @@ interface BaseTimelineElement {
|
|||
trimEnd: MediaTime;
|
||||
sourceDuration?: MediaTime;
|
||||
animations?: ElementAnimations;
|
||||
params: ParamValues;
|
||||
}
|
||||
|
||||
export interface VideoElement extends BaseTimelineElement {
|
||||
|
|
@ -124,9 +122,6 @@ export interface VideoElement extends BaseTimelineElement {
|
|||
isSourceAudioEnabled?: boolean;
|
||||
hidden?: boolean;
|
||||
retime?: RetimeConfig;
|
||||
transform: Transform;
|
||||
opacity: number;
|
||||
blendMode?: BlendMode;
|
||||
effects?: Effect[];
|
||||
masks?: Mask[];
|
||||
}
|
||||
|
|
@ -135,9 +130,6 @@ export interface ImageElement extends BaseTimelineElement {
|
|||
type: "image";
|
||||
mediaId: string;
|
||||
hidden?: boolean;
|
||||
transform: Transform;
|
||||
opacity: number;
|
||||
blendMode?: BlendMode;
|
||||
effects?: Effect[];
|
||||
masks?: Mask[];
|
||||
}
|
||||
|
|
@ -154,21 +146,7 @@ export interface TextBackground {
|
|||
|
||||
export interface TextElement extends BaseTimelineElement {
|
||||
type: "text";
|
||||
content: string;
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
color: string;
|
||||
background: TextBackground;
|
||||
textAlign: "left" | "center" | "right";
|
||||
fontWeight: "normal" | "bold";
|
||||
fontStyle: "normal" | "italic";
|
||||
textDecoration: "none" | "underline" | "line-through";
|
||||
letterSpacing?: number;
|
||||
lineHeight?: number;
|
||||
hidden?: boolean;
|
||||
transform: Transform;
|
||||
opacity: number;
|
||||
blendMode?: BlendMode;
|
||||
effects?: Effect[];
|
||||
}
|
||||
|
||||
|
|
@ -179,20 +157,13 @@ export interface StickerElement extends BaseTimelineElement {
|
|||
intrinsicWidth?: number;
|
||||
intrinsicHeight?: number;
|
||||
hidden?: boolean;
|
||||
transform: Transform;
|
||||
opacity: number;
|
||||
blendMode?: BlendMode;
|
||||
effects?: Effect[];
|
||||
}
|
||||
|
||||
export interface GraphicElement extends BaseTimelineElement {
|
||||
type: "graphic";
|
||||
definitionId: string;
|
||||
params: ParamValues;
|
||||
hidden?: boolean;
|
||||
transform: Transform;
|
||||
opacity: number;
|
||||
blendMode?: BlendMode;
|
||||
effects?: Effect[];
|
||||
masks?: Mask[];
|
||||
}
|
||||
|
|
@ -200,13 +171,9 @@ export interface GraphicElement extends BaseTimelineElement {
|
|||
export interface EffectElement extends BaseTimelineElement {
|
||||
type: "effect";
|
||||
effectType: string;
|
||||
params: ParamValues;
|
||||
}
|
||||
|
||||
export type ElementUpdatePatch =
|
||||
| { transform: Transform }
|
||||
| { opacity: number }
|
||||
| { volume: number };
|
||||
export type ElementUpdatePatch = { params?: Partial<ParamValues> };
|
||||
|
||||
export type TimelineElement =
|
||||
| AudioElement
|
||||
|
|
|
|||
|
|
@ -141,7 +141,14 @@ export function applyElementUpdate({
|
|||
patch: Partial<TimelineElement>;
|
||||
context: ElementUpdateContext;
|
||||
}): TimelineElement {
|
||||
let nextElement = { ...element, ...patch } as TimelineElement;
|
||||
let nextElement = {
|
||||
...element,
|
||||
...patch,
|
||||
params: {
|
||||
...element.params,
|
||||
...(patch.params ?? {}),
|
||||
},
|
||||
} as TimelineElement;
|
||||
const changedFields = new Set(
|
||||
Object.keys(patch) as ElementUpdateField[],
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue