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