summaryrefslogtreecommitdiff
path: root/apps/web/src/timeline
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/timeline')
-rw-r--r--apps/web/src/timeline/animation-properties.ts425
-rw-r--r--apps/web/src/timeline/animation-snap-points.ts35
-rw-r--r--apps/web/src/timeline/animation-targets.ts214
-rw-r--r--apps/web/src/timeline/audio-state.ts2
-rw-r--r--apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts2
-rw-r--r--apps/web/src/timeline/components/audio-tab.tsx302
-rw-r--r--apps/web/src/timeline/controllers/playhead-controller.ts2
-rw-r--r--apps/web/src/timeline/controllers/resize-controller.ts2
-rw-r--r--apps/web/src/timeline/group-move/snap.ts2
9 files changed, 830 insertions, 156 deletions
diff --git a/apps/web/src/timeline/animation-properties.ts b/apps/web/src/timeline/animation-properties.ts
new file mode 100644
index 00000000..31479a58
--- /dev/null
+++ b/apps/web/src/timeline/animation-properties.ts
@@ -0,0 +1,425 @@
+import type {
+ AnimationBindingKind,
+ AnimationInterpolation,
+ AnimationPropertyPath,
+ AnimationValue,
+ ElementAnimations,
+ NumericSpec,
+} from "@/animation/types";
+import { upsertPathKeyframe } from "@/animation";
+import { parseColorToLinearRgba } from "@/animation/binding-values";
+import { isAnimationPropertyPath } from "@/animation/path";
+import { MIN_TRANSFORM_SCALE } from "@/animation/transform";
+import { snapToStep } from "@/utils/math";
+import type { TimelineElement } from "@/timeline";
+import {
+ CORNER_RADIUS_MAX,
+ CORNER_RADIUS_MIN,
+} from "@/text/background";
+import {
+ canElementHaveAudio,
+ isVisualElement,
+} from "@/timeline/element-utils";
+import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
+import { DEFAULTS } from "@/timeline/defaults";
+
+export interface AnimationPropertyDefinition {
+ kind: AnimationBindingKind;
+ defaultInterpolation: AnimationInterpolation;
+ numericRanges?: Partial<Record<string, NumericSpec>>;
+ supportsElement: ({ element }: { element: TimelineElement }) => boolean;
+ getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null;
+ coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null;
+ setValue: ({
+ element,
+ value,
+ }: {
+ element: TimelineElement;
+ value: AnimationValue;
+ }) => TimelineElement;
+}
+
+function applyNumericSpec({
+ value,
+ numericRange,
+}: {
+ value: number;
+ numericRange: NumericSpec | undefined;
+}): number {
+ if (!numericRange) {
+ return value;
+ }
+
+ const steppedValue =
+ numericRange.step != null
+ ? snapToStep({ value, step: numericRange.step })
+ : value;
+ const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY;
+ const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY;
+ return Math.min(maxValue, Math.max(minValue, steppedValue));
+}
+
+function coerceNumberValue({
+ value,
+ numericRange,
+}: {
+ value: AnimationValue;
+ numericRange?: NumericSpec;
+}): number | null {
+ if (typeof value !== "number" || Number.isNaN(value)) {
+ return null;
+ }
+
+ return applyNumericSpec({ value, numericRange });
+}
+
+function coerceColorValue({
+ value,
+}: {
+ value: AnimationValue;
+}): string | null {
+ return typeof value === "string" && parseColorToLinearRgba({ color: value })
+ ? value
+ : null;
+}
+
+function createNumberPropertyDefinition({
+ numericRange,
+ supportsElement,
+ getValue,
+ setValue,
+}: {
+ numericRange?: NumericSpec;
+ supportsElement: AnimationPropertyDefinition["supportsElement"];
+ getValue: AnimationPropertyDefinition["getValue"];
+ setValue: AnimationPropertyDefinition["setValue"];
+}): AnimationPropertyDefinition {
+ return {
+ kind: "number",
+ defaultInterpolation: "linear",
+ numericRanges: numericRange ? { value: numericRange } : undefined,
+ supportsElement,
+ getValue,
+ coerceValue: ({ value }) =>
+ coerceNumberValue({
+ value,
+ numericRange,
+ }),
+ setValue,
+ };
+}
+
+const ANIMATION_PROPERTY_REGISTRY: Record<
+ AnimationPropertyPath,
+ AnimationPropertyDefinition
+> = {
+ "transform.positionX": createNumberPropertyDefinition({
+ numericRange: { step: 1 },
+ supportsElement: ({ element }) => isVisualElement(element),
+ getValue: ({ element }) =>
+ isVisualElement(element) ? element.transform.position.x : null,
+ setValue: ({ element, value }) =>
+ isVisualElement(element)
+ ? {
+ ...element,
+ transform: {
+ ...element.transform,
+ position: { ...element.transform.position, x: value as number },
+ },
+ }
+ : element,
+ }),
+ "transform.positionY": createNumberPropertyDefinition({
+ numericRange: { step: 1 },
+ supportsElement: ({ element }) => isVisualElement(element),
+ getValue: ({ element }) =>
+ isVisualElement(element) ? element.transform.position.y : null,
+ setValue: ({ element, value }) =>
+ isVisualElement(element)
+ ? {
+ ...element,
+ transform: {
+ ...element.transform,
+ position: { ...element.transform.position, y: value as number },
+ },
+ }
+ : element,
+ }),
+ "transform.scaleX": createNumberPropertyDefinition({
+ numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
+ supportsElement: ({ element }) => isVisualElement(element),
+ getValue: ({ element }) =>
+ isVisualElement(element) ? element.transform.scaleX : null,
+ setValue: ({ element, value }) =>
+ isVisualElement(element)
+ ? {
+ ...element,
+ transform: { ...element.transform, scaleX: value as number },
+ }
+ : element,
+ }),
+ "transform.scaleY": createNumberPropertyDefinition({
+ numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
+ supportsElement: ({ element }) => isVisualElement(element),
+ getValue: ({ element }) =>
+ isVisualElement(element) ? element.transform.scaleY : null,
+ setValue: ({ element, value }) =>
+ isVisualElement(element)
+ ? {
+ ...element,
+ transform: { ...element.transform, scaleY: value as number },
+ }
+ : element,
+ }),
+ "transform.rotate": createNumberPropertyDefinition({
+ numericRange: { min: -360, max: 360, step: 1 },
+ supportsElement: ({ element }) => isVisualElement(element),
+ getValue: ({ element }) =>
+ isVisualElement(element) ? element.transform.rotate : null,
+ setValue: ({ element, value }) =>
+ isVisualElement(element)
+ ? {
+ ...element,
+ transform: { ...element.transform, rotate: value as number },
+ }
+ : element,
+ }),
+ opacity: createNumberPropertyDefinition({
+ numericRange: { min: 0, max: 1, step: 0.01 },
+ supportsElement: ({ element }) => isVisualElement(element),
+ getValue: ({ element }) =>
+ isVisualElement(element) ? element.opacity : null,
+ setValue: ({ element, value }) =>
+ isVisualElement(element)
+ ? { ...element, opacity: value as number }
+ : element,
+ }),
+ volume: createNumberPropertyDefinition({
+ numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 },
+ supportsElement: ({ element }) => canElementHaveAudio(element),
+ getValue: ({ element }) =>
+ canElementHaveAudio(element) ? element.volume ?? 0 : null,
+ setValue: ({ element, value }) =>
+ canElementHaveAudio(element)
+ ? { ...element, volume: value as number }
+ : element,
+ }),
+ color: {
+ kind: "color",
+ defaultInterpolation: "linear",
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) => (element.type === "text" ? element.color : null),
+ coerceValue: ({ value }) => coerceColorValue({ value }),
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? { ...element, color: value as string }
+ : element,
+ },
+ "background.color": {
+ kind: "color",
+ defaultInterpolation: "linear",
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) =>
+ element.type === "text" ? element.background.color : null,
+ coerceValue: ({ value }) => coerceColorValue({ value }),
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? {
+ ...element,
+ background: { ...element.background, color: value as string },
+ }
+ : element,
+ },
+ "background.paddingX": createNumberPropertyDefinition({
+ numericRange: { min: 0, step: 1 },
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) =>
+ element.type === "text"
+ ? (element.background.paddingX ?? DEFAULTS.text.background.paddingX)
+ : null,
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? {
+ ...element,
+ background: { ...element.background, paddingX: value as number },
+ }
+ : element,
+ }),
+ "background.paddingY": createNumberPropertyDefinition({
+ numericRange: { min: 0, step: 1 },
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) =>
+ element.type === "text"
+ ? (element.background.paddingY ?? DEFAULTS.text.background.paddingY)
+ : null,
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? {
+ ...element,
+ background: { ...element.background, paddingY: value as number },
+ }
+ : element,
+ }),
+ "background.offsetX": createNumberPropertyDefinition({
+ numericRange: { step: 1 },
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) =>
+ element.type === "text"
+ ? (element.background.offsetX ?? DEFAULTS.text.background.offsetX)
+ : null,
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? {
+ ...element,
+ background: { ...element.background, offsetX: value as number },
+ }
+ : element,
+ }),
+ "background.offsetY": createNumberPropertyDefinition({
+ numericRange: { step: 1 },
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) =>
+ element.type === "text"
+ ? (element.background.offsetY ?? DEFAULTS.text.background.offsetY)
+ : null,
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? {
+ ...element,
+ background: { ...element.background, offsetY: value as number },
+ }
+ : element,
+ }),
+ "background.cornerRadius": createNumberPropertyDefinition({
+ numericRange: {
+ min: CORNER_RADIUS_MIN,
+ max: CORNER_RADIUS_MAX,
+ step: 1,
+ },
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) =>
+ element.type === "text"
+ ? (element.background.cornerRadius ?? CORNER_RADIUS_MIN)
+ : null,
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? {
+ ...element,
+ background: { ...element.background, cornerRadius: value as number },
+ }
+ : element,
+ }),
+};
+
+export function getAnimationPropertyDefinition({
+ propertyPath,
+}: {
+ propertyPath: AnimationPropertyPath;
+}): AnimationPropertyDefinition {
+ return ANIMATION_PROPERTY_REGISTRY[propertyPath];
+}
+
+export function supportsAnimationProperty({
+ element,
+ propertyPath,
+}: {
+ element: TimelineElement;
+ propertyPath: AnimationPropertyPath;
+}): boolean {
+ const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
+ return propertyDefinition.supportsElement({ element });
+}
+
+export function getElementBaseValueForProperty({
+ element,
+ propertyPath,
+}: {
+ element: TimelineElement;
+ propertyPath: AnimationPropertyPath;
+}): AnimationValue | null {
+ const definition = getAnimationPropertyDefinition({ propertyPath });
+ if (!definition.supportsElement({ element })) {
+ return null;
+ }
+
+ return definition.getValue({ element });
+}
+
+export function withElementBaseValueForProperty({
+ element,
+ propertyPath,
+ value,
+}: {
+ element: TimelineElement;
+ propertyPath: AnimationPropertyPath;
+ value: AnimationValue;
+}): TimelineElement {
+ const definition = getAnimationPropertyDefinition({ propertyPath });
+ const coercedValue = definition.coerceValue({ value });
+ if (coercedValue === null || !definition.supportsElement({ element })) {
+ return element;
+ }
+
+ return definition.setValue({ element, value: coercedValue });
+}
+
+export function getDefaultInterpolationForProperty({
+ propertyPath,
+}: {
+ propertyPath: AnimationPropertyPath;
+}): AnimationInterpolation {
+ const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
+ return propertyDefinition.defaultInterpolation;
+}
+
+export function coerceAnimationValueForProperty({
+ propertyPath,
+ value,
+}: {
+ propertyPath: AnimationPropertyPath;
+ value: AnimationValue;
+}): AnimationValue | null {
+ const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
+ 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,
+ }),
+ });
+}
diff --git a/apps/web/src/timeline/animation-snap-points.ts b/apps/web/src/timeline/animation-snap-points.ts
new file mode 100644
index 00000000..f7bf549a
--- /dev/null
+++ b/apps/web/src/timeline/animation-snap-points.ts
@@ -0,0 +1,35 @@
+import { getElementKeyframes } from "@/animation";
+import type { SceneTracks } from "@/timeline";
+import type { SnapPoint } from "@/timeline/snapping";
+
+export function getAnimationKeyframeSnapPointsForTimeline({
+ tracks,
+ excludeElementIds,
+}: {
+ tracks: SceneTracks;
+ excludeElementIds?: Set<string>;
+}): SnapPoint[] {
+ const snapPoints: SnapPoint[] = [];
+ const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
+
+ for (const track of orderedTracks) {
+ for (const element of track.elements) {
+ if (excludeElementIds?.has(element.id)) {
+ continue;
+ }
+
+ for (const keyframe of getElementKeyframes({
+ animations: element.animations,
+ })) {
+ snapPoints.push({
+ time: element.startTime + keyframe.time,
+ type: "keyframe",
+ elementId: element.id,
+ trackId: track.id,
+ });
+ }
+ }
+ }
+
+ return snapPoints;
+}
diff --git a/apps/web/src/timeline/animation-targets.ts b/apps/web/src/timeline/animation-targets.ts
new file mode 100644
index 00000000..f9d53fcc
--- /dev/null
+++ b/apps/web/src/timeline/animation-targets.ts
@@ -0,0 +1,214 @@
+import type {
+ AnimationBindingKind,
+ AnimationInterpolation,
+ AnimationPath,
+ AnimationValue,
+ NumericSpec,
+} from "@/animation/types";
+import {
+ coerceAnimationParamValue,
+ getAnimationParamDefaultInterpolation,
+ getAnimationParamNumericRange,
+ getAnimationParamValueKind,
+} from "@/animation/animated-params";
+import {
+ parseEffectParamPath,
+} from "@/animation/effect-param-channel";
+import {
+ parseGraphicParamPath,
+} from "@/animation/graphic-param-channel";
+import { effectsRegistry, registerDefaultEffects } from "@/effects";
+import { getGraphicDefinition } from "@/graphics";
+import type { ParamDefinition } from "@/params";
+import type { TimelineElement } from "@/timeline";
+import { isVisualElement } from "@/timeline/element-utils";
+import { isAnimationPropertyPath } from "@/animation/path";
+import {
+ coerceAnimationValueForProperty,
+ getAnimationPropertyDefinition,
+ getElementBaseValueForProperty,
+ withElementBaseValueForProperty,
+} from "./animation-properties";
+
+export interface AnimationPathDescriptor {
+ kind: AnimationBindingKind;
+ defaultInterpolation: AnimationInterpolation;
+ numericRanges?: Partial<Record<string, NumericSpec>>;
+ coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null;
+ getBaseValue: () => AnimationValue | null;
+ setBaseValue: ({ value }: { value: AnimationValue }) => TimelineElement;
+}
+
+// Number/discrete bindings expose a single component named "value"
+// (see binding-values.ts). Multi-component kinds (vector2, color) don't carry
+// numeric ranges yet — revisit when one does.
+function paramNumericRanges({
+ param,
+}: {
+ param: ParamDefinition;
+}): Partial<Record<string, NumericSpec>> | undefined {
+ const range = getAnimationParamNumericRange({ param });
+ return range ? { value: range } : undefined;
+}
+
+function buildGraphicParamDescriptor({
+ element,
+ paramKey,
+}: {
+ element: TimelineElement;
+ paramKey: string;
+}): AnimationPathDescriptor | null {
+ if (element.type !== "graphic") {
+ return null;
+ }
+
+ const definition = getGraphicDefinition({
+ definitionId: element.definitionId,
+ });
+ const param = definition.params.find((candidate) => candidate.key === paramKey);
+ if (!param) {
+ return null;
+ }
+
+ return {
+ kind: getAnimationParamValueKind({ param }),
+ defaultInterpolation: getAnimationParamDefaultInterpolation({ param }),
+ numericRanges: paramNumericRanges({ param }),
+ coerceValue: ({ value }) => coerceAnimationParamValue({ param, value }),
+ getBaseValue: () => element.params[param.key] ?? param.default,
+ setBaseValue: ({ value }) => {
+ const coercedValue = coerceAnimationParamValue({ param, value });
+ if (coercedValue === null) {
+ return element;
+ }
+
+ return {
+ ...element,
+ params: {
+ ...element.params,
+ [param.key]: coercedValue,
+ },
+ };
+ },
+ };
+}
+
+function buildEffectParamDescriptor({
+ element,
+ effectId,
+ paramKey,
+}: {
+ element: TimelineElement;
+ effectId: string;
+ paramKey: string;
+}): AnimationPathDescriptor | null {
+ if (!isVisualElement(element)) {
+ return null;
+ }
+
+ const effect = element.effects?.find((candidate) => candidate.id === effectId);
+ if (!effect) {
+ return null;
+ }
+
+ registerDefaultEffects();
+ const definition = effectsRegistry.get(effect.type);
+ const param = definition.params.find((candidate) => candidate.key === paramKey);
+ if (!param) {
+ return null;
+ }
+
+ return {
+ kind: getAnimationParamValueKind({ param }),
+ defaultInterpolation: getAnimationParamDefaultInterpolation({ param }),
+ numericRanges: paramNumericRanges({ param }),
+ coerceValue: ({ value }) => coerceAnimationParamValue({ param, value }),
+ getBaseValue: () => effect.params[param.key] ?? param.default,
+ setBaseValue: ({ value }) => {
+ const coercedValue = coerceAnimationParamValue({ param, value });
+ if (coercedValue === null) {
+ return element;
+ }
+
+ return {
+ ...element,
+ effects:
+ element.effects?.map((candidate) =>
+ candidate.id !== effectId
+ ? candidate
+ : {
+ ...candidate,
+ params: {
+ ...candidate.params,
+ [param.key]: coercedValue,
+ },
+ },
+ ) ?? element.effects,
+ };
+ },
+ };
+}
+
+export function resolveAnimationTarget({
+ element,
+ path,
+}: {
+ element: TimelineElement;
+ path: AnimationPath;
+}): AnimationPathDescriptor | null {
+ if (isAnimationPropertyPath(path)) {
+ const propertyDefinition = getAnimationPropertyDefinition({
+ propertyPath: path,
+ });
+ if (!propertyDefinition.supportsElement({ element })) {
+ return null;
+ }
+
+ return {
+ kind: propertyDefinition.kind,
+ defaultInterpolation: propertyDefinition.defaultInterpolation,
+ numericRanges: propertyDefinition.numericRanges,
+ coerceValue: ({ value }) =>
+ coerceAnimationValueForProperty({
+ propertyPath: path,
+ value,
+ }),
+ getBaseValue: () =>
+ getElementBaseValueForProperty({
+ element,
+ propertyPath: path,
+ }),
+ setBaseValue: ({ value }) => {
+ const coercedValue = propertyDefinition.coerceValue({ value });
+ if (coercedValue === null) {
+ return element;
+ }
+
+ return withElementBaseValueForProperty({
+ element,
+ propertyPath: path,
+ value: coercedValue,
+ });
+ },
+ };
+ }
+
+ const graphicParamTarget = parseGraphicParamPath({ propertyPath: path });
+ if (graphicParamTarget) {
+ return buildGraphicParamDescriptor({
+ element,
+ paramKey: graphicParamTarget.paramKey,
+ });
+ }
+
+ const effectParamTarget = parseEffectParamPath({ propertyPath: path });
+ if (effectParamTarget) {
+ return buildEffectParamDescriptor({
+ element,
+ effectId: effectParamTarget.effectId,
+ paramKey: effectParamTarget.paramKey,
+ });
+ }
+
+ return null;
+}
diff --git a/apps/web/src/timeline/audio-state.ts b/apps/web/src/timeline/audio-state.ts
index 4ef559ea..44df5aaf 100644
--- a/apps/web/src/timeline/audio-state.ts
+++ b/apps/web/src/timeline/audio-state.ts
@@ -1,5 +1,5 @@
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 type { TimelineElement } from "./types";
const DEFAULT_STEP_SECONDS = 1 / 60;
diff --git a/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts b/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts
index 1071c6dd..35ee9cca 100644
--- a/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts
+++ b/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts
@@ -19,7 +19,7 @@ import {
import { getBookmarkSnapPoints } from "../snap-source";
import { getElementEdgeSnapPoints } from "@/timeline/element-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";
export interface BookmarkDragState {
diff --git a/apps/web/src/timeline/components/audio-tab.tsx b/apps/web/src/timeline/components/audio-tab.tsx
index 1dbb3d1a..1e47dcc8 100644
--- a/apps/web/src/timeline/components/audio-tab.tsx
+++ b/apps/web/src/timeline/components/audio-tab.tsx
@@ -1,151 +1,151 @@
-import { Button } from "@/components/ui/button";
-import { NumberField } from "@/components/ui/number-field";
-import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
-import { isSourceAudioSeparated } from "@/timeline/audio-separation";
-import { DEFAULTS } from "@/timeline/defaults";
-import {
- clamp,
- formatNumberForDisplay,
- getFractionDigitsForStep,
- isNearlyEqual,
- snapToStep,
-} from "@/utils/math";
-import type { AudioElement, VideoElement } from "@/timeline";
-import { resolveNumberAtTime } from "@/animation";
-import { useEditor } from "@/editor/use-editor";
-import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
-import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property";
-import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { VolumeHighIcon } from "@hugeicons/core-free-icons";
-import {
- Section,
- SectionContent,
- SectionField,
- SectionFields,
- SectionHeader,
- SectionTitle,
-} from "@/components/section";
-
-const VOLUME_STEP = 0.1;
-const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP });
-
-export function AudioTab({
- element,
- trackId,
-}: {
- element: AudioElement | VideoElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
- startTime: element.startTime,
- duration: element.duration,
- });
- const resolvedVolume = resolveNumberAtTime({
- baseValue: element.volume ?? DEFAULTS.element.volume,
- animations: element.animations,
- propertyPath: "volume",
- localTime,
- });
- const volume = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "volume",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: formatNumberForDisplay({
- value: resolvedVolume,
- fractionDigits: VOLUME_FRACTION_DIGITS,
- }),
- parse: (input) => {
- const parsed = parseFloat(input);
- if (Number.isNaN(parsed)) {
- return null;
- }
-
- return clamp({
- value: snapToStep({ value: parsed, step: VOLUME_STEP }),
- min: VOLUME_DB_MIN,
- max: VOLUME_DB_MAX,
- });
- },
- valueAtPlayhead: resolvedVolume,
- step: VOLUME_STEP,
- buildBaseUpdates: ({ value }) => ({
- volume: value,
- }),
- });
- const isDefault =
- volume.hasAnimatedKeyframes && isPlayheadWithinElementRange
- ? isNearlyEqual({
- leftValue: resolvedVolume,
- rightValue: DEFAULTS.element.volume,
- })
- : (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume;
- const isSeparated =
- element.type === "video" && isSourceAudioSeparated({ element });
-
- return (
- <>
- {isSeparated && (
- <div className="mx-4 mt-4 rounded-md border bg-muted/30 p-3">
- <p className="text-sm">Audio has been separated.</p>
- <Button
- className="mt-3"
- size="sm"
- variant="secondary"
- onClick={() =>
- editor.timeline.toggleSourceAudioSeparation({
- trackId,
- elementId: element.id,
- })
- }
- >
- Recover audio
- </Button>
- </div>
- )}
- <Section collapsible sectionKey={`${element.id}:audio`}>
- <SectionHeader>
- <SectionTitle>Audio</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <SectionFields>
- <SectionField
- label="Volume"
- beforeLabel={
- <KeyframeToggle
- isActive={volume.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle volume keyframe"
- onToggle={volume.toggleKeyframe}
- />
- }
- >
- <NumberField
- icon={<HugeiconsIcon icon={VolumeHighIcon} />}
- value={volume.displayValue}
- onFocus={volume.onFocus}
- onChange={volume.onChange}
- onBlur={volume.onBlur}
- dragSensitivity="slow"
- scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }}
- onScrub={volume.scrubTo}
- onScrubEnd={volume.commitScrub}
- onReset={() =>
- volume.commitValue({
- value: DEFAULTS.element.volume,
- })
- }
- isDefault={isDefault}
- suffix="dB"
- />
- </SectionField>
- </SectionFields>
- </SectionContent>
- </Section>
- </>
- );
-}
+import { Button } from "@/components/ui/button";
+import { NumberField } from "@/components/ui/number-field";
+import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
+import { isSourceAudioSeparated } from "@/timeline/audio-separation";
+import { DEFAULTS } from "@/timeline/defaults";
+import {
+ clamp,
+ formatNumberForDisplay,
+ getFractionDigitsForStep,
+ isNearlyEqual,
+ snapToStep,
+} from "@/utils/math";
+import type { AudioElement, VideoElement } from "@/timeline";
+import { resolveNumberAtTime } from "@/animation/values";
+import { useEditor } from "@/editor/use-editor";
+import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
+import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property";
+import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { VolumeHighIcon } from "@hugeicons/core-free-icons";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+
+const VOLUME_STEP = 0.1;
+const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP });
+
+export function AudioTab({
+ element,
+ trackId,
+}: {
+ element: AudioElement | VideoElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const resolvedVolume = resolveNumberAtTime({
+ baseValue: element.volume ?? DEFAULTS.element.volume,
+ animations: element.animations,
+ propertyPath: "volume",
+ localTime,
+ });
+ const volume = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "volume",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: formatNumberForDisplay({
+ value: resolvedVolume,
+ fractionDigits: VOLUME_FRACTION_DIGITS,
+ }),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) {
+ return null;
+ }
+
+ return clamp({
+ value: snapToStep({ value: parsed, step: VOLUME_STEP }),
+ min: VOLUME_DB_MIN,
+ max: VOLUME_DB_MAX,
+ });
+ },
+ valueAtPlayhead: resolvedVolume,
+ step: VOLUME_STEP,
+ buildBaseUpdates: ({ value }) => ({
+ volume: value,
+ }),
+ });
+ const isDefault =
+ volume.hasAnimatedKeyframes && isPlayheadWithinElementRange
+ ? isNearlyEqual({
+ leftValue: resolvedVolume,
+ rightValue: DEFAULTS.element.volume,
+ })
+ : (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume;
+ const isSeparated =
+ element.type === "video" && isSourceAudioSeparated({ element });
+
+ return (
+ <>
+ {isSeparated && (
+ <div className="mx-4 mt-4 rounded-md border bg-muted/30 p-3">
+ <p className="text-sm">Audio has been separated.</p>
+ <Button
+ className="mt-3"
+ size="sm"
+ variant="secondary"
+ onClick={() =>
+ editor.timeline.toggleSourceAudioSeparation({
+ trackId,
+ elementId: element.id,
+ })
+ }
+ >
+ Recover audio
+ </Button>
+ </div>
+ )}
+ <Section collapsible sectionKey={`${element.id}:audio`}>
+ <SectionHeader>
+ <SectionTitle>Audio</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <SectionFields>
+ <SectionField
+ label="Volume"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={volume.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle volume keyframe"
+ onToggle={volume.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField
+ icon={<HugeiconsIcon icon={VolumeHighIcon} />}
+ value={volume.displayValue}
+ onFocus={volume.onFocus}
+ onChange={volume.onChange}
+ onBlur={volume.onBlur}
+ dragSensitivity="slow"
+ scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }}
+ onScrub={volume.scrubTo}
+ onScrubEnd={volume.commitScrub}
+ onReset={() =>
+ volume.commitValue({
+ value: DEFAULTS.element.volume,
+ })
+ }
+ isDefault={isDefault}
+ suffix="dB"
+ />
+ </SectionField>
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ </>
+ );
+}
diff --git a/apps/web/src/timeline/controllers/playhead-controller.ts b/apps/web/src/timeline/controllers/playhead-controller.ts
index 5260c046..d2b7d82f 100644
--- a/apps/web/src/timeline/controllers/playhead-controller.ts
+++ b/apps/web/src/timeline/controllers/playhead-controller.ts
@@ -8,7 +8,7 @@ import {
} from "@/timeline/snapping";
import { getBookmarkSnapPoints } from "@/timeline/bookmarks/index";
import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
-import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
+import { getAnimationKeyframeSnapPointsForTimeline } from "@/timeline/animation-snap-points";
import {
getCenteredLineLeft,
timelineTimeToPixels,
diff --git a/apps/web/src/timeline/controllers/resize-controller.ts b/apps/web/src/timeline/controllers/resize-controller.ts
index 8677dd15..cdf502a1 100644
--- a/apps/web/src/timeline/controllers/resize-controller.ts
+++ b/apps/web/src/timeline/controllers/resize-controller.ts
@@ -16,7 +16,7 @@ import {
} from "@/timeline/snapping";
import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source";
-import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
+import { getAnimationKeyframeSnapPointsForTimeline } from "@/timeline/animation-snap-points";
import {
isRetimableElement,
type SceneTracks,
diff --git a/apps/web/src/timeline/group-move/snap.ts b/apps/web/src/timeline/group-move/snap.ts
index 32c0a2f1..7f431e6e 100644
--- a/apps/web/src/timeline/group-move/snap.ts
+++ b/apps/web/src/timeline/group-move/snap.ts
@@ -7,7 +7,7 @@ import {
} from "@/timeline/snapping";
import { getElementEdgeSnapPoints } from "@/timeline/element-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";
export function snapGroupEdges({