refactor: new parameters modulerefactor: unify element params

This commit is contained in:
Maze Winther
2026-04-29 01:28:50 +02:00
parent d7692b4c79
commit 222ef055fc
53 changed files with 2021 additions and 1362 deletions
@@ -0,0 +1,154 @@
"use client";
import { resolveAnimationPathValueAtTime } from "@/animation";
import { Section, SectionContent, SectionFields } from "@/components/section";
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
import { useKeyframedParamProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-param-property";
import { PropertyParamField } from "@/components/editor/panels/properties/components/property-param-field";
import type { ParamValue, ParamValues } from "@/params";
import {
getElementParams,
readElementParamValue,
writeElementParamValue,
type ElementParamDefinition,
} from "@/params/registry";
import type { TimelineElement } from "@/timeline";
import type { MediaTime } from "@/wasm";
export function ElementParamsTab({
element,
trackId,
paramKeys,
sectionKey,
}: {
element: TimelineElement;
trackId: string;
paramKeys?: readonly string[];
sectionKey: string;
}) {
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
startTime: element.startTime,
duration: element.duration,
});
const params = getElementParams({ element }).filter(
(param) => !paramKeys || paramKeys.includes(param.key),
);
const baseValues = buildValues({ element, params });
return (
<Section sectionKey={`${element.id}:${sectionKey}`}>
<SectionContent className="pt-4">
<SectionFields>
{params
.filter((param) => isVisible({ param, values: baseValues }))
.map((param) => (
<ElementParamField
key={param.key}
element={element}
trackId={trackId}
param={param}
baseValue={baseValues[param.key] ?? param.default}
localTime={localTime}
isPlayheadWithinElementRange={isPlayheadWithinElementRange}
/>
))}
</SectionFields>
</SectionContent>
</Section>
);
}
function ElementParamField({
element,
trackId,
param,
baseValue,
localTime,
isPlayheadWithinElementRange,
}: {
element: TimelineElement;
trackId: string;
param: ElementParamDefinition;
baseValue: ParamValue;
localTime: MediaTime;
isPlayheadWithinElementRange: boolean;
}) {
const resolvedValue = resolveAnimationPathValueAtTime({
animations: element.animations,
propertyPath: param.key,
localTime,
fallbackValue: baseValue,
});
const animatedParam = useKeyframedParamProperty({
param,
trackId,
elementId: element.id,
animations: element.animations,
propertyPath: param.key,
localTime,
isPlayheadWithinElementRange,
resolvedValue,
buildBaseUpdates: ({ value }) =>
writeElementParamValue({ element, param, value }),
});
return (
<PropertyParamField
param={param}
value={resolvedValue}
onPreview={animatedParam.onPreview}
onCommit={animatedParam.onCommit}
keyframe={
param.keyframable === false
? undefined
: {
isActive: animatedParam.isKeyframedAtTime,
isDisabled: !isPlayheadWithinElementRange,
onToggle: animatedParam.toggleKeyframe,
}
}
/>
);
}
function buildValues({
element,
params,
}: {
element: TimelineElement;
params: readonly ElementParamDefinition[];
}): ParamValues {
const values: ParamValues = {};
for (const param of params) {
const value = readElementParamValue({ element, param });
if (value !== null) {
values[param.key] = value;
}
}
return values;
}
function isVisible({
param,
values,
}: {
param: ElementParamDefinition;
values: ParamValues;
}): boolean {
return (param.dependencies ?? []).every((dependency) =>
areParamValuesEqual({
left: values[dependency.param],
right: dependency.equals,
}),
);
}
function areParamValuesEqual({
left,
right,
}: {
left: ParamValue | undefined;
right: ParamValue;
}): boolean {
return left === right;
}
@@ -1,6 +1,10 @@
"use client";
import type { ParamDefinition, NumberParamDefinition } from "@/params";
import type {
ParamDefinition,
NumberParamDefinition,
ParamValue,
} from "@/params";
import {
formatNumberForDisplay,
getFractionDigitsForStep,
@@ -19,6 +23,7 @@ import {
} from "@/components/ui/select";
import { usePropertyDraft } from "../hooks/use-property-draft";
import { KeyframeToggle } from "./keyframe-toggle";
import { Textarea } from "@/components/ui/textarea";
export function PropertyParamField({
param,
@@ -28,8 +33,8 @@ export function PropertyParamField({
keyframe,
}: {
param: ParamDefinition;
value: number | string | boolean;
onPreview: (value: number | string | boolean) => void;
value: ParamValue;
onPreview: (value: ParamValue) => void;
onCommit: () => void;
keyframe?: {
isActive: boolean;
@@ -41,7 +46,7 @@ export function PropertyParamField({
<SectionField
label={param.label}
beforeLabel={
keyframe ? (
keyframe && param.keyframable !== false ? (
<KeyframeToggle
isActive={keyframe.isActive}
isDisabled={keyframe.isDisabled}
@@ -68,8 +73,8 @@ function ParamInput({
onCommit,
}: {
param: ParamDefinition;
value: number | string | boolean;
onPreview: (value: number | string | boolean) => void;
value: ParamValue;
onPreview: (value: ParamValue) => void;
onCommit: () => void;
}) {
if (param.type === "number") {
@@ -131,6 +136,27 @@ function ParamInput({
);
}
if (param.type === "text") {
return (
<Textarea
value={String(value)}
onChange={(event) => onPreview(event.currentTarget.value)}
onBlur={onCommit}
/>
);
}
if (param.type === "font") {
return (
<input
className="border-input bg-accent h-9 w-full rounded-md border px-3 text-sm outline-none"
value={String(value)}
onChange={(event) => onPreview(event.currentTarget.value)}
onBlur={onCommit}
/>
);
}
return null;
}
@@ -1,96 +0,0 @@
import { useEditor } from "@/editor/use-editor";
import {
getKeyframeAtTime,
hasKeyframesForPath,
upsertElementKeyframe,
} from "@/animation";
import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
import type { TimelineElement } from "@/timeline";
import type { MediaTime } from "@/wasm";
export function useKeyframedColorProperty({
trackId,
elementId,
animations,
propertyPath,
localTime,
isPlayheadWithinElementRange,
resolvedColor,
buildBaseUpdates,
}: {
trackId: string;
elementId: string;
animations: ElementAnimations | undefined;
propertyPath: AnimationPropertyPath;
localTime: MediaTime;
isPlayheadWithinElementRange: boolean;
resolvedColor: string;
buildBaseUpdates: ({ value }: { value: string }) => Partial<TimelineElement>;
}) {
const editor = useEditor();
const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
const keyframeAtTime = isPlayheadWithinElementRange
? getKeyframeAtTime({ animations, propertyPath, time: localTime })
: null;
const keyframeIdAtTime = keyframeAtTime?.id ?? null;
const isKeyframedAtTime = keyframeAtTime !== null;
const shouldUseAnimatedChannel =
hasAnimatedKeyframes && isPlayheadWithinElementRange;
const onChange = ({ color }: { color: string }) => {
if (shouldUseAnimatedChannel) {
editor.timeline.previewElements({
updates: [
{
trackId,
elementId,
updates: {
animations: upsertElementKeyframe({
animations,
propertyPath,
time: localTime,
value: color,
}),
},
},
],
});
return;
}
editor.timeline.previewElements({
updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: color }) }],
});
};
const onChangeEnd = () => editor.timeline.commitPreview();
const toggleKeyframe = () => {
if (!isPlayheadWithinElementRange) {
return;
}
if (keyframeIdAtTime) {
editor.timeline.removeKeyframes({
keyframes: [{ trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }],
});
return;
}
editor.timeline.upsertKeyframes({
keyframes: [
{ trackId, elementId, propertyPath, time: localTime, value: resolvedColor },
],
});
};
return {
isKeyframedAtTime,
hasAnimatedKeyframes,
keyframeIdAtTime,
onChange,
onChangeEnd,
toggleKeyframe,
};
}
@@ -1,175 +0,0 @@
import { useEditor } from "@/editor/use-editor";
import {
getKeyframeAtTime,
hasKeyframesForPath,
upsertElementKeyframe,
} from "@/animation";
import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
import type { TimelineElement } from "@/timeline";
import { snapToStep } from "@/utils/math";
import { usePropertyDraft } from "./use-property-draft";
import type { MediaTime } from "@/wasm";
export function useKeyframedNumberProperty({
trackId,
elementId,
animations,
propertyPath,
localTime,
isPlayheadWithinElementRange,
displayValue,
parse,
valueAtPlayhead,
step,
buildBaseUpdates,
buildAdditionalKeyframes,
}: {
trackId: string;
elementId: string;
animations: ElementAnimations | undefined;
propertyPath: AnimationPropertyPath;
localTime: MediaTime;
isPlayheadWithinElementRange: boolean;
displayValue: string;
parse: (input: string) => number | null;
valueAtPlayhead: number;
step?: number;
buildBaseUpdates: ({ value }: { value: number }) => Partial<TimelineElement>;
buildAdditionalKeyframes?: ({
value,
}: { value: number }) => Array<{ propertyPath: AnimationPropertyPath; value: number }>;
}) {
const editor = useEditor();
const snapValue = (value: number) =>
step != null ? snapToStep({ value, step }) : value;
const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
const keyframeAtTime = isPlayheadWithinElementRange
? getKeyframeAtTime({ animations, propertyPath, time: localTime })
: null;
const keyframeIdAtTime = keyframeAtTime?.id ?? null;
const isKeyframedAtTime = keyframeAtTime !== null;
const shouldUseAnimatedChannel =
hasAnimatedKeyframes && isPlayheadWithinElementRange;
const previewValue = ({ value }: { value: number }) => {
const nextValue = snapValue(value);
if (shouldUseAnimatedChannel) {
const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? [];
const updatedAnimations = [
{ propertyPath, value: nextValue },
...additionalKeyframes,
].reduce(
(currentAnimations, keyframe) =>
upsertElementKeyframe({
animations: currentAnimations,
propertyPath: keyframe.propertyPath,
time: localTime,
value: keyframe.value,
}),
animations,
);
editor.timeline.previewElements({
updates: [
{
trackId,
elementId,
updates: { animations: updatedAnimations },
},
],
});
return;
}
editor.timeline.previewElements({
updates: [
{
trackId,
elementId,
updates: buildBaseUpdates({ value: nextValue }),
},
],
});
};
const propertyDraft = usePropertyDraft({
displayValue,
parse: (input) => {
const parsedValue = parse(input);
return parsedValue === null ? null : snapValue(parsedValue);
},
onPreview: (value) => previewValue({ value }),
onCommit: () => editor.timeline.commitPreview(),
});
const toggleKeyframe = () => {
if (!isPlayheadWithinElementRange) {
return;
}
if (keyframeIdAtTime) {
editor.timeline.removeKeyframes({
keyframes: [
{
trackId,
elementId,
propertyPath,
keyframeId: keyframeIdAtTime,
},
],
});
return;
}
editor.timeline.upsertKeyframes({
keyframes: [
{
trackId,
elementId,
propertyPath,
time: localTime,
value: snapValue(valueAtPlayhead),
},
],
});
};
const commitValue = ({ value }: { value: number }) => {
const nextValue = snapValue(value);
if (shouldUseAnimatedChannel) {
const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? [];
editor.timeline.upsertKeyframes({
keyframes: [
{ trackId, elementId, propertyPath, time: localTime, value: nextValue },
...additionalKeyframes.map((keyframe) => ({
trackId,
elementId,
propertyPath: keyframe.propertyPath,
time: localTime,
value: keyframe.value,
})),
],
});
return;
}
editor.timeline.updateElements({
updates: [
{
trackId,
elementId,
patch: buildBaseUpdates({ value: nextValue }),
},
],
});
};
return {
...propertyDraft,
hasAnimatedKeyframes,
isKeyframedAtTime,
keyframeIdAtTime,
toggleKeyframe,
commitValue,
};
}
@@ -8,14 +8,15 @@ import {
upsertPathKeyframe,
} from "@/animation";
import type {
AnimationPath,
ElementAnimations,
} from "@/animation/types";
import {
coerceAnimationParamValue,
getAnimationParamDefaultInterpolation,
getAnimationParamValueKind,
} from "@/animation/animated-params";
import type { ParamDefinition } from "@/params";
coerceParamValue,
getParamDefaultInterpolation,
getParamValueKind,
type ParamDefinition,
} from "@/params";
import type { TimelineElement } from "@/timeline";
import type { MediaTime } from "@/wasm";
@@ -33,6 +34,7 @@ export function useKeyframedParamProperty({
trackId,
elementId,
animations,
propertyPath,
localTime,
isPlayheadWithinElementRange,
resolvedValue,
@@ -42,6 +44,7 @@ export function useKeyframedParamProperty({
trackId: string;
elementId: string;
animations: ElementAnimations | undefined;
propertyPath?: AnimationPath;
localTime: MediaTime;
isPlayheadWithinElementRange: boolean;
resolvedValue: number | string | boolean;
@@ -52,15 +55,16 @@ export function useKeyframedParamProperty({
}) => Partial<TimelineElement>;
}): KeyframedParamPropertyResult {
const editor = useEditor();
const propertyPath = buildGraphicParamPath({ paramKey: param.key });
const resolvedPropertyPath =
propertyPath ?? buildGraphicParamPath({ paramKey: param.key });
const hasAnimatedKeyframes = hasKeyframesForPath({
animations,
propertyPath,
propertyPath: resolvedPropertyPath,
});
const keyframeAtTime = isPlayheadWithinElementRange
? getKeyframeAtTime({
animations,
propertyPath,
propertyPath: resolvedPropertyPath,
time: localTime,
})
: null;
@@ -79,15 +83,15 @@ export function useKeyframedParamProperty({
updates: {
animations: upsertPathKeyframe({
animations,
propertyPath,
propertyPath: resolvedPropertyPath,
time: localTime,
value,
kind: getAnimationParamValueKind({ param }),
defaultInterpolation: getAnimationParamDefaultInterpolation({
kind: getParamValueKind({ param }),
defaultInterpolation: getParamDefaultInterpolation({
param,
}),
coerceValue: ({ value: nextValue }) =>
coerceAnimationParamValue({
coerceParamValue({
param,
value: nextValue,
}),
@@ -121,7 +125,7 @@ export function useKeyframedParamProperty({
{
trackId,
elementId,
propertyPath,
propertyPath: resolvedPropertyPath,
keyframeId: keyframeIdAtTime,
},
],
@@ -134,7 +138,7 @@ export function useKeyframedParamProperty({
{
trackId,
elementId,
propertyPath,
propertyPath: resolvedPropertyPath,
time: localTime,
value: resolvedValue,
},
@@ -22,16 +22,43 @@ import {
MagicWand05Icon,
DashboardSpeed02Icon,
} from "@hugeicons/core-free-icons";
import { TransformTab } from "@/rendering/components/transform-tab";
import { BlendingTab } from "@/rendering/components/blending-tab";
import { AudioTab } from "@/timeline/components/audio-tab";
import { TextTab } from "@/text/components/text-tab";
import { ElementParamsTab } from "./components/element-params-tab";
import { ClipEffectsTab, StandaloneEffectTab } from "@/effects/components/effects-tab";
import { MasksTab } from "@/masks/components/masks-tab";
import { SpeedTab } from "@/speed/components/speed-tab";
import { GraphicTab } from "@/graphics/components/graphic-tab";
import { OcShapesIcon } from "@/components/icons";
const TRANSFORM_PARAM_KEYS = [
"transform.positionX",
"transform.positionY",
"transform.scaleX",
"transform.scaleY",
"transform.rotate",
] as const;
const BLENDING_PARAM_KEYS = ["opacity", "blendMode"] as const;
const AUDIO_PARAM_KEYS = ["volume", "muted"] as const;
const TEXT_PARAM_KEYS = [
"content",
"fontFamily",
"fontSize",
"color",
"textAlign",
"fontWeight",
"fontStyle",
"textDecoration",
"letterSpacing",
"lineHeight",
"background.enabled",
"background.color",
"background.cornerRadius",
"background.paddingX",
"background.paddingY",
"background.offsetX",
"background.offsetY",
] as const;
export type TabContentProps = {
trackId: string;
};
@@ -58,7 +85,12 @@ function buildTransformTab({
label: "Transform",
icon: <HugeiconsIcon icon={ArrowExpandIcon} size={16} />,
content: ({ trackId }) => (
<TransformTab element={element} trackId={trackId} />
<ElementParamsTab
element={element}
trackId={trackId}
paramKeys={TRANSFORM_PARAM_KEYS}
sectionKey="transform"
/>
),
};
}
@@ -73,7 +105,12 @@ function buildBlendingTab({
label: "Blending",
icon: <HugeiconsIcon icon={RainDropIcon} size={16} />,
content: ({ trackId }) => (
<BlendingTab element={element} trackId={trackId} />
<ElementParamsTab
element={element}
trackId={trackId}
paramKeys={BLENDING_PARAM_KEYS}
sectionKey="blending"
/>
),
};
}
@@ -87,7 +124,14 @@ function buildAudioTab({
id: "audio",
label: "Audio",
icon: <HugeiconsIcon icon={MusicNote03Icon} size={16} />,
content: ({ trackId }) => <AudioTab element={element} trackId={trackId} />,
content: ({ trackId }) => (
<ElementParamsTab
element={element}
trackId={trackId}
paramKeys={AUDIO_PARAM_KEYS}
sectionKey="audio"
/>
),
};
}
@@ -137,7 +181,14 @@ function buildTextTab({ element }: { element: TextElement }): PropertiesTabDef {
id: "text",
label: "Text",
icon: <HugeiconsIcon icon={TextFontIcon} size={16} />,
content: ({ trackId }) => <TextTab element={element} trackId={trackId} />,
content: ({ trackId }) => (
<ElementParamsTab
element={element}
trackId={trackId}
paramKeys={TEXT_PARAM_KEYS}
sectionKey="text"
/>
),
};
}