refactor: split animation helpers by domain

This commit is contained in:
Maze Winther 2026-04-26 04:04:30 +02:00
parent 6a22a3ecbd
commit 0b7597b31f
43 changed files with 1628 additions and 1322 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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