feat: Clip effects, asset sorting, and timeline improvements

Major features and improvements:

* **Clip Effects**:
  * Added UI in Properties Panel to manage effects on video/image clips (add, remove, toggle, reorder).
  * Implemented dynamic parameter fields for effects.
  * Added support for keyframing effect parameters.

* **Assets Panel**:
  * Added sorting options: Name, Type, Duration, and File Size.
  * Persisted view preferences (grid/list mode, sort order) to local storage.
  * Refactored media item rendering and drag interactions.

* **Timeline & Interaction**:
  * **Keyframe Dragging**: Added ability to drag keyframes directly on the timeline element.
  * **Resizing**: Improved resize logic to respect neighboring clips (prevents overlaps).
  * **Visuals**: Implemented tiled background rendering for video/image clips on the timeline.
  * **Shortcuts**: Added "Deselect All" action bound to the `Escape` key.
  * **Fixes**: Corrected drag-and-drop coordinate calculations when the timeline track area is scrolled.

* **Text Elements**:
  * Refactored text background storage to use an explicit `enabled` flag.
  * Added `V8toV9` storage migration to update existing projects.

* **Architecture**:
  * Moved export state management to `ProjectManager` for better lifecycle handling.
  * Refactored `PropertiesPanel` sections to be more composable (custom headers, borders).
This commit is contained in:
Maze Winther
2026-03-02 13:13:07 +01:00
parent 93bea01c9e
commit e7dcb586c0
66 changed files with 3688 additions and 1333 deletions
@@ -0,0 +1,117 @@
import type { Effect, EffectParamValues } from "@/types/effects";
import type {
ElementAnimations,
NumberAnimationChannel,
} from "@/types/animation";
import {
getChannel,
removeKeyframe,
setChannel,
upsertKeyframe,
} from "./keyframes";
import { getChannelValueAtTime } from "./interpolation";
const EFFECT_PARAM_PATH_PREFIX = "effects.";
const EFFECT_PARAM_PATH_SUFFIX = ".params.";
function buildEffectParamPath({
effectId,
paramKey,
}: {
effectId: string;
paramKey: string;
}): string {
return `${EFFECT_PARAM_PATH_PREFIX}${effectId}${EFFECT_PARAM_PATH_SUFFIX}${paramKey}`;
}
export function resolveEffectParamsAtTime({
effect,
animations,
localTime,
}: {
effect: Effect;
animations: ElementAnimations | undefined;
localTime: number;
}): EffectParamValues {
const resolved: EffectParamValues = {};
for (const [paramKey, staticValue] of Object.entries(effect.params)) {
const path = buildEffectParamPath({ effectId: effect.id, paramKey });
const channel = getChannel({ animations, propertyPath: path });
if (channel && channel.keyframes.length > 0) {
resolved[paramKey] = getChannelValueAtTime({
channel,
time: localTime,
fallbackValue: staticValue,
}) as number | string | boolean;
} else {
resolved[paramKey] = staticValue;
}
}
return resolved;
}
const EMPTY_NUMBER_CHANNEL: NumberAnimationChannel = {
valueKind: "number",
keyframes: [],
};
export function upsertEffectParamKeyframe({
animations,
effectId,
paramKey,
time,
value,
interpolation,
keyframeId,
}: {
animations: ElementAnimations | undefined;
effectId: string;
paramKey: string;
time: number;
value: number;
interpolation?: "linear" | "hold";
keyframeId?: string;
}): ElementAnimations | undefined {
const path = buildEffectParamPath({ effectId, paramKey });
const channel = getChannel({ animations, propertyPath: path });
const targetChannel =
channel && channel.valueKind === "number" ? channel : EMPTY_NUMBER_CHANNEL;
const updatedChannel = upsertKeyframe({
channel: targetChannel,
time,
value,
interpolation: interpolation ?? "linear",
keyframeId,
});
return (
setChannel({
animations,
propertyPath: path,
channel: updatedChannel,
}) ?? { channels: {} }
);
}
export function removeEffectParamKeyframe({
animations,
effectId,
paramKey,
keyframeId,
}: {
animations: ElementAnimations | undefined;
effectId: string;
paramKey: string;
keyframeId: string;
}): ElementAnimations | undefined {
const path = buildEffectParamPath({ effectId, paramKey });
const channel = getChannel({ animations, propertyPath: path });
const updatedChannel = removeKeyframe({ channel, keyframeId });
return setChannel({
animations,
propertyPath: path,
channel: updatedChannel,
});
}