refactor: split animation helpers by domain
This commit is contained in:
parent
6a22a3ecbd
commit
0b7597b31f
|
|
@ -0,0 +1,241 @@
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
coerceAnimationParamValue,
|
||||||
|
getAnimationParamDefaultInterpolation,
|
||||||
|
getAnimationParamNumericRange,
|
||||||
|
getAnimationParamValueKind,
|
||||||
|
} from "@/animation/animated-params";
|
||||||
|
|
||||||
|
describe("animated params", () => {
|
||||||
|
test("snaps and clamps number params", () => {
|
||||||
|
expect(
|
||||||
|
coerceAnimationParamValue({
|
||||||
|
param: {
|
||||||
|
key: "intensity",
|
||||||
|
label: "Intensity",
|
||||||
|
type: "number",
|
||||||
|
default: 0,
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
step: 0.25,
|
||||||
|
},
|
||||||
|
value: 0.62,
|
||||||
|
}),
|
||||||
|
).toBe(0.5);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
coerceAnimationParamValue({
|
||||||
|
param: {
|
||||||
|
key: "intensity",
|
||||||
|
label: "Intensity",
|
||||||
|
type: "number",
|
||||||
|
default: 0,
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
step: 0.25,
|
||||||
|
},
|
||||||
|
value: 1.2,
|
||||||
|
}),
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects NaN and non-number values for number params", () => {
|
||||||
|
const param = {
|
||||||
|
key: "intensity",
|
||||||
|
label: "Intensity",
|
||||||
|
type: "number" as const,
|
||||||
|
default: 0,
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
step: 0.25,
|
||||||
|
};
|
||||||
|
expect(coerceAnimationParamValue({ param, value: Number.NaN })).toBeNull();
|
||||||
|
expect(coerceAnimationParamValue({ param, value: "0.5" })).toBeNull();
|
||||||
|
expect(coerceAnimationParamValue({ param, value: true })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("passthrough with step <= 0 guard", () => {
|
||||||
|
expect(
|
||||||
|
coerceAnimationParamValue({
|
||||||
|
param: {
|
||||||
|
key: "x",
|
||||||
|
label: "X",
|
||||||
|
type: "number",
|
||||||
|
default: 0,
|
||||||
|
min: 0,
|
||||||
|
step: 0,
|
||||||
|
},
|
||||||
|
value: 0.123,
|
||||||
|
}),
|
||||||
|
).toBe(0.123);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts valid select values", () => {
|
||||||
|
const param = {
|
||||||
|
key: "blend",
|
||||||
|
label: "Blend",
|
||||||
|
type: "select" as const,
|
||||||
|
default: "normal",
|
||||||
|
options: [
|
||||||
|
{ value: "normal", label: "Normal" },
|
||||||
|
{ value: "multiply", label: "Multiply" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(coerceAnimationParamValue({ param, value: "normal" })).toBe("normal");
|
||||||
|
expect(coerceAnimationParamValue({ param, value: "multiply" })).toBe("multiply");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects select values outside the allowed options", () => {
|
||||||
|
expect(
|
||||||
|
coerceAnimationParamValue({
|
||||||
|
param: {
|
||||||
|
key: "blend",
|
||||||
|
label: "Blend",
|
||||||
|
type: "select",
|
||||||
|
default: "normal",
|
||||||
|
options: [
|
||||||
|
{ value: "normal", label: "Normal" },
|
||||||
|
{ value: "multiply", label: "Multiply" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
value: "screen",
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects non-string select values", () => {
|
||||||
|
const param = {
|
||||||
|
key: "blend",
|
||||||
|
label: "Blend",
|
||||||
|
type: "select" as const,
|
||||||
|
default: "normal",
|
||||||
|
options: [{ value: "normal", label: "Normal" }],
|
||||||
|
};
|
||||||
|
expect(coerceAnimationParamValue({ param, value: 42 })).toBeNull();
|
||||||
|
expect(coerceAnimationParamValue({ param, value: null })).toBeNull();
|
||||||
|
expect(coerceAnimationParamValue({ param, value: undefined })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("boolean params accept booleans and reject other types", () => {
|
||||||
|
const param = {
|
||||||
|
key: "visible",
|
||||||
|
label: "Visible",
|
||||||
|
type: "boolean" as const,
|
||||||
|
default: true,
|
||||||
|
};
|
||||||
|
expect(coerceAnimationParamValue({ param, value: true })).toBe(true);
|
||||||
|
expect(coerceAnimationParamValue({ param, value: false })).toBe(false);
|
||||||
|
expect(coerceAnimationParamValue({ param, value: 1 })).toBeNull();
|
||||||
|
expect(coerceAnimationParamValue({ param, value: "true" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("color params accept strings and reject other types", () => {
|
||||||
|
const param = {
|
||||||
|
key: "fill",
|
||||||
|
label: "Fill",
|
||||||
|
type: "color" as const,
|
||||||
|
default: "#ffffff",
|
||||||
|
};
|
||||||
|
expect(coerceAnimationParamValue({ param, value: "#ff0000" })).toBe("#ff0000");
|
||||||
|
expect(coerceAnimationParamValue({ param, value: 0xff0000 })).toBeNull();
|
||||||
|
expect(coerceAnimationParamValue({ param, value: null })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getAnimationParamValueKind maps param type to binding kind", () => {
|
||||||
|
expect(
|
||||||
|
getAnimationParamValueKind({
|
||||||
|
param: {
|
||||||
|
key: "n",
|
||||||
|
label: "N",
|
||||||
|
type: "number",
|
||||||
|
default: 0,
|
||||||
|
min: 0,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBe("number");
|
||||||
|
expect(
|
||||||
|
getAnimationParamValueKind({
|
||||||
|
param: { key: "c", label: "C", type: "color", default: "#fff" },
|
||||||
|
}),
|
||||||
|
).toBe("color");
|
||||||
|
expect(
|
||||||
|
getAnimationParamValueKind({
|
||||||
|
param: { key: "b", label: "B", type: "boolean", default: false },
|
||||||
|
}),
|
||||||
|
).toBe("discrete");
|
||||||
|
expect(
|
||||||
|
getAnimationParamValueKind({
|
||||||
|
param: {
|
||||||
|
key: "s",
|
||||||
|
label: "S",
|
||||||
|
type: "select",
|
||||||
|
default: "a",
|
||||||
|
options: [{ value: "a", label: "A" }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBe("discrete");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getAnimationParamDefaultInterpolation is linear for continuous, hold for discrete", () => {
|
||||||
|
expect(
|
||||||
|
getAnimationParamDefaultInterpolation({
|
||||||
|
param: {
|
||||||
|
key: "n",
|
||||||
|
label: "N",
|
||||||
|
type: "number",
|
||||||
|
default: 0,
|
||||||
|
min: 0,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBe("linear");
|
||||||
|
expect(
|
||||||
|
getAnimationParamDefaultInterpolation({
|
||||||
|
param: { key: "c", label: "C", type: "color", default: "#fff" },
|
||||||
|
}),
|
||||||
|
).toBe("linear");
|
||||||
|
expect(
|
||||||
|
getAnimationParamDefaultInterpolation({
|
||||||
|
param: { key: "b", label: "B", type: "boolean", default: false },
|
||||||
|
}),
|
||||||
|
).toBe("hold");
|
||||||
|
expect(
|
||||||
|
getAnimationParamDefaultInterpolation({
|
||||||
|
param: {
|
||||||
|
key: "s",
|
||||||
|
label: "S",
|
||||||
|
type: "select",
|
||||||
|
default: "a",
|
||||||
|
options: [{ value: "a", label: "A" }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBe("hold");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getAnimationParamNumericRange returns spec for number params, undefined otherwise", () => {
|
||||||
|
expect(
|
||||||
|
getAnimationParamNumericRange({
|
||||||
|
param: {
|
||||||
|
key: "intensity",
|
||||||
|
label: "Intensity",
|
||||||
|
type: "number",
|
||||||
|
default: 0.5,
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
step: 0.1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({ min: 0, max: 1, step: 0.1 });
|
||||||
|
expect(
|
||||||
|
getAnimationParamNumericRange({
|
||||||
|
param: { key: "c", label: "C", type: "color", default: "#fff" },
|
||||||
|
}),
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
getAnimationParamNumericRange({
|
||||||
|
param: { key: "b", label: "B", type: "boolean", default: false },
|
||||||
|
}),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import type { ParamValues } from "@/params";
|
|
||||||
import type { Effect } from "@/effects/types";
|
|
||||||
import type {
|
import type {
|
||||||
ElementAnimations,
|
ElementAnimations,
|
||||||
EffectParamPath,
|
EffectParamPath,
|
||||||
} from "@/animation/types";
|
} from "@/animation/types";
|
||||||
|
import type { ParamValues } from "@/params";
|
||||||
import { removeElementKeyframe } from "./keyframes";
|
import { removeElementKeyframe } from "./keyframes";
|
||||||
import { resolveAnimationPathValueAtTime } from "./resolve";
|
import { resolveAnimationPathValueAtTime } from "./resolve";
|
||||||
|
|
||||||
|
|
@ -56,23 +55,26 @@ export function parseEffectParamPath({
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveEffectParamsAtTime({
|
export function resolveEffectParamsAtTime({
|
||||||
effect,
|
effectId,
|
||||||
|
params,
|
||||||
animations,
|
animations,
|
||||||
localTime,
|
localTime,
|
||||||
}: {
|
}: {
|
||||||
effect: Effect;
|
effectId: string;
|
||||||
|
params: ParamValues;
|
||||||
animations: ElementAnimations | undefined;
|
animations: ElementAnimations | undefined;
|
||||||
localTime: number;
|
localTime: number;
|
||||||
}): ParamValues {
|
}): ParamValues {
|
||||||
|
const safeLocalTime = Math.max(0, localTime);
|
||||||
const resolved: ParamValues = {};
|
const resolved: ParamValues = {};
|
||||||
|
|
||||||
for (const [paramKey, staticValue] of Object.entries(effect.params)) {
|
for (const [paramKey, staticValue] of Object.entries(params)) {
|
||||||
const path = buildEffectParamPath({ effectId: effect.id, paramKey });
|
const path = buildEffectParamPath({ effectId, paramKey });
|
||||||
resolved[paramKey] = animations?.bindings[path]
|
resolved[paramKey] = animations?.bindings[path]
|
||||||
? resolveAnimationPathValueAtTime({
|
? resolveAnimationPathValueAtTime({
|
||||||
animations,
|
animations,
|
||||||
propertyPath: path,
|
propertyPath: path,
|
||||||
localTime,
|
localTime: safeLocalTime,
|
||||||
fallbackValue: staticValue,
|
fallbackValue: staticValue,
|
||||||
})
|
})
|
||||||
: staticValue;
|
: staticValue;
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,7 @@ import type {
|
||||||
ElementAnimations,
|
ElementAnimations,
|
||||||
GraphicParamPath,
|
GraphicParamPath,
|
||||||
} from "@/animation/types";
|
} from "@/animation/types";
|
||||||
import type { ParamValues } from "@/params";
|
import type { ParamDefinition, ParamValues } from "@/params";
|
||||||
import {
|
|
||||||
getGraphicDefinition,
|
|
||||||
resolveGraphicParams,
|
|
||||||
} from "@/graphics";
|
|
||||||
import { resolveAnimationPathValueAtTime } from "./resolve";
|
import { resolveAnimationPathValueAtTime } from "./resolve";
|
||||||
|
|
||||||
export const GRAPHIC_PARAM_PATH_PREFIX = "params.";
|
export const GRAPHIC_PARAM_PATH_PREFIX = "params.";
|
||||||
|
|
@ -39,33 +35,29 @@ export function parseGraphicParamPath({
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveGraphicParamsAtTime({
|
export function resolveGraphicParamsAtTime({
|
||||||
element,
|
params,
|
||||||
|
definitions,
|
||||||
|
animations,
|
||||||
localTime,
|
localTime,
|
||||||
}: {
|
}: {
|
||||||
element: {
|
params: ParamValues;
|
||||||
definitionId: string;
|
definitions: ParamDefinition[];
|
||||||
params: ParamValues;
|
animations?: ElementAnimations;
|
||||||
animations?: ElementAnimations;
|
|
||||||
};
|
|
||||||
localTime: number;
|
localTime: number;
|
||||||
}): ParamValues {
|
}): ParamValues {
|
||||||
const definition = getGraphicDefinition({
|
const resolved: ParamValues = { ...params };
|
||||||
definitionId: element.definitionId,
|
|
||||||
});
|
|
||||||
const baseParams = resolveGraphicParams(definition, element.params);
|
|
||||||
const resolved: ParamValues = { ...baseParams };
|
|
||||||
|
|
||||||
for (const param of definition.params) {
|
for (const param of definitions) {
|
||||||
const path = buildGraphicParamPath({ paramKey: param.key });
|
const path = buildGraphicParamPath({ paramKey: param.key });
|
||||||
if (!element.animations?.bindings[path]) {
|
if (!animations?.bindings[path]) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
resolved[param.key] = resolveAnimationPathValueAtTime({
|
resolved[param.key] = resolveAnimationPathValueAtTime({
|
||||||
animations: element.animations,
|
animations,
|
||||||
propertyPath: path,
|
propertyPath: path,
|
||||||
localTime: Math.max(0, localTime),
|
localTime: Math.max(0, localTime),
|
||||||
fallbackValue: baseParams[param.key] ?? param.default,
|
fallbackValue: params[param.key] ?? param.default,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,31 +16,14 @@ export {
|
||||||
setChannel,
|
setChannel,
|
||||||
splitAnimationsAtTime,
|
splitAnimationsAtTime,
|
||||||
updateScalarKeyframeCurve,
|
updateScalarKeyframeCurve,
|
||||||
upsertElementKeyframe,
|
|
||||||
upsertPathKeyframe,
|
upsertPathKeyframe,
|
||||||
} from "./keyframes";
|
} from "./keyframes";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
getElementLocalTime,
|
getElementLocalTime,
|
||||||
resolveAnimationPathValueAtTime,
|
resolveAnimationPathValueAtTime,
|
||||||
resolveColorAtTime,
|
|
||||||
resolveNumberAtTime,
|
|
||||||
resolveOpacityAtTime,
|
|
||||||
resolveTransformAtTime,
|
|
||||||
} from "./resolve";
|
} from "./resolve";
|
||||||
|
|
||||||
export {
|
|
||||||
coerceAnimationValueForProperty,
|
|
||||||
getAnimationPropertyDefinition,
|
|
||||||
getDefaultInterpolationForProperty,
|
|
||||||
getElementBaseValueForProperty,
|
|
||||||
isAnimationPropertyPath,
|
|
||||||
supportsAnimationProperty,
|
|
||||||
type AnimationPropertyDefinition,
|
|
||||||
type NumericSpec,
|
|
||||||
withElementBaseValueForProperty,
|
|
||||||
} from "./property-registry";
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
getElementKeyframes,
|
getElementKeyframes,
|
||||||
getKeyframeById,
|
getKeyframeById,
|
||||||
|
|
@ -75,15 +58,6 @@ export {
|
||||||
resolveEffectParamsAtTime,
|
resolveEffectParamsAtTime,
|
||||||
} from "./effect-param-channel";
|
} from "./effect-param-channel";
|
||||||
|
|
||||||
export {
|
|
||||||
isAnimationPath,
|
|
||||||
coerceAnimationValueForParam,
|
|
||||||
resolveAnimationTarget,
|
|
||||||
getParamValueKind,
|
|
||||||
getParamDefaultInterpolation,
|
|
||||||
type AnimationPathDescriptor,
|
|
||||||
} from "./target-resolver";
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
getGroupKeyframesAtTime,
|
getGroupKeyframesAtTime,
|
||||||
hasGroupKeyframeAtTime,
|
hasGroupKeyframeAtTime,
|
||||||
|
|
@ -94,3 +68,7 @@ export {
|
||||||
type EasingMode,
|
type EasingMode,
|
||||||
getEasingModeForKind,
|
getEasingModeForKind,
|
||||||
} from "./binding-values";
|
} from "./binding-values";
|
||||||
|
|
||||||
|
export {
|
||||||
|
isAnimationPath,
|
||||||
|
} from "./path";
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,13 @@ import type {
|
||||||
ScalarAnimationKey,
|
ScalarAnimationKey,
|
||||||
ScalarSegmentType,
|
ScalarSegmentType,
|
||||||
} from "@/animation/types";
|
} from "@/animation/types";
|
||||||
import { clamp } from "@/utils/math";
|
|
||||||
import {
|
import {
|
||||||
getBezierPoint,
|
getBezierPoint,
|
||||||
getDefaultLeftHandle,
|
getDefaultLeftHandle,
|
||||||
getDefaultRightHandle,
|
getDefaultRightHandle,
|
||||||
solveBezierProgressForTime,
|
solveBezierProgressForTime,
|
||||||
} from "./bezier";
|
} from "./bezier";
|
||||||
|
import { clamp } from "@/utils/math";
|
||||||
|
|
||||||
function byTimeAscending({
|
function byTimeAscending({
|
||||||
leftTime,
|
leftTime,
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import {
|
||||||
getChannelValueAtTime,
|
getChannelValueAtTime,
|
||||||
getScalarSegmentInterpolation,
|
getScalarSegmentInterpolation,
|
||||||
} from "./interpolation";
|
} from "./interpolation";
|
||||||
import { isAnimationPath } from "./target-resolver";
|
import { isAnimationPath } from "./path";
|
||||||
|
|
||||||
function getBindingFallbackValue({
|
function getBindingFallbackValue({
|
||||||
channel,
|
channel,
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import type {
|
||||||
AnimationChannel,
|
AnimationChannel,
|
||||||
AnimationInterpolation,
|
AnimationInterpolation,
|
||||||
AnimationPath,
|
AnimationPath,
|
||||||
AnimationPropertyPath,
|
|
||||||
AnimationValue,
|
AnimationValue,
|
||||||
DiscreteAnimationChannel,
|
DiscreteAnimationChannel,
|
||||||
DiscreteAnimationKey,
|
DiscreteAnimationKey,
|
||||||
|
|
@ -14,7 +13,6 @@ import type {
|
||||||
ScalarCurveKeyframePatch,
|
ScalarCurveKeyframePatch,
|
||||||
ScalarSegmentType,
|
ScalarSegmentType,
|
||||||
} from "@/animation/types";
|
} from "@/animation/types";
|
||||||
import { generateUUID } from "@/utils/id";
|
|
||||||
import {
|
import {
|
||||||
cloneAnimationBinding,
|
cloneAnimationBinding,
|
||||||
createAnimationBinding,
|
createAnimationBinding,
|
||||||
|
|
@ -31,10 +29,7 @@ import {
|
||||||
getScalarSegmentInterpolation,
|
getScalarSegmentInterpolation,
|
||||||
normalizeChannel,
|
normalizeChannel,
|
||||||
} from "./interpolation";
|
} from "./interpolation";
|
||||||
import {
|
import { generateUUID } from "@/utils/id";
|
||||||
coerceAnimationValueForProperty,
|
|
||||||
getAnimationPropertyDefinition,
|
|
||||||
} from "./property-registry";
|
|
||||||
|
|
||||||
function isNearlySameTime({
|
function isNearlySameTime({
|
||||||
leftTime,
|
leftTime,
|
||||||
|
|
@ -527,47 +522,6 @@ export function upsertPathKeyframe({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function upsertElementKeyframe({
|
|
||||||
animations,
|
|
||||||
propertyPath,
|
|
||||||
time,
|
|
||||||
value,
|
|
||||||
interpolation,
|
|
||||||
keyframeId,
|
|
||||||
}: {
|
|
||||||
animations: ElementAnimations | undefined;
|
|
||||||
propertyPath: AnimationPropertyPath;
|
|
||||||
time: number;
|
|
||||||
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,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import type { AnimationPath, AnimationPropertyPath } from "@/animation/types";
|
||||||
|
import { ANIMATION_PROPERTY_PATHS } from "./types";
|
||||||
|
import { isEffectParamPath } from "./effect-param-channel";
|
||||||
|
import { isGraphicParamPath } from "./graphic-param-channel";
|
||||||
|
|
||||||
|
const ANIMATION_PROPERTY_PATH_SET = new Set<string>(ANIMATION_PROPERTY_PATHS);
|
||||||
|
|
||||||
|
export function isAnimationPropertyPath(
|
||||||
|
propertyPath: string,
|
||||||
|
): propertyPath is AnimationPropertyPath {
|
||||||
|
return ANIMATION_PROPERTY_PATH_SET.has(propertyPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAnimationPath(
|
||||||
|
propertyPath: string,
|
||||||
|
): propertyPath is AnimationPath {
|
||||||
|
return (
|
||||||
|
isAnimationPropertyPath(propertyPath) ||
|
||||||
|
isGraphicParamPath(propertyPath) ||
|
||||||
|
isEffectParamPath(propertyPath)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,8 @@
|
||||||
import type {
|
import type {
|
||||||
AnimationColorPropertyPath,
|
|
||||||
AnimationNumericPropertyPath,
|
|
||||||
AnimationPath,
|
AnimationPath,
|
||||||
AnimationPropertyPath,
|
|
||||||
AnimationValueForPath,
|
AnimationValueForPath,
|
||||||
ElementAnimations,
|
ElementAnimations,
|
||||||
} from "@/animation/types";
|
} from "@/animation/types";
|
||||||
import type { Transform } from "@/rendering";
|
|
||||||
import {
|
import {
|
||||||
type AnimationComponentValue,
|
type AnimationComponentValue,
|
||||||
composeAnimationValue,
|
composeAnimationValue,
|
||||||
|
|
@ -37,107 +33,6 @@ export function getElementLocalTime({
|
||||||
return localTime;
|
return localTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveTransformAtTime({
|
|
||||||
baseTransform,
|
|
||||||
animations,
|
|
||||||
localTime,
|
|
||||||
}: {
|
|
||||||
baseTransform: Transform;
|
|
||||||
animations: ElementAnimations | undefined;
|
|
||||||
localTime: number;
|
|
||||||
}): Transform {
|
|
||||||
const safeLocalTime = Math.max(0, localTime);
|
|
||||||
return {
|
|
||||||
position: {
|
|
||||||
x: resolveAnimationPathValueAtTime({
|
|
||||||
animations,
|
|
||||||
propertyPath: "transform.positionX",
|
|
||||||
localTime: safeLocalTime,
|
|
||||||
fallbackValue: baseTransform.position.x,
|
|
||||||
}),
|
|
||||||
y: resolveAnimationPathValueAtTime({
|
|
||||||
animations,
|
|
||||||
propertyPath: "transform.positionY",
|
|
||||||
localTime: safeLocalTime,
|
|
||||||
fallbackValue: baseTransform.position.y,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
scaleX: resolveAnimationPathValueAtTime({
|
|
||||||
animations,
|
|
||||||
propertyPath: "transform.scaleX",
|
|
||||||
localTime: safeLocalTime,
|
|
||||||
fallbackValue: baseTransform.scaleX,
|
|
||||||
}),
|
|
||||||
scaleY: resolveAnimationPathValueAtTime({
|
|
||||||
animations,
|
|
||||||
propertyPath: "transform.scaleY",
|
|
||||||
localTime: safeLocalTime,
|
|
||||||
fallbackValue: baseTransform.scaleY,
|
|
||||||
}),
|
|
||||||
rotate: resolveAnimationPathValueAtTime({
|
|
||||||
animations,
|
|
||||||
propertyPath: "transform.rotate",
|
|
||||||
localTime: safeLocalTime,
|
|
||||||
fallbackValue: baseTransform.rotate,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveOpacityAtTime({
|
|
||||||
baseOpacity,
|
|
||||||
animations,
|
|
||||||
localTime,
|
|
||||||
}: {
|
|
||||||
baseOpacity: number;
|
|
||||||
animations: ElementAnimations | undefined;
|
|
||||||
localTime: number;
|
|
||||||
}): number {
|
|
||||||
return resolveAnimationPathValueAtTime({
|
|
||||||
animations,
|
|
||||||
propertyPath: "opacity",
|
|
||||||
localTime: Math.max(0, localTime),
|
|
||||||
fallbackValue: baseOpacity,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveNumberAtTime({
|
|
||||||
baseValue,
|
|
||||||
animations,
|
|
||||||
propertyPath,
|
|
||||||
localTime,
|
|
||||||
}: {
|
|
||||||
baseValue: number;
|
|
||||||
animations: ElementAnimations | undefined;
|
|
||||||
propertyPath: AnimationNumericPropertyPath;
|
|
||||||
localTime: number;
|
|
||||||
}): number {
|
|
||||||
return resolveAnimationPathValueAtTime({
|
|
||||||
animations,
|
|
||||||
propertyPath,
|
|
||||||
localTime: Math.max(0, localTime),
|
|
||||||
fallbackValue: baseValue,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveColorAtTime({
|
|
||||||
baseColor,
|
|
||||||
animations,
|
|
||||||
propertyPath,
|
|
||||||
localTime,
|
|
||||||
}: {
|
|
||||||
baseColor: string;
|
|
||||||
animations: ElementAnimations | undefined;
|
|
||||||
propertyPath: AnimationColorPropertyPath;
|
|
||||||
localTime: number;
|
|
||||||
}): string {
|
|
||||||
return resolveAnimationPathValueAtTime({
|
|
||||||
animations,
|
|
||||||
propertyPath,
|
|
||||||
localTime: Math.max(0, localTime),
|
|
||||||
fallbackValue: baseColor,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveAnimationPathValueAtTime<TPath extends AnimationPath>({
|
export function resolveAnimationPathValueAtTime<TPath extends AnimationPath>({
|
||||||
animations,
|
animations,
|
||||||
propertyPath,
|
propertyPath,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import type { ParamValues } from "@/params";
|
|
||||||
|
|
||||||
export const ANIMATION_PROPERTY_PATHS = [
|
export const ANIMATION_PROPERTY_PATHS = [
|
||||||
"transform.positionX",
|
"transform.positionX",
|
||||||
"transform.positionY",
|
"transform.positionY",
|
||||||
|
|
@ -50,7 +48,13 @@ export interface AnimationPropertyValueMap {
|
||||||
"background.offsetY": number;
|
"background.offsetY": number;
|
||||||
"background.cornerRadius": number;
|
"background.cornerRadius": number;
|
||||||
}
|
}
|
||||||
export type DynamicAnimationPathValue = ParamValues[string];
|
export type DynamicAnimationPathValue = number | string | boolean;
|
||||||
|
|
||||||
|
export interface NumericSpec {
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
}
|
||||||
export type AnimationValueForPath<TPath extends AnimationPath> =
|
export type AnimationValueForPath<TPath extends AnimationPath> =
|
||||||
TPath extends AnimationPropertyPath
|
TPath extends AnimationPropertyPath
|
||||||
? AnimationPropertyValueMap[TPath]
|
? AnimationPropertyValueMap[TPath]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
import type {
|
||||||
|
AnimationColorPropertyPath,
|
||||||
|
AnimationNumericPropertyPath,
|
||||||
|
ElementAnimations,
|
||||||
|
} from "./types";
|
||||||
|
import { resolveAnimationPathValueAtTime } from "./resolve";
|
||||||
|
|
||||||
|
export function resolveOpacityAtTime({
|
||||||
|
baseOpacity,
|
||||||
|
animations,
|
||||||
|
localTime,
|
||||||
|
}: {
|
||||||
|
baseOpacity: number;
|
||||||
|
animations: ElementAnimations | undefined;
|
||||||
|
localTime: number;
|
||||||
|
}): number {
|
||||||
|
return resolveAnimationPathValueAtTime({
|
||||||
|
animations,
|
||||||
|
propertyPath: "opacity",
|
||||||
|
localTime: Math.max(0, localTime),
|
||||||
|
fallbackValue: baseOpacity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveNumberAtTime({
|
||||||
|
baseValue,
|
||||||
|
animations,
|
||||||
|
propertyPath,
|
||||||
|
localTime,
|
||||||
|
}: {
|
||||||
|
baseValue: number;
|
||||||
|
animations: ElementAnimations | undefined;
|
||||||
|
propertyPath: AnimationNumericPropertyPath;
|
||||||
|
localTime: number;
|
||||||
|
}): number {
|
||||||
|
return resolveAnimationPathValueAtTime({
|
||||||
|
animations,
|
||||||
|
propertyPath,
|
||||||
|
localTime: Math.max(0, localTime),
|
||||||
|
fallbackValue: baseValue,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveColorAtTime({
|
||||||
|
baseColor,
|
||||||
|
animations,
|
||||||
|
propertyPath,
|
||||||
|
localTime,
|
||||||
|
}: {
|
||||||
|
baseColor: string;
|
||||||
|
animations: ElementAnimations | undefined;
|
||||||
|
propertyPath: AnimationColorPropertyPath;
|
||||||
|
localTime: number;
|
||||||
|
}): string {
|
||||||
|
return resolveAnimationPathValueAtTime({
|
||||||
|
animations,
|
||||||
|
propertyPath,
|
||||||
|
localTime: Math.max(0, localTime),
|
||||||
|
fallbackValue: baseColor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import {
|
import {
|
||||||
getKeyframeAtTime,
|
getKeyframeAtTime,
|
||||||
resolveAnimationTarget,
|
|
||||||
updateScalarKeyframeCurve,
|
updateScalarKeyframeCurve,
|
||||||
upsertPathKeyframe,
|
upsertPathKeyframe,
|
||||||
} from "@/animation";
|
} from "@/animation";
|
||||||
|
|
@ -9,6 +8,7 @@ import { Command, type CommandResult } from "@/commands/base-command";
|
||||||
import type { KeyframeClipboardItem } from "@/clipboard";
|
import type { KeyframeClipboardItem } from "@/clipboard";
|
||||||
import type { SceneTracks, TimelineElement } from "@/timeline";
|
import type { SceneTracks, TimelineElement } from "@/timeline";
|
||||||
import { updateElementInSceneTracks } from "@/timeline";
|
import { updateElementInSceneTracks } from "@/timeline";
|
||||||
|
import { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||||
import { generateUUID } from "@/utils/id";
|
import { generateUUID } from "@/utils/id";
|
||||||
|
|
||||||
function pasteKeyframesIntoElement({
|
function pasteKeyframesIntoElement({
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@ import { EditorCore } from "@/core";
|
||||||
import {
|
import {
|
||||||
hasKeyframesForPath,
|
hasKeyframesForPath,
|
||||||
removeElementKeyframe,
|
removeElementKeyframe,
|
||||||
resolveAnimationTarget,
|
|
||||||
} from "@/animation";
|
} from "@/animation";
|
||||||
import { Command, type CommandResult } from "@/commands/base-command";
|
import { Command, type CommandResult } from "@/commands/base-command";
|
||||||
import { updateElementInSceneTracks } from "@/timeline";
|
import { updateElementInSceneTracks } from "@/timeline";
|
||||||
import type { AnimationPath, AnimationValue } from "@/animation/types";
|
import type { AnimationPath, AnimationValue } from "@/animation/types";
|
||||||
import type { SceneTracks, TimelineElement } from "@/timeline";
|
import type { SceneTracks, TimelineElement } from "@/timeline";
|
||||||
|
import { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||||
|
|
||||||
function removeKeyframeAndPersist({
|
function removeKeyframeAndPersist({
|
||||||
element,
|
element,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import { resolveAnimationTarget, retimeElementKeyframe } from "@/animation";
|
import { retimeElementKeyframe } from "@/animation";
|
||||||
import { Command, type CommandResult } from "@/commands/base-command";
|
import { Command, type CommandResult } from "@/commands/base-command";
|
||||||
import { updateElementInSceneTracks } from "@/timeline";
|
import { updateElementInSceneTracks } from "@/timeline";
|
||||||
import type { AnimationPath } from "@/animation/types";
|
import type { AnimationPath } from "@/animation/types";
|
||||||
import type { SceneTracks } from "@/timeline";
|
import type { SceneTracks } from "@/timeline";
|
||||||
|
import { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||||
|
|
||||||
export class RetimeKeyframeCommand extends Command {
|
export class RetimeKeyframeCommand extends Command {
|
||||||
private savedState: SceneTracks | null = null;
|
private savedState: SceneTracks | null = null;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import {
|
import {
|
||||||
resolveAnimationTarget,
|
|
||||||
updateScalarKeyframeCurve,
|
updateScalarKeyframeCurve,
|
||||||
} from "@/animation";
|
} from "@/animation";
|
||||||
import { Command, type CommandResult } from "@/commands/base-command";
|
import { Command, type CommandResult } from "@/commands/base-command";
|
||||||
import { updateElementInSceneTracks } from "@/timeline";
|
import { updateElementInSceneTracks } from "@/timeline";
|
||||||
|
import { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||||
import type {
|
import type {
|
||||||
AnimationPath,
|
AnimationPath,
|
||||||
ScalarCurveKeyframePatch,
|
ScalarCurveKeyframePatch,
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,11 @@ import { EditorCore } from "@/core";
|
||||||
import { Command, type CommandResult } from "@/commands/base-command";
|
import { Command, type CommandResult } from "@/commands/base-command";
|
||||||
import {
|
import {
|
||||||
buildEffectParamPath,
|
buildEffectParamPath,
|
||||||
resolveAnimationTarget,
|
|
||||||
upsertPathKeyframe,
|
upsertPathKeyframe,
|
||||||
} from "@/animation";
|
} from "@/animation";
|
||||||
import { updateElementInSceneTracks } from "@/timeline";
|
import { updateElementInSceneTracks } from "@/timeline";
|
||||||
import { isVisualElement } from "@/timeline/element-utils";
|
import { isVisualElement } from "@/timeline/element-utils";
|
||||||
|
import { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||||
import type { AnimationInterpolation } from "@/animation/types";
|
import type { AnimationInterpolation } from "@/animation/types";
|
||||||
import type { SceneTracks } from "@/timeline";
|
import type { SceneTracks } from "@/timeline";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import { Command, type CommandResult } from "@/commands/base-command";
|
import { Command, type CommandResult } from "@/commands/base-command";
|
||||||
import { resolveAnimationTarget, upsertPathKeyframe } from "@/animation";
|
import { upsertPathKeyframe } from "@/animation";
|
||||||
import { updateElementInSceneTracks } from "@/timeline";
|
import { updateElementInSceneTracks } from "@/timeline";
|
||||||
import type { SceneTracks } from "@/timeline";
|
import type { SceneTracks } from "@/timeline";
|
||||||
|
import { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||||
import type {
|
import type {
|
||||||
AnimationPath,
|
AnimationPath,
|
||||||
AnimationInterpolation,
|
AnimationInterpolation,
|
||||||
|
|
|
||||||
|
|
@ -1,95 +1,95 @@
|
||||||
import { useEditor } from "@/editor/use-editor";
|
import { useEditor } from "@/editor/use-editor";
|
||||||
import {
|
import {
|
||||||
getKeyframeAtTime,
|
getKeyframeAtTime,
|
||||||
hasKeyframesForPath,
|
hasKeyframesForPath,
|
||||||
upsertElementKeyframe,
|
} from "@/animation";
|
||||||
} from "@/animation";
|
import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
|
||||||
import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
|
import type { TimelineElement } from "@/timeline";
|
||||||
import type { TimelineElement } from "@/timeline";
|
import { upsertElementKeyframe } from "@/timeline/animation-properties";
|
||||||
|
|
||||||
export function useKeyframedColorProperty({
|
export function useKeyframedColorProperty({
|
||||||
trackId,
|
trackId,
|
||||||
elementId,
|
elementId,
|
||||||
animations,
|
animations,
|
||||||
propertyPath,
|
propertyPath,
|
||||||
localTime,
|
localTime,
|
||||||
isPlayheadWithinElementRange,
|
isPlayheadWithinElementRange,
|
||||||
resolvedColor,
|
resolvedColor,
|
||||||
buildBaseUpdates,
|
buildBaseUpdates,
|
||||||
}: {
|
}: {
|
||||||
trackId: string;
|
trackId: string;
|
||||||
elementId: string;
|
elementId: string;
|
||||||
animations: ElementAnimations | undefined;
|
animations: ElementAnimations | undefined;
|
||||||
propertyPath: AnimationPropertyPath;
|
propertyPath: AnimationPropertyPath;
|
||||||
localTime: number;
|
localTime: number;
|
||||||
isPlayheadWithinElementRange: boolean;
|
isPlayheadWithinElementRange: boolean;
|
||||||
resolvedColor: string;
|
resolvedColor: string;
|
||||||
buildBaseUpdates: ({ value }: { value: string }) => Partial<TimelineElement>;
|
buildBaseUpdates: ({ value }: { value: string }) => Partial<TimelineElement>;
|
||||||
}) {
|
}) {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
|
|
||||||
const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
|
const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
|
||||||
const keyframeAtTime = isPlayheadWithinElementRange
|
const keyframeAtTime = isPlayheadWithinElementRange
|
||||||
? getKeyframeAtTime({ animations, propertyPath, time: localTime })
|
? getKeyframeAtTime({ animations, propertyPath, time: localTime })
|
||||||
: null;
|
: null;
|
||||||
const keyframeIdAtTime = keyframeAtTime?.id ?? null;
|
const keyframeIdAtTime = keyframeAtTime?.id ?? null;
|
||||||
const isKeyframedAtTime = keyframeAtTime !== null;
|
const isKeyframedAtTime = keyframeAtTime !== null;
|
||||||
const shouldUseAnimatedChannel =
|
const shouldUseAnimatedChannel =
|
||||||
hasAnimatedKeyframes && isPlayheadWithinElementRange;
|
hasAnimatedKeyframes && isPlayheadWithinElementRange;
|
||||||
|
|
||||||
const onChange = ({ color }: { color: string }) => {
|
const onChange = ({ color }: { color: string }) => {
|
||||||
if (shouldUseAnimatedChannel) {
|
if (shouldUseAnimatedChannel) {
|
||||||
editor.timeline.previewElements({
|
editor.timeline.previewElements({
|
||||||
updates: [
|
updates: [
|
||||||
{
|
{
|
||||||
trackId,
|
trackId,
|
||||||
elementId,
|
elementId,
|
||||||
updates: {
|
updates: {
|
||||||
animations: upsertElementKeyframe({
|
animations: upsertElementKeyframe({
|
||||||
animations,
|
animations,
|
||||||
propertyPath,
|
propertyPath,
|
||||||
time: localTime,
|
time: localTime,
|
||||||
value: color,
|
value: color,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
editor.timeline.previewElements({
|
editor.timeline.previewElements({
|
||||||
updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: color }) }],
|
updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: color }) }],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onChangeEnd = () => editor.timeline.commitPreview();
|
const onChangeEnd = () => editor.timeline.commitPreview();
|
||||||
|
|
||||||
const toggleKeyframe = () => {
|
const toggleKeyframe = () => {
|
||||||
if (!isPlayheadWithinElementRange) {
|
if (!isPlayheadWithinElementRange) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (keyframeIdAtTime) {
|
if (keyframeIdAtTime) {
|
||||||
editor.timeline.removeKeyframes({
|
editor.timeline.removeKeyframes({
|
||||||
keyframes: [{ trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }],
|
keyframes: [{ trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }],
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
editor.timeline.upsertKeyframes({
|
editor.timeline.upsertKeyframes({
|
||||||
keyframes: [
|
keyframes: [
|
||||||
{ trackId, elementId, propertyPath, time: localTime, value: resolvedColor },
|
{ trackId, elementId, propertyPath, time: localTime, value: resolvedColor },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isKeyframedAtTime,
|
isKeyframedAtTime,
|
||||||
hasAnimatedKeyframes,
|
hasAnimatedKeyframes,
|
||||||
keyframeIdAtTime,
|
keyframeIdAtTime,
|
||||||
onChange,
|
onChange,
|
||||||
onChangeEnd,
|
onChangeEnd,
|
||||||
toggleKeyframe,
|
toggleKeyframe,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,174 +1,174 @@
|
||||||
import { useEditor } from "@/editor/use-editor";
|
import { useEditor } from "@/editor/use-editor";
|
||||||
import {
|
import {
|
||||||
getKeyframeAtTime,
|
getKeyframeAtTime,
|
||||||
hasKeyframesForPath,
|
hasKeyframesForPath,
|
||||||
upsertElementKeyframe,
|
} from "@/animation";
|
||||||
} from "@/animation";
|
import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
|
||||||
import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
|
import type { TimelineElement } from "@/timeline";
|
||||||
import type { TimelineElement } from "@/timeline";
|
import { upsertElementKeyframe } from "@/timeline/animation-properties";
|
||||||
import { snapToStep } from "@/utils/math";
|
import { snapToStep } from "@/utils/math";
|
||||||
import { usePropertyDraft } from "./use-property-draft";
|
import { usePropertyDraft } from "./use-property-draft";
|
||||||
|
|
||||||
export function useKeyframedNumberProperty({
|
export function useKeyframedNumberProperty({
|
||||||
trackId,
|
trackId,
|
||||||
elementId,
|
elementId,
|
||||||
animations,
|
animations,
|
||||||
propertyPath,
|
propertyPath,
|
||||||
localTime,
|
localTime,
|
||||||
isPlayheadWithinElementRange,
|
isPlayheadWithinElementRange,
|
||||||
displayValue,
|
displayValue,
|
||||||
parse,
|
parse,
|
||||||
valueAtPlayhead,
|
valueAtPlayhead,
|
||||||
step,
|
step,
|
||||||
buildBaseUpdates,
|
buildBaseUpdates,
|
||||||
buildAdditionalKeyframes,
|
buildAdditionalKeyframes,
|
||||||
}: {
|
}: {
|
||||||
trackId: string;
|
trackId: string;
|
||||||
elementId: string;
|
elementId: string;
|
||||||
animations: ElementAnimations | undefined;
|
animations: ElementAnimations | undefined;
|
||||||
propertyPath: AnimationPropertyPath;
|
propertyPath: AnimationPropertyPath;
|
||||||
localTime: number;
|
localTime: number;
|
||||||
isPlayheadWithinElementRange: boolean;
|
isPlayheadWithinElementRange: boolean;
|
||||||
displayValue: string;
|
displayValue: string;
|
||||||
parse: (input: string) => number | null;
|
parse: (input: string) => number | null;
|
||||||
valueAtPlayhead: number;
|
valueAtPlayhead: number;
|
||||||
step?: number;
|
step?: number;
|
||||||
buildBaseUpdates: ({ value }: { value: number }) => Partial<TimelineElement>;
|
buildBaseUpdates: ({ value }: { value: number }) => Partial<TimelineElement>;
|
||||||
buildAdditionalKeyframes?: ({
|
buildAdditionalKeyframes?: ({
|
||||||
value,
|
value,
|
||||||
}: { value: number }) => Array<{ propertyPath: AnimationPropertyPath; value: number }>;
|
}: { value: number }) => Array<{ propertyPath: AnimationPropertyPath; value: number }>;
|
||||||
}) {
|
}) {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const snapValue = (value: number) =>
|
const snapValue = (value: number) =>
|
||||||
step != null ? snapToStep({ value, step }) : value;
|
step != null ? snapToStep({ value, step }) : value;
|
||||||
|
|
||||||
const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
|
const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
|
||||||
const keyframeAtTime = isPlayheadWithinElementRange
|
const keyframeAtTime = isPlayheadWithinElementRange
|
||||||
? getKeyframeAtTime({ animations, propertyPath, time: localTime })
|
? getKeyframeAtTime({ animations, propertyPath, time: localTime })
|
||||||
: null;
|
: null;
|
||||||
const keyframeIdAtTime = keyframeAtTime?.id ?? null;
|
const keyframeIdAtTime = keyframeAtTime?.id ?? null;
|
||||||
const isKeyframedAtTime = keyframeAtTime !== null;
|
const isKeyframedAtTime = keyframeAtTime !== null;
|
||||||
const shouldUseAnimatedChannel =
|
const shouldUseAnimatedChannel =
|
||||||
hasAnimatedKeyframes && isPlayheadWithinElementRange;
|
hasAnimatedKeyframes && isPlayheadWithinElementRange;
|
||||||
|
|
||||||
const previewValue = ({ value }: { value: number }) => {
|
const previewValue = ({ value }: { value: number }) => {
|
||||||
const nextValue = snapValue(value);
|
const nextValue = snapValue(value);
|
||||||
if (shouldUseAnimatedChannel) {
|
if (shouldUseAnimatedChannel) {
|
||||||
const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? [];
|
const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? [];
|
||||||
const updatedAnimations = [
|
const updatedAnimations = [
|
||||||
{ propertyPath, value: nextValue },
|
{ propertyPath, value: nextValue },
|
||||||
...additionalKeyframes,
|
...additionalKeyframes,
|
||||||
].reduce(
|
].reduce(
|
||||||
(currentAnimations, keyframe) =>
|
(currentAnimations, keyframe) =>
|
||||||
upsertElementKeyframe({
|
upsertElementKeyframe({
|
||||||
animations: currentAnimations,
|
animations: currentAnimations,
|
||||||
propertyPath: keyframe.propertyPath,
|
propertyPath: keyframe.propertyPath,
|
||||||
time: localTime,
|
time: localTime,
|
||||||
value: keyframe.value,
|
value: keyframe.value,
|
||||||
}),
|
}),
|
||||||
animations,
|
animations,
|
||||||
);
|
);
|
||||||
editor.timeline.previewElements({
|
editor.timeline.previewElements({
|
||||||
updates: [
|
updates: [
|
||||||
{
|
{
|
||||||
trackId,
|
trackId,
|
||||||
elementId,
|
elementId,
|
||||||
updates: { animations: updatedAnimations },
|
updates: { animations: updatedAnimations },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
editor.timeline.previewElements({
|
editor.timeline.previewElements({
|
||||||
updates: [
|
updates: [
|
||||||
{
|
{
|
||||||
trackId,
|
trackId,
|
||||||
elementId,
|
elementId,
|
||||||
updates: buildBaseUpdates({ value: nextValue }),
|
updates: buildBaseUpdates({ value: nextValue }),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const propertyDraft = usePropertyDraft({
|
const propertyDraft = usePropertyDraft({
|
||||||
displayValue,
|
displayValue,
|
||||||
parse: (input) => {
|
parse: (input) => {
|
||||||
const parsedValue = parse(input);
|
const parsedValue = parse(input);
|
||||||
return parsedValue === null ? null : snapValue(parsedValue);
|
return parsedValue === null ? null : snapValue(parsedValue);
|
||||||
},
|
},
|
||||||
onPreview: (value) => previewValue({ value }),
|
onPreview: (value) => previewValue({ value }),
|
||||||
onCommit: () => editor.timeline.commitPreview(),
|
onCommit: () => editor.timeline.commitPreview(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleKeyframe = () => {
|
const toggleKeyframe = () => {
|
||||||
if (!isPlayheadWithinElementRange) {
|
if (!isPlayheadWithinElementRange) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (keyframeIdAtTime) {
|
if (keyframeIdAtTime) {
|
||||||
editor.timeline.removeKeyframes({
|
editor.timeline.removeKeyframes({
|
||||||
keyframes: [
|
keyframes: [
|
||||||
{
|
{
|
||||||
trackId,
|
trackId,
|
||||||
elementId,
|
elementId,
|
||||||
propertyPath,
|
propertyPath,
|
||||||
keyframeId: keyframeIdAtTime,
|
keyframeId: keyframeIdAtTime,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
editor.timeline.upsertKeyframes({
|
editor.timeline.upsertKeyframes({
|
||||||
keyframes: [
|
keyframes: [
|
||||||
{
|
{
|
||||||
trackId,
|
trackId,
|
||||||
elementId,
|
elementId,
|
||||||
propertyPath,
|
propertyPath,
|
||||||
time: localTime,
|
time: localTime,
|
||||||
value: snapValue(valueAtPlayhead),
|
value: snapValue(valueAtPlayhead),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const commitValue = ({ value }: { value: number }) => {
|
const commitValue = ({ value }: { value: number }) => {
|
||||||
const nextValue = snapValue(value);
|
const nextValue = snapValue(value);
|
||||||
if (shouldUseAnimatedChannel) {
|
if (shouldUseAnimatedChannel) {
|
||||||
const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? [];
|
const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? [];
|
||||||
editor.timeline.upsertKeyframes({
|
editor.timeline.upsertKeyframes({
|
||||||
keyframes: [
|
keyframes: [
|
||||||
{ trackId, elementId, propertyPath, time: localTime, value: nextValue },
|
{ trackId, elementId, propertyPath, time: localTime, value: nextValue },
|
||||||
...additionalKeyframes.map((keyframe) => ({
|
...additionalKeyframes.map((keyframe) => ({
|
||||||
trackId,
|
trackId,
|
||||||
elementId,
|
elementId,
|
||||||
propertyPath: keyframe.propertyPath,
|
propertyPath: keyframe.propertyPath,
|
||||||
time: localTime,
|
time: localTime,
|
||||||
value: keyframe.value,
|
value: keyframe.value,
|
||||||
})),
|
})),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
editor.timeline.updateElements({
|
editor.timeline.updateElements({
|
||||||
updates: [
|
updates: [
|
||||||
{
|
{
|
||||||
trackId,
|
trackId,
|
||||||
elementId,
|
elementId,
|
||||||
patch: buildBaseUpdates({ value: nextValue }),
|
patch: buildBaseUpdates({ value: nextValue }),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...propertyDraft,
|
...propertyDraft,
|
||||||
hasAnimatedKeyframes,
|
hasAnimatedKeyframes,
|
||||||
isKeyframedAtTime,
|
isKeyframedAtTime,
|
||||||
keyframeIdAtTime,
|
keyframeIdAtTime,
|
||||||
toggleKeyframe,
|
toggleKeyframe,
|
||||||
commitValue,
|
commitValue,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,18 @@
|
||||||
import { useEditor } from "@/editor/use-editor";
|
import { useEditor } from "@/editor/use-editor";
|
||||||
import {
|
import {
|
||||||
buildGraphicParamPath,
|
buildGraphicParamPath,
|
||||||
coerceAnimationValueForParam,
|
|
||||||
getKeyframeAtTime,
|
getKeyframeAtTime,
|
||||||
getParamDefaultInterpolation,
|
|
||||||
getParamValueKind,
|
|
||||||
hasKeyframesForPath,
|
hasKeyframesForPath,
|
||||||
upsertPathKeyframe,
|
upsertPathKeyframe,
|
||||||
} from "@/animation";
|
} from "@/animation";
|
||||||
import type {
|
import type {
|
||||||
ElementAnimations,
|
ElementAnimations,
|
||||||
} from "@/animation/types";
|
} from "@/animation/types";
|
||||||
|
import {
|
||||||
|
coerceAnimationParamValue,
|
||||||
|
getAnimationParamDefaultInterpolation,
|
||||||
|
getAnimationParamValueKind,
|
||||||
|
} from "@/animation/animated-params";
|
||||||
import type { ParamDefinition } from "@/params";
|
import type { ParamDefinition } from "@/params";
|
||||||
import type { TimelineElement } from "@/timeline";
|
import type { TimelineElement } from "@/timeline";
|
||||||
|
|
||||||
|
|
@ -79,12 +81,12 @@ export function useKeyframedParamProperty({
|
||||||
propertyPath,
|
propertyPath,
|
||||||
time: localTime,
|
time: localTime,
|
||||||
value,
|
value,
|
||||||
kind: getParamValueKind({ param }),
|
kind: getAnimationParamValueKind({ param }),
|
||||||
defaultInterpolation: getParamDefaultInterpolation({
|
defaultInterpolation: getAnimationParamDefaultInterpolation({
|
||||||
param,
|
param,
|
||||||
}),
|
}),
|
||||||
coerceValue: ({ value: nextValue }) =>
|
coerceValue: ({ value: nextValue }) =>
|
||||||
coerceAnimationValueForParam({
|
coerceAnimationParamValue({
|
||||||
param,
|
param,
|
||||||
value: nextValue,
|
value: nextValue,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,9 @@ import type {
|
||||||
} from "@/animation/types";
|
} from "@/animation/types";
|
||||||
import {
|
import {
|
||||||
getElementLocalTime,
|
getElementLocalTime,
|
||||||
resolveAnimationTarget,
|
|
||||||
resolveAnimationPathValueAtTime,
|
resolveAnimationPathValueAtTime,
|
||||||
} from "@/animation";
|
} from "@/animation";
|
||||||
|
import { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||||
import { lastFrameTime } from "opencut-wasm";
|
import { lastFrameTime } from "opencut-wasm";
|
||||||
import { BatchCommand } from "@/commands";
|
import { BatchCommand } from "@/commands";
|
||||||
import {
|
import {
|
||||||
|
|
|
||||||
|
|
@ -1,238 +1,241 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
|
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
|
||||||
import {
|
import {
|
||||||
useKeyframedParamProperty,
|
useKeyframedParamProperty,
|
||||||
type KeyframedParamPropertyResult,
|
type KeyframedParamPropertyResult,
|
||||||
} from "@/components/editor/panels/properties/hooks/use-keyframed-param-property";
|
} from "@/components/editor/panels/properties/hooks/use-keyframed-param-property";
|
||||||
import { resolveGraphicParamsAtTime } from "@/animation";
|
import type { ParamDefinition, ParamValues } from "@/params";
|
||||||
import type { ParamDefinition, ParamValues } from "@/params";
|
import type { GraphicElement } from "@/timeline";
|
||||||
import type { GraphicElement } from "@/timeline";
|
import {
|
||||||
import { graphicsRegistry, registerDefaultGraphics } from "@/graphics";
|
graphicsRegistry,
|
||||||
import { useElementPreview } from "@/timeline/hooks/use-element-preview";
|
registerDefaultGraphics,
|
||||||
import { useEditor } from "@/editor/use-editor";
|
resolveGraphicElementParamsAtTime,
|
||||||
import {
|
} from "@/graphics";
|
||||||
Section,
|
import { useElementPreview } from "@/timeline/hooks/use-element-preview";
|
||||||
SectionContent,
|
import { useEditor } from "@/editor/use-editor";
|
||||||
SectionFields,
|
import {
|
||||||
SectionHeader,
|
Section,
|
||||||
SectionTitle,
|
SectionContent,
|
||||||
} from "@/components/section";
|
SectionFields,
|
||||||
import { PropertyParamField } from "@/components/editor/panels/properties/components/property-param-field";
|
SectionHeader,
|
||||||
import { Button } from "@/components/ui/button";
|
SectionTitle,
|
||||||
import { HugeiconsIcon } from "@hugeicons/react";
|
} from "@/components/section";
|
||||||
import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons";
|
import { PropertyParamField } from "@/components/editor/panels/properties/components/property-param-field";
|
||||||
import { cn } from "@/utils/ui";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { HugeiconsIcon } from "@hugeicons/react";
|
||||||
registerDefaultGraphics();
|
import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons";
|
||||||
|
import { cn } from "@/utils/ui";
|
||||||
const DEFAULT_STROKE_WIDTH = 2;
|
|
||||||
|
registerDefaultGraphics();
|
||||||
export function GraphicTab({
|
|
||||||
element,
|
const DEFAULT_STROKE_WIDTH = 2;
|
||||||
trackId,
|
|
||||||
}: {
|
export function GraphicTab({
|
||||||
element: GraphicElement;
|
element,
|
||||||
trackId: string;
|
trackId,
|
||||||
}) {
|
}: {
|
||||||
const definition = graphicsRegistry.get(element.definitionId);
|
element: GraphicElement;
|
||||||
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
trackId: string;
|
||||||
startTime: element.startTime,
|
}) {
|
||||||
duration: element.duration,
|
const definition = graphicsRegistry.get(element.definitionId);
|
||||||
});
|
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
||||||
const { renderElement } = useElementPreview({
|
startTime: element.startTime,
|
||||||
trackId,
|
duration: element.duration,
|
||||||
elementId: element.id,
|
});
|
||||||
fallback: element,
|
const { renderElement } = useElementPreview({
|
||||||
});
|
trackId,
|
||||||
|
elementId: element.id,
|
||||||
const liveElement = renderElement as GraphicElement;
|
fallback: element,
|
||||||
const resolvedParams = resolveGraphicParamsAtTime({
|
});
|
||||||
element: liveElement,
|
|
||||||
localTime,
|
const liveElement = renderElement as GraphicElement;
|
||||||
});
|
const resolvedParams = resolveGraphicElementParamsAtTime({
|
||||||
|
element: liveElement,
|
||||||
const shapeParams = definition.params.filter((p) => p.group !== "stroke");
|
localTime,
|
||||||
const hasStrokeParams = definition.params.some((p) => p.group === "stroke");
|
});
|
||||||
|
|
||||||
return (
|
const shapeParams = definition.params.filter((p) => p.group !== "stroke");
|
||||||
<div className="flex flex-col">
|
const hasStrokeParams = definition.params.some((p) => p.group === "stroke");
|
||||||
<Section collapsible sectionKey={`${element.id}:graphic`}>
|
|
||||||
<SectionHeader>
|
return (
|
||||||
<SectionTitle>{definition.name}</SectionTitle>
|
<div className="flex flex-col">
|
||||||
</SectionHeader>
|
<Section collapsible sectionKey={`${element.id}:graphic`}>
|
||||||
<SectionContent>
|
<SectionHeader>
|
||||||
<SectionFields>
|
<SectionTitle>{definition.name}</SectionTitle>
|
||||||
{shapeParams.map((param) => (
|
</SectionHeader>
|
||||||
<AnimatedGraphicParamField
|
<SectionContent>
|
||||||
key={param.key}
|
<SectionFields>
|
||||||
param={param}
|
{shapeParams.map((param) => (
|
||||||
trackId={trackId}
|
<AnimatedGraphicParamField
|
||||||
element={liveElement}
|
key={param.key}
|
||||||
localTime={localTime}
|
param={param}
|
||||||
isPlayheadWithinElementRange={isPlayheadWithinElementRange}
|
trackId={trackId}
|
||||||
resolvedParams={resolvedParams}
|
element={liveElement}
|
||||||
/>
|
localTime={localTime}
|
||||||
))}
|
isPlayheadWithinElementRange={isPlayheadWithinElementRange}
|
||||||
</SectionFields>
|
resolvedParams={resolvedParams}
|
||||||
</SectionContent>
|
/>
|
||||||
</Section>
|
))}
|
||||||
{hasStrokeParams && <StrokeSection element={element} trackId={trackId} />}
|
</SectionFields>
|
||||||
</div>
|
</SectionContent>
|
||||||
);
|
</Section>
|
||||||
}
|
{hasStrokeParams && <StrokeSection element={element} trackId={trackId} />}
|
||||||
|
</div>
|
||||||
function StrokeSection({
|
);
|
||||||
element,
|
}
|
||||||
trackId,
|
|
||||||
}: {
|
function StrokeSection({
|
||||||
element: GraphicElement;
|
element,
|
||||||
trackId: string;
|
trackId,
|
||||||
}) {
|
}: {
|
||||||
const editor = useEditor();
|
element: GraphicElement;
|
||||||
const definition = graphicsRegistry.get(element.definitionId);
|
trackId: string;
|
||||||
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
}) {
|
||||||
startTime: element.startTime,
|
const editor = useEditor();
|
||||||
duration: element.duration,
|
const definition = graphicsRegistry.get(element.definitionId);
|
||||||
});
|
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
||||||
const { renderElement } = useElementPreview({
|
startTime: element.startTime,
|
||||||
trackId,
|
duration: element.duration,
|
||||||
elementId: element.id,
|
});
|
||||||
fallback: element,
|
const { renderElement } = useElementPreview({
|
||||||
});
|
trackId,
|
||||||
|
elementId: element.id,
|
||||||
const liveElement = renderElement as GraphicElement;
|
fallback: element,
|
||||||
const resolvedParams = resolveGraphicParamsAtTime({
|
});
|
||||||
element: liveElement,
|
|
||||||
localTime,
|
const liveElement = renderElement as GraphicElement;
|
||||||
});
|
const resolvedParams = resolveGraphicElementParamsAtTime({
|
||||||
const strokeParams = definition.params.filter((p) => p.group === "stroke");
|
element: liveElement,
|
||||||
const lastStrokeWidth = useRef(DEFAULT_STROKE_WIDTH);
|
localTime,
|
||||||
const isStrokeEnabled = Number(element.params.strokeWidth ?? 0) > 0;
|
});
|
||||||
|
const strokeParams = definition.params.filter((p) => p.group === "stroke");
|
||||||
const toggleStroke = () => {
|
const lastStrokeWidth = useRef(DEFAULT_STROKE_WIDTH);
|
||||||
if (isStrokeEnabled) {
|
const isStrokeEnabled = Number(element.params.strokeWidth ?? 0) > 0;
|
||||||
lastStrokeWidth.current = Number(
|
|
||||||
element.params.strokeWidth ?? DEFAULT_STROKE_WIDTH,
|
const toggleStroke = () => {
|
||||||
);
|
if (isStrokeEnabled) {
|
||||||
editor.timeline.updateElements({
|
lastStrokeWidth.current = Number(
|
||||||
updates: [
|
element.params.strokeWidth ?? DEFAULT_STROKE_WIDTH,
|
||||||
{
|
);
|
||||||
trackId,
|
editor.timeline.updateElements({
|
||||||
elementId: element.id,
|
updates: [
|
||||||
patch: { params: { ...element.params, strokeWidth: 0 } },
|
{
|
||||||
},
|
trackId,
|
||||||
],
|
elementId: element.id,
|
||||||
});
|
patch: { params: { ...element.params, strokeWidth: 0 } },
|
||||||
} else {
|
},
|
||||||
editor.timeline.updateElements({
|
],
|
||||||
updates: [
|
});
|
||||||
{
|
} else {
|
||||||
trackId,
|
editor.timeline.updateElements({
|
||||||
elementId: element.id,
|
updates: [
|
||||||
patch: {
|
{
|
||||||
params: {
|
trackId,
|
||||||
...element.params,
|
elementId: element.id,
|
||||||
strokeWidth: lastStrokeWidth.current,
|
patch: {
|
||||||
},
|
params: {
|
||||||
},
|
...element.params,
|
||||||
},
|
strokeWidth: lastStrokeWidth.current,
|
||||||
],
|
},
|
||||||
});
|
},
|
||||||
}
|
},
|
||||||
};
|
],
|
||||||
|
});
|
||||||
return (
|
}
|
||||||
<Section
|
};
|
||||||
collapsible
|
|
||||||
defaultOpen={isStrokeEnabled}
|
return (
|
||||||
sectionKey={`${element.id}:stroke`}
|
<Section
|
||||||
>
|
collapsible
|
||||||
<SectionHeader
|
defaultOpen={isStrokeEnabled}
|
||||||
trailing={
|
sectionKey={`${element.id}:stroke`}
|
||||||
<Button
|
>
|
||||||
variant="ghost"
|
<SectionHeader
|
||||||
size="icon"
|
trailing={
|
||||||
onClick={(event) => {
|
<Button
|
||||||
event.stopPropagation();
|
variant="ghost"
|
||||||
toggleStroke();
|
size="icon"
|
||||||
}}
|
onClick={(event) => {
|
||||||
>
|
event.stopPropagation();
|
||||||
<HugeiconsIcon
|
toggleStroke();
|
||||||
icon={isStrokeEnabled ? MinusSignIcon : PlusSignIcon}
|
}}
|
||||||
strokeWidth={1}
|
>
|
||||||
/>
|
<HugeiconsIcon
|
||||||
</Button>
|
icon={isStrokeEnabled ? MinusSignIcon : PlusSignIcon}
|
||||||
}
|
strokeWidth={1}
|
||||||
>
|
/>
|
||||||
<SectionTitle>Stroke</SectionTitle>
|
</Button>
|
||||||
</SectionHeader>
|
}
|
||||||
<SectionContent
|
>
|
||||||
className={cn(!isStrokeEnabled && "pointer-events-none opacity-50")}
|
<SectionTitle>Stroke</SectionTitle>
|
||||||
>
|
</SectionHeader>
|
||||||
<SectionFields>
|
<SectionContent
|
||||||
{strokeParams.map((param) => (
|
className={cn(!isStrokeEnabled && "pointer-events-none opacity-50")}
|
||||||
<AnimatedGraphicParamField
|
>
|
||||||
key={param.key}
|
<SectionFields>
|
||||||
param={param}
|
{strokeParams.map((param) => (
|
||||||
trackId={trackId}
|
<AnimatedGraphicParamField
|
||||||
element={liveElement}
|
key={param.key}
|
||||||
localTime={localTime}
|
param={param}
|
||||||
isPlayheadWithinElementRange={isPlayheadWithinElementRange}
|
trackId={trackId}
|
||||||
resolvedParams={resolvedParams}
|
element={liveElement}
|
||||||
/>
|
localTime={localTime}
|
||||||
))}
|
isPlayheadWithinElementRange={isPlayheadWithinElementRange}
|
||||||
</SectionFields>
|
resolvedParams={resolvedParams}
|
||||||
</SectionContent>
|
/>
|
||||||
</Section>
|
))}
|
||||||
);
|
</SectionFields>
|
||||||
}
|
</SectionContent>
|
||||||
|
</Section>
|
||||||
function AnimatedGraphicParamField({
|
);
|
||||||
param,
|
}
|
||||||
trackId,
|
|
||||||
element,
|
function AnimatedGraphicParamField({
|
||||||
localTime,
|
param,
|
||||||
isPlayheadWithinElementRange,
|
trackId,
|
||||||
resolvedParams,
|
element,
|
||||||
}: {
|
localTime,
|
||||||
param: ParamDefinition;
|
isPlayheadWithinElementRange,
|
||||||
trackId: string;
|
resolvedParams,
|
||||||
element: GraphicElement;
|
}: {
|
||||||
localTime: number;
|
param: ParamDefinition;
|
||||||
isPlayheadWithinElementRange: boolean;
|
trackId: string;
|
||||||
resolvedParams: ParamValues;
|
element: GraphicElement;
|
||||||
}) {
|
localTime: number;
|
||||||
const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty(
|
isPlayheadWithinElementRange: boolean;
|
||||||
{
|
resolvedParams: ParamValues;
|
||||||
param,
|
}) {
|
||||||
trackId,
|
const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty(
|
||||||
elementId: element.id,
|
{
|
||||||
animations: element.animations,
|
param,
|
||||||
localTime,
|
trackId,
|
||||||
isPlayheadWithinElementRange,
|
elementId: element.id,
|
||||||
resolvedValue: resolvedParams[param.key] ?? param.default,
|
animations: element.animations,
|
||||||
buildBaseUpdates: ({ value }) => ({
|
localTime,
|
||||||
params: {
|
isPlayheadWithinElementRange,
|
||||||
...element.params,
|
resolvedValue: resolvedParams[param.key] ?? param.default,
|
||||||
[param.key]: value,
|
buildBaseUpdates: ({ value }) => ({
|
||||||
},
|
params: {
|
||||||
}),
|
...element.params,
|
||||||
},
|
[param.key]: value,
|
||||||
);
|
},
|
||||||
|
}),
|
||||||
return (
|
},
|
||||||
<PropertyParamField
|
);
|
||||||
param={param}
|
|
||||||
value={resolvedParams[param.key] ?? param.default}
|
return (
|
||||||
onPreview={animatedParam.onPreview}
|
<PropertyParamField
|
||||||
onCommit={animatedParam.onCommit}
|
param={param}
|
||||||
keyframe={{
|
value={resolvedParams[param.key] ?? param.default}
|
||||||
isActive: animatedParam.isKeyframedAtTime,
|
onPreview={animatedParam.onPreview}
|
||||||
isDisabled: !isPlayheadWithinElementRange,
|
onCommit={animatedParam.onCommit}
|
||||||
onToggle: animatedParam.toggleKeyframe,
|
keyframe={{
|
||||||
}}
|
isActive: animatedParam.isKeyframedAtTime,
|
||||||
/>
|
isDisabled: !isPlayheadWithinElementRange,
|
||||||
);
|
onToggle: animatedParam.toggleKeyframe,
|
||||||
}
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { resolveGraphicParamsAtTime } from "@/animation";
|
||||||
|
import type { ElementAnimations } from "@/animation/types";
|
||||||
import { buildDefaultParamValues } from "@/params/registry";
|
import { buildDefaultParamValues } from "@/params/registry";
|
||||||
import type { ParamValues } from "@/params";
|
import type { ParamValues } from "@/params";
|
||||||
import { graphicsRegistry } from "./registry";
|
import { graphicsRegistry } from "./registry";
|
||||||
|
|
@ -68,6 +70,28 @@ export function resolveGraphicParams(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveGraphicElementParamsAtTime({
|
||||||
|
element,
|
||||||
|
localTime,
|
||||||
|
}: {
|
||||||
|
element: {
|
||||||
|
definitionId: string;
|
||||||
|
params: ParamValues;
|
||||||
|
animations?: ElementAnimations;
|
||||||
|
};
|
||||||
|
localTime: number;
|
||||||
|
}): ParamValues {
|
||||||
|
const definition = getGraphicDefinition({
|
||||||
|
definitionId: element.definitionId,
|
||||||
|
});
|
||||||
|
return resolveGraphicParamsAtTime({
|
||||||
|
params: resolveGraphicParams(definition, element.params),
|
||||||
|
definitions: definition.params,
|
||||||
|
animations: element.animations,
|
||||||
|
localTime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function buildGraphicPreviewUrl({
|
export function buildGraphicPreviewUrl({
|
||||||
definitionId,
|
definitionId,
|
||||||
params,
|
params,
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ import type { TextElement } from "@/timeline";
|
||||||
import { DEFAULTS } from "@/timeline/defaults";
|
import { DEFAULTS } from "@/timeline/defaults";
|
||||||
import {
|
import {
|
||||||
getElementLocalTime,
|
getElementLocalTime,
|
||||||
resolveTransformAtTime,
|
|
||||||
} from "@/animation";
|
} from "@/animation";
|
||||||
|
import { resolveTransformAtTime } from "@/rendering/animation-values";
|
||||||
import { resolveTextLayout } from "@/text/primitives";
|
import { resolveTextLayout } from "@/text/primitives";
|
||||||
|
|
||||||
export function TextEditOverlay({
|
export function TextEditOverlay({
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,11 @@ import { isVisualElement } from "@/timeline/element-utils";
|
||||||
import {
|
import {
|
||||||
getElementLocalTime,
|
getElementLocalTime,
|
||||||
hasKeyframesForPath,
|
hasKeyframesForPath,
|
||||||
resolveTransformAtTime,
|
|
||||||
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 { Transform } from "@/rendering";
|
||||||
|
import { resolveTransformAtTime } from "@/rendering/animation-values";
|
||||||
import type {
|
import type {
|
||||||
ElementRef,
|
ElementRef,
|
||||||
SceneTracks,
|
SceneTracks,
|
||||||
|
|
|
||||||
|
|
@ -1,308 +1,308 @@
|
||||||
import type { SceneTracks, TimelineElement } from "@/timeline";
|
import type { SceneTracks, TimelineElement } from "@/timeline";
|
||||||
import type { MediaAsset } from "@/media/types";
|
import type { MediaAsset } from "@/media/types";
|
||||||
import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/stickers/intrinsic-size";
|
import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/stickers/intrinsic-size";
|
||||||
import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/graphics";
|
import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/graphics";
|
||||||
import { measureTextElement } from "@/text/measure-element";
|
import { measureTextElement } from "@/text/measure-element";
|
||||||
import {
|
import {
|
||||||
getElementLocalTime,
|
getElementLocalTime,
|
||||||
resolveTransformAtTime,
|
} from "@/animation";
|
||||||
} from "@/animation";
|
import { resolveTransformAtTime } from "@/rendering/animation-values";
|
||||||
|
|
||||||
export interface ElementBounds {
|
export interface ElementBounds {
|
||||||
cx: number;
|
cx: number;
|
||||||
cy: number;
|
cy: number;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
rotation: number;
|
rotation: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ElementWithBounds {
|
export interface ElementWithBounds {
|
||||||
trackId: string;
|
trackId: string;
|
||||||
elementId: string;
|
elementId: string;
|
||||||
element: TimelineElement;
|
element: TimelineElement;
|
||||||
bounds: ElementBounds;
|
bounds: ElementBounds;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getVisualElementBounds({
|
function getVisualElementBounds({
|
||||||
canvasWidth,
|
canvasWidth,
|
||||||
canvasHeight,
|
canvasHeight,
|
||||||
sourceWidth,
|
sourceWidth,
|
||||||
sourceHeight,
|
sourceHeight,
|
||||||
transform,
|
transform,
|
||||||
}: {
|
}: {
|
||||||
canvasWidth: number;
|
canvasWidth: number;
|
||||||
canvasHeight: number;
|
canvasHeight: number;
|
||||||
sourceWidth: number;
|
sourceWidth: number;
|
||||||
sourceHeight: number;
|
sourceHeight: number;
|
||||||
transform: {
|
transform: {
|
||||||
scaleX: number;
|
scaleX: number;
|
||||||
scaleY: number;
|
scaleY: number;
|
||||||
position: { x: number; y: number };
|
position: { x: number; y: number };
|
||||||
rotate: number;
|
rotate: number;
|
||||||
};
|
};
|
||||||
}): ElementBounds {
|
}): ElementBounds {
|
||||||
const containScale = Math.min(
|
const containScale = Math.min(
|
||||||
canvasWidth / sourceWidth,
|
canvasWidth / sourceWidth,
|
||||||
canvasHeight / sourceHeight,
|
canvasHeight / sourceHeight,
|
||||||
);
|
);
|
||||||
const scaledWidth = sourceWidth * containScale * transform.scaleX;
|
const scaledWidth = sourceWidth * containScale * transform.scaleX;
|
||||||
const scaledHeight = sourceHeight * containScale * transform.scaleY;
|
const scaledHeight = sourceHeight * containScale * transform.scaleY;
|
||||||
const cx = canvasWidth / 2 + transform.position.x;
|
const cx = canvasWidth / 2 + transform.position.x;
|
||||||
const cy = canvasHeight / 2 + transform.position.y;
|
const cy = canvasHeight / 2 + transform.position.y;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cx,
|
cx,
|
||||||
cy,
|
cy,
|
||||||
width: scaledWidth,
|
width: scaledWidth,
|
||||||
height: scaledHeight,
|
height: scaledHeight,
|
||||||
rotation: transform.rotate,
|
rotation: transform.rotate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTransformedRectBounds({
|
function getTransformedRectBounds({
|
||||||
canvasWidth,
|
canvasWidth,
|
||||||
canvasHeight,
|
canvasHeight,
|
||||||
rect,
|
rect,
|
||||||
transform,
|
transform,
|
||||||
}: {
|
}: {
|
||||||
canvasWidth: number;
|
canvasWidth: number;
|
||||||
canvasHeight: number;
|
canvasHeight: number;
|
||||||
rect: { left: number; top: number; width: number; height: number };
|
rect: { left: number; top: number; width: number; height: number };
|
||||||
transform: {
|
transform: {
|
||||||
scaleX: number;
|
scaleX: number;
|
||||||
scaleY: number;
|
scaleY: number;
|
||||||
position: { x: number; y: number };
|
position: { x: number; y: number };
|
||||||
rotate: number;
|
rotate: number;
|
||||||
};
|
};
|
||||||
}): ElementBounds {
|
}): ElementBounds {
|
||||||
const localCenterX = rect.left + rect.width / 2;
|
const localCenterX = rect.left + rect.width / 2;
|
||||||
const localCenterY = rect.top + rect.height / 2;
|
const localCenterY = rect.top + rect.height / 2;
|
||||||
const scaledCenterX = localCenterX * transform.scaleX;
|
const scaledCenterX = localCenterX * transform.scaleX;
|
||||||
const scaledCenterY = localCenterY * transform.scaleY;
|
const scaledCenterY = localCenterY * transform.scaleY;
|
||||||
const rotationRad = (transform.rotate * Math.PI) / 180;
|
const rotationRad = (transform.rotate * Math.PI) / 180;
|
||||||
const cos = Math.cos(rotationRad);
|
const cos = Math.cos(rotationRad);
|
||||||
const sin = Math.sin(rotationRad);
|
const sin = Math.sin(rotationRad);
|
||||||
return {
|
return {
|
||||||
cx:
|
cx:
|
||||||
canvasWidth / 2 +
|
canvasWidth / 2 +
|
||||||
transform.position.x +
|
transform.position.x +
|
||||||
scaledCenterX * cos -
|
scaledCenterX * cos -
|
||||||
scaledCenterY * sin,
|
scaledCenterY * sin,
|
||||||
cy:
|
cy:
|
||||||
canvasHeight / 2 +
|
canvasHeight / 2 +
|
||||||
transform.position.y +
|
transform.position.y +
|
||||||
scaledCenterX * sin +
|
scaledCenterX * sin +
|
||||||
scaledCenterY * cos,
|
scaledCenterY * cos,
|
||||||
width: rect.width * transform.scaleX,
|
width: rect.width * transform.scaleX,
|
||||||
height: rect.height * transform.scaleY,
|
height: rect.height * transform.scaleY,
|
||||||
rotation: transform.rotate,
|
rotation: transform.rotate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bounds policy: bounds reflect base content geometry (text glyphs + background,
|
* Bounds policy: bounds reflect base content geometry (text glyphs + background,
|
||||||
* sticker/image/video content area) and base transform. Post-effect spill (blur,
|
* sticker/image/video content area) and base transform. Post-effect spill (blur,
|
||||||
* glow) and mask-clipped regions are intentionally excluded — handles manipulate
|
* glow) and mask-clipped regions are intentionally excluded — handles manipulate
|
||||||
* the canonical element geometry, not visual effect output.
|
* the canonical element geometry, not visual effect output.
|
||||||
*/
|
*/
|
||||||
function getElementBounds({
|
function getElementBounds({
|
||||||
element,
|
element,
|
||||||
canvasSize,
|
canvasSize,
|
||||||
mediaAsset,
|
mediaAsset,
|
||||||
localTime,
|
localTime,
|
||||||
}: {
|
}: {
|
||||||
element: TimelineElement;
|
element: TimelineElement;
|
||||||
canvasSize: { width: number; height: number };
|
canvasSize: { width: number; height: number };
|
||||||
mediaAsset?: MediaAsset | null;
|
mediaAsset?: MediaAsset | null;
|
||||||
localTime: number;
|
localTime: number;
|
||||||
}): ElementBounds | null {
|
}): ElementBounds | null {
|
||||||
if (element.type === "audio" || element.type === "effect") return null;
|
if (element.type === "audio" || element.type === "effect") return null;
|
||||||
if ("hidden" in element && element.hidden) return null;
|
if ("hidden" in element && element.hidden) return null;
|
||||||
|
|
||||||
const { width: canvasWidth, height: canvasHeight } = canvasSize;
|
const { width: canvasWidth, height: canvasHeight } = canvasSize;
|
||||||
|
|
||||||
if (element.type === "video" || element.type === "image") {
|
if (element.type === "video" || element.type === "image") {
|
||||||
const transform = resolveTransformAtTime({
|
const transform = resolveTransformAtTime({
|
||||||
baseTransform: element.transform,
|
baseTransform: element.transform,
|
||||||
animations: element.animations,
|
animations: element.animations,
|
||||||
localTime,
|
localTime,
|
||||||
});
|
});
|
||||||
const sourceWidth = mediaAsset?.width ?? canvasWidth;
|
const sourceWidth = mediaAsset?.width ?? canvasWidth;
|
||||||
const sourceHeight = mediaAsset?.height ?? canvasHeight;
|
const sourceHeight = mediaAsset?.height ?? canvasHeight;
|
||||||
return getVisualElementBounds({
|
return getVisualElementBounds({
|
||||||
canvasWidth,
|
canvasWidth,
|
||||||
canvasHeight,
|
canvasHeight,
|
||||||
sourceWidth,
|
sourceWidth,
|
||||||
sourceHeight,
|
sourceHeight,
|
||||||
transform,
|
transform,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (element.type === "sticker") {
|
if (element.type === "sticker") {
|
||||||
const transform = resolveTransformAtTime({
|
const transform = resolveTransformAtTime({
|
||||||
baseTransform: element.transform,
|
baseTransform: element.transform,
|
||||||
animations: element.animations,
|
animations: element.animations,
|
||||||
localTime,
|
localTime,
|
||||||
});
|
});
|
||||||
return getVisualElementBounds({
|
return getVisualElementBounds({
|
||||||
canvasWidth,
|
canvasWidth,
|
||||||
canvasHeight,
|
canvasHeight,
|
||||||
sourceWidth: element.intrinsicWidth ?? STICKER_INTRINSIC_SIZE_FALLBACK,
|
sourceWidth: element.intrinsicWidth ?? STICKER_INTRINSIC_SIZE_FALLBACK,
|
||||||
sourceHeight: element.intrinsicHeight ?? STICKER_INTRINSIC_SIZE_FALLBACK,
|
sourceHeight: element.intrinsicHeight ?? STICKER_INTRINSIC_SIZE_FALLBACK,
|
||||||
transform,
|
transform,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (element.type === "graphic") {
|
if (element.type === "graphic") {
|
||||||
const transform = resolveTransformAtTime({
|
const transform = resolveTransformAtTime({
|
||||||
baseTransform: element.transform,
|
baseTransform: element.transform,
|
||||||
animations: element.animations,
|
animations: element.animations,
|
||||||
localTime,
|
localTime,
|
||||||
});
|
});
|
||||||
return getVisualElementBounds({
|
return getVisualElementBounds({
|
||||||
canvasWidth,
|
canvasWidth,
|
||||||
canvasHeight,
|
canvasHeight,
|
||||||
sourceWidth: DEFAULT_GRAPHIC_SOURCE_SIZE,
|
sourceWidth: DEFAULT_GRAPHIC_SOURCE_SIZE,
|
||||||
sourceHeight: DEFAULT_GRAPHIC_SOURCE_SIZE,
|
sourceHeight: DEFAULT_GRAPHIC_SOURCE_SIZE,
|
||||||
transform,
|
transform,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (element.type === "text") {
|
if (element.type === "text") {
|
||||||
const transform = resolveTransformAtTime({
|
const transform = resolveTransformAtTime({
|
||||||
baseTransform: element.transform,
|
baseTransform: element.transform,
|
||||||
animations: element.animations,
|
animations: element.animations,
|
||||||
localTime,
|
localTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
if (!ctx) return null;
|
if (!ctx) return null;
|
||||||
|
|
||||||
const measured = measureTextElement({
|
const measured = measureTextElement({
|
||||||
element,
|
element,
|
||||||
canvasHeight,
|
canvasHeight,
|
||||||
localTime,
|
localTime,
|
||||||
ctx,
|
ctx,
|
||||||
});
|
});
|
||||||
|
|
||||||
return getTransformedRectBounds({
|
return getTransformedRectBounds({
|
||||||
canvasWidth,
|
canvasWidth,
|
||||||
canvasHeight,
|
canvasHeight,
|
||||||
rect: measured.visualRect,
|
rect: measured.visualRect,
|
||||||
transform,
|
transform,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ROTATION_HANDLE_OFFSET = 24;
|
export const ROTATION_HANDLE_OFFSET = 24;
|
||||||
|
|
||||||
export type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
export type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
||||||
export type Edge = "right" | "left" | "bottom";
|
export type Edge = "right" | "left" | "bottom";
|
||||||
|
|
||||||
export function getCornerPosition({
|
export function getCornerPosition({
|
||||||
bounds,
|
bounds,
|
||||||
corner,
|
corner,
|
||||||
}: {
|
}: {
|
||||||
bounds: ElementBounds;
|
bounds: ElementBounds;
|
||||||
corner: Corner;
|
corner: Corner;
|
||||||
}): { x: number; y: number } {
|
}): { x: number; y: number } {
|
||||||
const halfW = bounds.width / 2;
|
const halfW = bounds.width / 2;
|
||||||
const halfH = bounds.height / 2;
|
const halfH = bounds.height / 2;
|
||||||
const angleRad = (bounds.rotation * Math.PI) / 180;
|
const angleRad = (bounds.rotation * Math.PI) / 180;
|
||||||
const cos = Math.cos(angleRad);
|
const cos = Math.cos(angleRad);
|
||||||
const sin = Math.sin(angleRad);
|
const sin = Math.sin(angleRad);
|
||||||
const localX =
|
const localX =
|
||||||
corner === "top-left" || corner === "bottom-left" ? -halfW : halfW;
|
corner === "top-left" || corner === "bottom-left" ? -halfW : halfW;
|
||||||
const localY =
|
const localY =
|
||||||
corner === "top-left" || corner === "top-right" ? -halfH : halfH;
|
corner === "top-left" || corner === "top-right" ? -halfH : halfH;
|
||||||
return {
|
return {
|
||||||
x: bounds.cx + (localX * cos - localY * sin),
|
x: bounds.cx + (localX * cos - localY * sin),
|
||||||
y: bounds.cy + (localX * sin + localY * cos),
|
y: bounds.cy + (localX * sin + localY * cos),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getEdgeHandlePosition({
|
export function getEdgeHandlePosition({
|
||||||
bounds,
|
bounds,
|
||||||
edge,
|
edge,
|
||||||
}: {
|
}: {
|
||||||
bounds: ElementBounds;
|
bounds: ElementBounds;
|
||||||
edge: Edge;
|
edge: Edge;
|
||||||
}): { x: number; y: number } {
|
}): { x: number; y: number } {
|
||||||
const halfWidth = bounds.width / 2;
|
const halfWidth = bounds.width / 2;
|
||||||
const halfHeight = bounds.height / 2;
|
const halfHeight = bounds.height / 2;
|
||||||
const angleRad = (bounds.rotation * Math.PI) / 180;
|
const angleRad = (bounds.rotation * Math.PI) / 180;
|
||||||
const cos = Math.cos(angleRad);
|
const cos = Math.cos(angleRad);
|
||||||
const sin = Math.sin(angleRad);
|
const sin = Math.sin(angleRad);
|
||||||
const localX = edge === "right" ? halfWidth : edge === "left" ? -halfWidth : 0;
|
const localX = edge === "right" ? halfWidth : edge === "left" ? -halfWidth : 0;
|
||||||
const localY = edge === "bottom" ? halfHeight : 0;
|
const localY = edge === "bottom" ? halfHeight : 0;
|
||||||
return {
|
return {
|
||||||
x: bounds.cx + (localX * cos - localY * sin),
|
x: bounds.cx + (localX * cos - localY * sin),
|
||||||
y: bounds.cy + (localX * sin + localY * cos),
|
y: bounds.cy + (localX * sin + localY * cos),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getVisibleElementsWithBounds({
|
export function getVisibleElementsWithBounds({
|
||||||
tracks,
|
tracks,
|
||||||
currentTime,
|
currentTime,
|
||||||
canvasSize,
|
canvasSize,
|
||||||
mediaAssets,
|
mediaAssets,
|
||||||
}: {
|
}: {
|
||||||
tracks: SceneTracks;
|
tracks: SceneTracks;
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
canvasSize: { width: number; height: number };
|
canvasSize: { width: number; height: number };
|
||||||
mediaAssets: MediaAsset[];
|
mediaAssets: MediaAsset[];
|
||||||
}): ElementWithBounds[] {
|
}): ElementWithBounds[] {
|
||||||
const mediaMap = new Map(mediaAssets.map((m) => [m.id, m]));
|
const mediaMap = new Map(mediaAssets.map((m) => [m.id, m]));
|
||||||
const orderedTracks = [
|
const orderedTracks = [
|
||||||
...tracks.overlay.filter((track) => !("hidden" in track && track.hidden)),
|
...tracks.overlay.filter((track) => !("hidden" in track && track.hidden)),
|
||||||
...(!tracks.main.hidden ? [tracks.main] : []),
|
...(!tracks.main.hidden ? [tracks.main] : []),
|
||||||
].reverse();
|
].reverse();
|
||||||
|
|
||||||
const result: ElementWithBounds[] = [];
|
const result: ElementWithBounds[] = [];
|
||||||
|
|
||||||
for (const track of orderedTracks) {
|
for (const track of orderedTracks) {
|
||||||
const elements = track.elements
|
const elements = track.elements
|
||||||
.filter((element) => !("hidden" in element && element.hidden))
|
.filter((element) => !("hidden" in element && element.hidden))
|
||||||
.filter(
|
.filter(
|
||||||
(element) =>
|
(element) =>
|
||||||
currentTime >= element.startTime &&
|
currentTime >= element.startTime &&
|
||||||
currentTime < element.startTime + element.duration,
|
currentTime < element.startTime + element.duration,
|
||||||
)
|
)
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
if (a.startTime !== b.startTime) return a.startTime - b.startTime;
|
if (a.startTime !== b.startTime) return a.startTime - b.startTime;
|
||||||
return a.id.localeCompare(b.id);
|
return a.id.localeCompare(b.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const element of elements) {
|
for (const element of elements) {
|
||||||
const localTime = getElementLocalTime({
|
const localTime = getElementLocalTime({
|
||||||
timelineTime: currentTime,
|
timelineTime: currentTime,
|
||||||
elementStartTime: element.startTime,
|
elementStartTime: element.startTime,
|
||||||
elementDuration: element.duration,
|
elementDuration: element.duration,
|
||||||
});
|
});
|
||||||
const mediaAsset =
|
const mediaAsset =
|
||||||
element.type === "video" || element.type === "image"
|
element.type === "video" || element.type === "image"
|
||||||
? mediaMap.get(element.mediaId)
|
? mediaMap.get(element.mediaId)
|
||||||
: undefined;
|
: undefined;
|
||||||
const bounds = getElementBounds({
|
const bounds = getElementBounds({
|
||||||
element,
|
element,
|
||||||
canvasSize,
|
canvasSize,
|
||||||
mediaAsset,
|
mediaAsset,
|
||||||
localTime,
|
localTime,
|
||||||
});
|
});
|
||||||
if (bounds) {
|
if (bounds) {
|
||||||
result.push({
|
result.push({
|
||||||
trackId: track.id,
|
trackId: track.id,
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
element,
|
element,
|
||||||
bounds,
|
bounds,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
# Notes
|
||||||
|
|
||||||
|
## `Transform` is misplaced
|
||||||
|
|
||||||
|
`Transform` is currently defined in `apps/web/src/rendering/index.ts`. It's
|
||||||
|
exported from the rendering domain because rendering happens to be one of its
|
||||||
|
consumers — but every other domain that touches scene geometry consumes it too
|
||||||
|
(`@/timeline`, `@/preview`, `@/animation/values`, `@/text`, etc.). It's not
|
||||||
|
"of" rendering any more than `Vector2` would be "of" math.
|
||||||
|
|
||||||
|
`Transform` is closer to a primitive than a domain concept. It's infrastructure:
|
||||||
|
a small value type with no behavior, no dependencies, no domain-specific
|
||||||
|
invariants. Anything that wants to position something on a 2D canvas needs it.
|
||||||
|
|
||||||
|
The codebase should be refactored so it's defined lower-level, rather than
|
||||||
|
sitting in a domain. Candidate homes:
|
||||||
|
|
||||||
|
- `apps/web/src/primitives/transform.ts` (new dir for these value types)
|
||||||
|
- `apps/web/src/geometry/transform.ts` if `Vector2` and friends end up there
|
||||||
|
- `apps/web/src/rendering/transform.ts` is acceptable only if `@/rendering`
|
||||||
|
becomes a primitives folder rather than a domain (it's borderline today —
|
||||||
|
it also exports `BlendMode`, which has the same problem).
|
||||||
|
|
||||||
|
Side effect of fixing this: `@/rendering/animation-values.ts` (which exists
|
||||||
|
only because `Transform` lives next to it) can move into `@/animation/values.ts`
|
||||||
|
alongside the other resolve-at-time helpers, since `Transform` would no longer
|
||||||
|
pull in a domain dependency.
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
import type { ElementAnimations } from "@/animation/types";
|
||||||
|
import { resolveAnimationPathValueAtTime } from "@/animation";
|
||||||
|
import type { Transform } from "./index";
|
||||||
|
|
||||||
|
export function resolveTransformAtTime({
|
||||||
|
baseTransform,
|
||||||
|
animations,
|
||||||
|
localTime,
|
||||||
|
}: {
|
||||||
|
baseTransform: Transform;
|
||||||
|
animations: ElementAnimations | undefined;
|
||||||
|
localTime: number;
|
||||||
|
}): Transform {
|
||||||
|
const safeLocalTime = Math.max(0, localTime);
|
||||||
|
return {
|
||||||
|
position: {
|
||||||
|
x: resolveAnimationPathValueAtTime({
|
||||||
|
animations,
|
||||||
|
propertyPath: "transform.positionX",
|
||||||
|
localTime: safeLocalTime,
|
||||||
|
fallbackValue: baseTransform.position.x,
|
||||||
|
}),
|
||||||
|
y: resolveAnimationPathValueAtTime({
|
||||||
|
animations,
|
||||||
|
propertyPath: "transform.positionY",
|
||||||
|
localTime: safeLocalTime,
|
||||||
|
fallbackValue: baseTransform.position.y,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
scaleX: resolveAnimationPathValueAtTime({
|
||||||
|
animations,
|
||||||
|
propertyPath: "transform.scaleX",
|
||||||
|
localTime: safeLocalTime,
|
||||||
|
fallbackValue: baseTransform.scaleX,
|
||||||
|
}),
|
||||||
|
scaleY: resolveAnimationPathValueAtTime({
|
||||||
|
animations,
|
||||||
|
propertyPath: "transform.scaleY",
|
||||||
|
localTime: safeLocalTime,
|
||||||
|
fallbackValue: baseTransform.scaleY,
|
||||||
|
}),
|
||||||
|
rotate: resolveAnimationPathValueAtTime({
|
||||||
|
animations,
|
||||||
|
propertyPath: "transform.rotate",
|
||||||
|
localTime: safeLocalTime,
|
||||||
|
fallbackValue: baseTransform.rotate,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -27,7 +27,7 @@ import { RainDropIcon } from "@hugeicons/core-free-icons";
|
||||||
import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
|
import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
|
||||||
import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property";
|
import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property";
|
||||||
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
|
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
|
||||||
import { resolveOpacityAtTime } from "@/animation";
|
import { resolveOpacityAtTime } from "@/animation/values";
|
||||||
import { DEFAULTS } from "@/timeline/defaults";
|
import { DEFAULTS } from "@/timeline/defaults";
|
||||||
import { isPropertyAtDefault } from "./transform-tab";
|
import { isPropertyAtDefault } from "./transform-tab";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@ import {
|
||||||
import {
|
import {
|
||||||
getGroupKeyframesAtTime,
|
getGroupKeyframesAtTime,
|
||||||
hasGroupKeyframeAtTime,
|
hasGroupKeyframeAtTime,
|
||||||
resolveTransformAtTime,
|
|
||||||
} from "@/animation";
|
} from "@/animation";
|
||||||
|
import { resolveTransformAtTime } from "@/rendering/animation-values";
|
||||||
import { DEFAULTS } from "@/timeline/defaults";
|
import { DEFAULTS } from "@/timeline/defaults";
|
||||||
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
|
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
|
||||||
import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
|
import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,5 @@
|
||||||
import { mediaTimeToSeconds } from "opencut-wasm";
|
import { mediaTimeToSeconds } from "opencut-wasm";
|
||||||
import {
|
import { getElementLocalTime } from "@/animation";
|
||||||
getElementLocalTime,
|
|
||||||
resolveColorAtTime,
|
|
||||||
resolveGraphicParamsAtTime,
|
|
||||||
resolveOpacityAtTime,
|
|
||||||
resolveTransformAtTime,
|
|
||||||
} from "@/animation";
|
|
||||||
import { resolveEffectParamsAtTime } from "@/animation/effect-param-channel";
|
import { resolveEffectParamsAtTime } from "@/animation/effect-param-channel";
|
||||||
import {
|
import {
|
||||||
buildGaussianBlurPasses,
|
buildGaussianBlurPasses,
|
||||||
|
|
@ -14,11 +8,16 @@ import {
|
||||||
import { effectsRegistry, resolveEffectPasses } from "@/effects";
|
import { effectsRegistry, resolveEffectPasses } from "@/effects";
|
||||||
import type { Effect, EffectPass } from "@/effects/types";
|
import type { Effect, EffectPass } from "@/effects/types";
|
||||||
import { getSourceTimeAtClipTime } from "@/retime";
|
import { getSourceTimeAtClipTime } from "@/retime";
|
||||||
import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/graphics";
|
import {
|
||||||
|
DEFAULT_GRAPHIC_SOURCE_SIZE,
|
||||||
|
resolveGraphicElementParamsAtTime,
|
||||||
|
} from "@/graphics";
|
||||||
import {
|
import {
|
||||||
getTextMeasurementContext,
|
getTextMeasurementContext,
|
||||||
measureTextElement,
|
measureTextElement,
|
||||||
} from "@/text/measure-element";
|
} from "@/text/measure-element";
|
||||||
|
import { resolveColorAtTime, resolveOpacityAtTime } from "@/animation/values";
|
||||||
|
import { resolveTransformAtTime } from "@/rendering/animation-values";
|
||||||
import { videoCache } from "@/services/video-cache/service";
|
import { videoCache } from "@/services/video-cache/service";
|
||||||
import type { CanvasRenderer } from "./canvas-renderer";
|
import type { CanvasRenderer } from "./canvas-renderer";
|
||||||
import type { AnyBaseNode } from "./nodes/base-node";
|
import type { AnyBaseNode } from "./nodes/base-node";
|
||||||
|
|
@ -113,7 +112,8 @@ function resolveEffectPassGroups({
|
||||||
.filter((effect) => effect.enabled)
|
.filter((effect) => effect.enabled)
|
||||||
.map((effect) => {
|
.map((effect) => {
|
||||||
const resolvedParams = resolveEffectParamsAtTime({
|
const resolvedParams = resolveEffectParamsAtTime({
|
||||||
effect,
|
effectId: effect.id,
|
||||||
|
params: effect.params,
|
||||||
animations,
|
animations,
|
||||||
localTime,
|
localTime,
|
||||||
});
|
});
|
||||||
|
|
@ -304,7 +304,7 @@ function resolveGraphicNode({
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...visualState,
|
...visualState,
|
||||||
resolvedParams: resolveGraphicParamsAtTime({
|
resolvedParams: resolveGraphicElementParamsAtTime({
|
||||||
element: node.params,
|
element: node.params,
|
||||||
localTime: visualState.localTime,
|
localTime: visualState.localTime,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,10 @@ import { useKeyframedNumberProperty } from "@/components/editor/panels/propertie
|
||||||
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
|
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
|
||||||
import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
|
import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
|
||||||
import { isPropertyAtDefault } from "@/rendering/components/transform-tab";
|
import { isPropertyAtDefault } from "@/rendering/components/transform-tab";
|
||||||
import { resolveColorAtTime, resolveNumberAtTime } from "@/animation";
|
import {
|
||||||
|
resolveColorAtTime,
|
||||||
|
resolveNumberAtTime,
|
||||||
|
} from "@/animation/values";
|
||||||
import { HugeiconsIcon } from "@hugeicons/react";
|
import { HugeiconsIcon } from "@hugeicons/react";
|
||||||
import {
|
import {
|
||||||
MinusSignIcon,
|
MinusSignIcon,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { CORNER_RADIUS_MIN } from "@/text/background";
|
import { CORNER_RADIUS_MIN } from "@/text/background";
|
||||||
import { resolveNumberAtTime } from "@/animation";
|
|
||||||
import { DEFAULTS } from "@/timeline/defaults";
|
import { DEFAULTS } from "@/timeline/defaults";
|
||||||
import type { TextBackground, TextElement } from "@/timeline";
|
import type { TextBackground, TextElement } from "@/timeline";
|
||||||
|
import { resolveNumberAtTime } from "@/animation/values";
|
||||||
import {
|
import {
|
||||||
getTextVisualRect,
|
getTextVisualRect,
|
||||||
} from "./layout";
|
} from "./layout";
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,15 @@ import type {
|
||||||
AnimationInterpolation,
|
AnimationInterpolation,
|
||||||
AnimationPropertyPath,
|
AnimationPropertyPath,
|
||||||
AnimationValue,
|
AnimationValue,
|
||||||
|
ElementAnimations,
|
||||||
|
NumericSpec,
|
||||||
} from "@/animation/types";
|
} from "@/animation/types";
|
||||||
import { parseColorToLinearRgba } from "./binding-values";
|
import { upsertPathKeyframe } from "@/animation";
|
||||||
import type { TimelineElement } from "@/timeline";
|
import { parseColorToLinearRgba } from "@/animation/binding-values";
|
||||||
|
import { isAnimationPropertyPath } from "@/animation/path";
|
||||||
import { MIN_TRANSFORM_SCALE } from "@/animation/transform";
|
import { MIN_TRANSFORM_SCALE } from "@/animation/transform";
|
||||||
|
import { snapToStep } from "@/utils/math";
|
||||||
|
import type { TimelineElement } from "@/timeline";
|
||||||
import {
|
import {
|
||||||
CORNER_RADIUS_MAX,
|
CORNER_RADIUS_MAX,
|
||||||
CORNER_RADIUS_MIN,
|
CORNER_RADIUS_MIN,
|
||||||
|
|
@ -17,13 +22,6 @@ import {
|
||||||
} from "@/timeline/element-utils";
|
} from "@/timeline/element-utils";
|
||||||
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
|
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
|
||||||
import { DEFAULTS } from "@/timeline/defaults";
|
import { DEFAULTS } from "@/timeline/defaults";
|
||||||
import { snapToStep } from "@/utils/math";
|
|
||||||
|
|
||||||
export interface NumericSpec {
|
|
||||||
min?: number;
|
|
||||||
max?: number;
|
|
||||||
step?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AnimationPropertyDefinition {
|
export interface AnimationPropertyDefinition {
|
||||||
kind: AnimationBindingKind;
|
kind: AnimationBindingKind;
|
||||||
|
|
@ -313,12 +311,6 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
export function isAnimationPropertyPath(
|
|
||||||
propertyPath: string,
|
|
||||||
): propertyPath is AnimationPropertyPath {
|
|
||||||
return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAnimationPropertyDefinition({
|
export function getAnimationPropertyDefinition({
|
||||||
propertyPath,
|
propertyPath,
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -349,6 +341,7 @@ export function getElementBaseValueForProperty({
|
||||||
if (!definition.supportsElement({ element })) {
|
if (!definition.supportsElement({ element })) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return definition.getValue({ element });
|
return definition.getValue({ element });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -366,6 +359,7 @@ export function withElementBaseValueForProperty({
|
||||||
if (coercedValue === null || !definition.supportsElement({ element })) {
|
if (coercedValue === null || !definition.supportsElement({ element })) {
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
|
||||||
return definition.setValue({ element, value: coercedValue });
|
return definition.setValue({ element, value: coercedValue });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -388,3 +382,44 @@ export function coerceAnimationValueForProperty({
|
||||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||||
return propertyDefinition.coerceValue({ value });
|
return propertyDefinition.coerceValue({ value });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function upsertElementKeyframe({
|
||||||
|
animations,
|
||||||
|
propertyPath,
|
||||||
|
time,
|
||||||
|
value,
|
||||||
|
interpolation,
|
||||||
|
keyframeId,
|
||||||
|
}: {
|
||||||
|
animations: ElementAnimations | undefined;
|
||||||
|
propertyPath: AnimationPropertyPath;
|
||||||
|
time: number;
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -3,30 +3,32 @@ import type {
|
||||||
AnimationInterpolation,
|
AnimationInterpolation,
|
||||||
AnimationPath,
|
AnimationPath,
|
||||||
AnimationValue,
|
AnimationValue,
|
||||||
|
NumericSpec,
|
||||||
} from "@/animation/types";
|
} from "@/animation/types";
|
||||||
|
import {
|
||||||
|
coerceAnimationParamValue,
|
||||||
|
getAnimationParamDefaultInterpolation,
|
||||||
|
getAnimationParamNumericRange,
|
||||||
|
getAnimationParamValueKind,
|
||||||
|
} from "@/animation/animated-params";
|
||||||
import {
|
import {
|
||||||
parseEffectParamPath,
|
parseEffectParamPath,
|
||||||
isEffectParamPath,
|
|
||||||
} from "@/animation/effect-param-channel";
|
} from "@/animation/effect-param-channel";
|
||||||
import {
|
import {
|
||||||
isGraphicParamPath,
|
|
||||||
parseGraphicParamPath,
|
parseGraphicParamPath,
|
||||||
} from "@/animation/graphic-param-channel";
|
} from "@/animation/graphic-param-channel";
|
||||||
import type { ParamDefinition } from "@/params";
|
|
||||||
import { effectsRegistry, registerDefaultEffects } from "@/effects";
|
import { effectsRegistry, registerDefaultEffects } from "@/effects";
|
||||||
import { getGraphicDefinition } from "@/graphics";
|
import { getGraphicDefinition } from "@/graphics";
|
||||||
|
import type { ParamDefinition } from "@/params";
|
||||||
import type { TimelineElement } from "@/timeline";
|
import type { TimelineElement } from "@/timeline";
|
||||||
import { isVisualElement } from "@/timeline/element-utils";
|
import { isVisualElement } from "@/timeline/element-utils";
|
||||||
import { snapToStep } from "@/utils/math";
|
import { isAnimationPropertyPath } from "@/animation/path";
|
||||||
import {
|
import {
|
||||||
coerceAnimationValueForProperty,
|
coerceAnimationValueForProperty,
|
||||||
getAnimationPropertyDefinition,
|
getAnimationPropertyDefinition,
|
||||||
getElementBaseValueForProperty,
|
getElementBaseValueForProperty,
|
||||||
isAnimationPropertyPath,
|
|
||||||
type NumericSpec,
|
|
||||||
withElementBaseValueForProperty,
|
withElementBaseValueForProperty,
|
||||||
} from "./property-registry";
|
} from "./animation-properties";
|
||||||
import { parseColorToLinearRgba } from "./binding-values";
|
|
||||||
|
|
||||||
export interface AnimationPathDescriptor {
|
export interface AnimationPathDescriptor {
|
||||||
kind: AnimationBindingKind;
|
kind: AnimationBindingKind;
|
||||||
|
|
@ -37,79 +39,16 @@ export interface AnimationPathDescriptor {
|
||||||
setBaseValue: ({ value }: { value: AnimationValue }) => TimelineElement;
|
setBaseValue: ({ value }: { value: AnimationValue }) => TimelineElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getParamValueKind({
|
// Number/discrete bindings expose a single component named "value"
|
||||||
param,
|
// (see binding-values.ts). Multi-component kinds (vector2, color) don't carry
|
||||||
}: {
|
// numeric ranges yet — revisit when one does.
|
||||||
param: ParamDefinition;
|
function paramNumericRanges({
|
||||||
}): AnimationBindingKind {
|
|
||||||
if (param.type === "number") {
|
|
||||||
return "number";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (param.type === "color") {
|
|
||||||
return "color";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "discrete";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getParamDefaultInterpolation({
|
|
||||||
param,
|
|
||||||
}: {
|
|
||||||
param: ParamDefinition;
|
|
||||||
}): AnimationInterpolation {
|
|
||||||
return param.type === "number" || param.type === "color" ? "linear" : "hold";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getParamNumericRange({
|
|
||||||
param,
|
param,
|
||||||
}: {
|
}: {
|
||||||
param: ParamDefinition;
|
param: ParamDefinition;
|
||||||
}): Partial<Record<string, NumericSpec>> | undefined {
|
}): Partial<Record<string, NumericSpec>> | undefined {
|
||||||
if (param.type !== "number") {
|
const range = getAnimationParamNumericRange({ param });
|
||||||
return undefined;
|
return range ? { value: range } : undefined;
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
value: {
|
|
||||||
min: param.min,
|
|
||||||
max: param.max,
|
|
||||||
step: param.step,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function coerceAnimationValueForParam({
|
|
||||||
param,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
param: ParamDefinition;
|
|
||||||
value: AnimationValue;
|
|
||||||
}): number | string | boolean | 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildGraphicParamDescriptor({
|
function buildGraphicParamDescriptor({
|
||||||
|
|
@ -132,13 +71,13 @@ function buildGraphicParamDescriptor({
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
kind: getParamValueKind({ param }),
|
kind: getAnimationParamValueKind({ param }),
|
||||||
defaultInterpolation: getParamDefaultInterpolation({ param }),
|
defaultInterpolation: getAnimationParamDefaultInterpolation({ param }),
|
||||||
numericRanges: getParamNumericRange({ param }),
|
numericRanges: paramNumericRanges({ param }),
|
||||||
coerceValue: ({ value }) => coerceAnimationValueForParam({ param, value }),
|
coerceValue: ({ value }) => coerceAnimationParamValue({ param, value }),
|
||||||
getBaseValue: () => element.params[param.key] ?? param.default,
|
getBaseValue: () => element.params[param.key] ?? param.default,
|
||||||
setBaseValue: ({ value }) => {
|
setBaseValue: ({ value }) => {
|
||||||
const coercedValue = coerceAnimationValueForParam({ param, value });
|
const coercedValue = coerceAnimationParamValue({ param, value });
|
||||||
if (coercedValue === null) {
|
if (coercedValue === null) {
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
|
@ -180,13 +119,13 @@ function buildEffectParamDescriptor({
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
kind: getParamValueKind({ param }),
|
kind: getAnimationParamValueKind({ param }),
|
||||||
defaultInterpolation: getParamDefaultInterpolation({ param }),
|
defaultInterpolation: getAnimationParamDefaultInterpolation({ param }),
|
||||||
numericRanges: getParamNumericRange({ param }),
|
numericRanges: paramNumericRanges({ param }),
|
||||||
coerceValue: ({ value }) => coerceAnimationValueForParam({ param, value }),
|
coerceValue: ({ value }) => coerceAnimationParamValue({ param, value }),
|
||||||
getBaseValue: () => effect.params[param.key] ?? param.default,
|
getBaseValue: () => effect.params[param.key] ?? param.default,
|
||||||
setBaseValue: ({ value }) => {
|
setBaseValue: ({ value }) => {
|
||||||
const coercedValue = coerceAnimationValueForParam({ param, value });
|
const coercedValue = coerceAnimationParamValue({ param, value });
|
||||||
if (coercedValue === null) {
|
if (coercedValue === null) {
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
|
@ -210,16 +149,6 @@ function buildEffectParamDescriptor({
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAnimationPath(
|
|
||||||
propertyPath: string,
|
|
||||||
): propertyPath is AnimationPath {
|
|
||||||
return (
|
|
||||||
isAnimationPropertyPath(propertyPath) ||
|
|
||||||
isGraphicParamPath(propertyPath) ||
|
|
||||||
isEffectParamPath(propertyPath)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveAnimationTarget({
|
export function resolveAnimationTarget({
|
||||||
element,
|
element,
|
||||||
path,
|
path,
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { hasKeyframesForPath } from "@/animation/keyframe-query";
|
import { hasKeyframesForPath } from "@/animation/keyframe-query";
|
||||||
import { resolveNumberAtTime } from "@/animation/resolve";
|
import { resolveNumberAtTime } from "@/animation/values";
|
||||||
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "./audio-constants";
|
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "./audio-constants";
|
||||||
import type { TimelineElement } from "./types";
|
import type { TimelineElement } from "./types";
|
||||||
const DEFAULT_STEP_SECONDS = 1 / 60;
|
const DEFAULT_STEP_SECONDS = 1 / 60;
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import {
|
||||||
import { getBookmarkSnapPoints } from "../snap-source";
|
import { getBookmarkSnapPoints } from "../snap-source";
|
||||||
import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
|
import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
|
||||||
import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source";
|
import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source";
|
||||||
import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
|
import { getAnimationKeyframeSnapPointsForTimeline } from "@/timeline/animation-snap-points";
|
||||||
import type { Bookmark } from "@/timeline";
|
import type { Bookmark } from "@/timeline";
|
||||||
|
|
||||||
export interface BookmarkDragState {
|
export interface BookmarkDragState {
|
||||||
|
|
|
||||||
|
|
@ -1,151 +1,151 @@
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { NumberField } from "@/components/ui/number-field";
|
import { NumberField } from "@/components/ui/number-field";
|
||||||
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
|
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
|
||||||
import { isSourceAudioSeparated } from "@/timeline/audio-separation";
|
import { isSourceAudioSeparated } from "@/timeline/audio-separation";
|
||||||
import { DEFAULTS } from "@/timeline/defaults";
|
import { DEFAULTS } from "@/timeline/defaults";
|
||||||
import {
|
import {
|
||||||
clamp,
|
clamp,
|
||||||
formatNumberForDisplay,
|
formatNumberForDisplay,
|
||||||
getFractionDigitsForStep,
|
getFractionDigitsForStep,
|
||||||
isNearlyEqual,
|
isNearlyEqual,
|
||||||
snapToStep,
|
snapToStep,
|
||||||
} from "@/utils/math";
|
} from "@/utils/math";
|
||||||
import type { AudioElement, VideoElement } from "@/timeline";
|
import type { AudioElement, VideoElement } from "@/timeline";
|
||||||
import { resolveNumberAtTime } from "@/animation";
|
import { resolveNumberAtTime } from "@/animation/values";
|
||||||
import { useEditor } from "@/editor/use-editor";
|
import { useEditor } from "@/editor/use-editor";
|
||||||
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
|
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
|
||||||
import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property";
|
import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property";
|
||||||
import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
|
import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
|
||||||
import { HugeiconsIcon } from "@hugeicons/react";
|
import { HugeiconsIcon } from "@hugeicons/react";
|
||||||
import { VolumeHighIcon } from "@hugeicons/core-free-icons";
|
import { VolumeHighIcon } from "@hugeicons/core-free-icons";
|
||||||
import {
|
import {
|
||||||
Section,
|
Section,
|
||||||
SectionContent,
|
SectionContent,
|
||||||
SectionField,
|
SectionField,
|
||||||
SectionFields,
|
SectionFields,
|
||||||
SectionHeader,
|
SectionHeader,
|
||||||
SectionTitle,
|
SectionTitle,
|
||||||
} from "@/components/section";
|
} from "@/components/section";
|
||||||
|
|
||||||
const VOLUME_STEP = 0.1;
|
const VOLUME_STEP = 0.1;
|
||||||
const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP });
|
const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP });
|
||||||
|
|
||||||
export function AudioTab({
|
export function AudioTab({
|
||||||
element,
|
element,
|
||||||
trackId,
|
trackId,
|
||||||
}: {
|
}: {
|
||||||
element: AudioElement | VideoElement;
|
element: AudioElement | VideoElement;
|
||||||
trackId: string;
|
trackId: string;
|
||||||
}) {
|
}) {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
||||||
startTime: element.startTime,
|
startTime: element.startTime,
|
||||||
duration: element.duration,
|
duration: element.duration,
|
||||||
});
|
});
|
||||||
const resolvedVolume = resolveNumberAtTime({
|
const resolvedVolume = resolveNumberAtTime({
|
||||||
baseValue: element.volume ?? DEFAULTS.element.volume,
|
baseValue: element.volume ?? DEFAULTS.element.volume,
|
||||||
animations: element.animations,
|
animations: element.animations,
|
||||||
propertyPath: "volume",
|
propertyPath: "volume",
|
||||||
localTime,
|
localTime,
|
||||||
});
|
});
|
||||||
const volume = useKeyframedNumberProperty({
|
const volume = useKeyframedNumberProperty({
|
||||||
trackId,
|
trackId,
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
animations: element.animations,
|
animations: element.animations,
|
||||||
propertyPath: "volume",
|
propertyPath: "volume",
|
||||||
localTime,
|
localTime,
|
||||||
isPlayheadWithinElementRange,
|
isPlayheadWithinElementRange,
|
||||||
displayValue: formatNumberForDisplay({
|
displayValue: formatNumberForDisplay({
|
||||||
value: resolvedVolume,
|
value: resolvedVolume,
|
||||||
fractionDigits: VOLUME_FRACTION_DIGITS,
|
fractionDigits: VOLUME_FRACTION_DIGITS,
|
||||||
}),
|
}),
|
||||||
parse: (input) => {
|
parse: (input) => {
|
||||||
const parsed = parseFloat(input);
|
const parsed = parseFloat(input);
|
||||||
if (Number.isNaN(parsed)) {
|
if (Number.isNaN(parsed)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return clamp({
|
return clamp({
|
||||||
value: snapToStep({ value: parsed, step: VOLUME_STEP }),
|
value: snapToStep({ value: parsed, step: VOLUME_STEP }),
|
||||||
min: VOLUME_DB_MIN,
|
min: VOLUME_DB_MIN,
|
||||||
max: VOLUME_DB_MAX,
|
max: VOLUME_DB_MAX,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
valueAtPlayhead: resolvedVolume,
|
valueAtPlayhead: resolvedVolume,
|
||||||
step: VOLUME_STEP,
|
step: VOLUME_STEP,
|
||||||
buildBaseUpdates: ({ value }) => ({
|
buildBaseUpdates: ({ value }) => ({
|
||||||
volume: value,
|
volume: value,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const isDefault =
|
const isDefault =
|
||||||
volume.hasAnimatedKeyframes && isPlayheadWithinElementRange
|
volume.hasAnimatedKeyframes && isPlayheadWithinElementRange
|
||||||
? isNearlyEqual({
|
? isNearlyEqual({
|
||||||
leftValue: resolvedVolume,
|
leftValue: resolvedVolume,
|
||||||
rightValue: DEFAULTS.element.volume,
|
rightValue: DEFAULTS.element.volume,
|
||||||
})
|
})
|
||||||
: (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume;
|
: (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume;
|
||||||
const isSeparated =
|
const isSeparated =
|
||||||
element.type === "video" && isSourceAudioSeparated({ element });
|
element.type === "video" && isSourceAudioSeparated({ element });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isSeparated && (
|
{isSeparated && (
|
||||||
<div className="mx-4 mt-4 rounded-md border bg-muted/30 p-3">
|
<div className="mx-4 mt-4 rounded-md border bg-muted/30 p-3">
|
||||||
<p className="text-sm">Audio has been separated.</p>
|
<p className="text-sm">Audio has been separated.</p>
|
||||||
<Button
|
<Button
|
||||||
className="mt-3"
|
className="mt-3"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
editor.timeline.toggleSourceAudioSeparation({
|
editor.timeline.toggleSourceAudioSeparation({
|
||||||
trackId,
|
trackId,
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Recover audio
|
Recover audio
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Section collapsible sectionKey={`${element.id}:audio`}>
|
<Section collapsible sectionKey={`${element.id}:audio`}>
|
||||||
<SectionHeader>
|
<SectionHeader>
|
||||||
<SectionTitle>Audio</SectionTitle>
|
<SectionTitle>Audio</SectionTitle>
|
||||||
</SectionHeader>
|
</SectionHeader>
|
||||||
<SectionContent>
|
<SectionContent>
|
||||||
<SectionFields>
|
<SectionFields>
|
||||||
<SectionField
|
<SectionField
|
||||||
label="Volume"
|
label="Volume"
|
||||||
beforeLabel={
|
beforeLabel={
|
||||||
<KeyframeToggle
|
<KeyframeToggle
|
||||||
isActive={volume.isKeyframedAtTime}
|
isActive={volume.isKeyframedAtTime}
|
||||||
isDisabled={!isPlayheadWithinElementRange}
|
isDisabled={!isPlayheadWithinElementRange}
|
||||||
title="Toggle volume keyframe"
|
title="Toggle volume keyframe"
|
||||||
onToggle={volume.toggleKeyframe}
|
onToggle={volume.toggleKeyframe}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<NumberField
|
<NumberField
|
||||||
icon={<HugeiconsIcon icon={VolumeHighIcon} />}
|
icon={<HugeiconsIcon icon={VolumeHighIcon} />}
|
||||||
value={volume.displayValue}
|
value={volume.displayValue}
|
||||||
onFocus={volume.onFocus}
|
onFocus={volume.onFocus}
|
||||||
onChange={volume.onChange}
|
onChange={volume.onChange}
|
||||||
onBlur={volume.onBlur}
|
onBlur={volume.onBlur}
|
||||||
dragSensitivity="slow"
|
dragSensitivity="slow"
|
||||||
scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }}
|
scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }}
|
||||||
onScrub={volume.scrubTo}
|
onScrub={volume.scrubTo}
|
||||||
onScrubEnd={volume.commitScrub}
|
onScrubEnd={volume.commitScrub}
|
||||||
onReset={() =>
|
onReset={() =>
|
||||||
volume.commitValue({
|
volume.commitValue({
|
||||||
value: DEFAULTS.element.volume,
|
value: DEFAULTS.element.volume,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
isDefault={isDefault}
|
isDefault={isDefault}
|
||||||
suffix="dB"
|
suffix="dB"
|
||||||
/>
|
/>
|
||||||
</SectionField>
|
</SectionField>
|
||||||
</SectionFields>
|
</SectionFields>
|
||||||
</SectionContent>
|
</SectionContent>
|
||||||
</Section>
|
</Section>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import {
|
||||||
} from "@/timeline/snapping";
|
} from "@/timeline/snapping";
|
||||||
import { getBookmarkSnapPoints } from "@/timeline/bookmarks/index";
|
import { getBookmarkSnapPoints } from "@/timeline/bookmarks/index";
|
||||||
import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
|
import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
|
||||||
import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
|
import { getAnimationKeyframeSnapPointsForTimeline } from "@/timeline/animation-snap-points";
|
||||||
import {
|
import {
|
||||||
getCenteredLineLeft,
|
getCenteredLineLeft,
|
||||||
timelineTimeToPixels,
|
timelineTimeToPixels,
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import {
|
||||||
} from "@/timeline/snapping";
|
} from "@/timeline/snapping";
|
||||||
import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
|
import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
|
||||||
import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source";
|
import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source";
|
||||||
import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
|
import { getAnimationKeyframeSnapPointsForTimeline } from "@/timeline/animation-snap-points";
|
||||||
import {
|
import {
|
||||||
isRetimableElement,
|
isRetimableElement,
|
||||||
type SceneTracks,
|
type SceneTracks,
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import {
|
||||||
} from "@/timeline/snapping";
|
} from "@/timeline/snapping";
|
||||||
import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
|
import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
|
||||||
import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source";
|
import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source";
|
||||||
import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
|
import { getAnimationKeyframeSnapPointsForTimeline } from "@/timeline/animation-snap-points";
|
||||||
import type { MoveGroup } from "./types";
|
import type { MoveGroup } from "./types";
|
||||||
|
|
||||||
export function snapGroupEdges({
|
export function snapGroupEdges({
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue