summaryrefslogtreecommitdiff
path: root/apps
diff options
context:
space:
mode:
Diffstat (limited to 'apps')
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts190
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx892
-rw-r--r--apps/web/src/lib/animation/binding-values.ts12
-rw-r--r--apps/web/src/lib/animation/graph-channels.ts24
-rw-r--r--apps/web/src/lib/animation/index.ts4
-rw-r--r--apps/web/src/lib/animation/keyframes.ts46
-rw-r--r--apps/web/src/lib/animation/property-registry.ts31
-rw-r--r--apps/web/src/lib/animation/resolve.ts20
-rw-r--r--apps/web/src/lib/animation/types.ts6
-rw-r--r--apps/web/src/services/storage/migrations/index.ts114
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v24-to-v25.ts140
-rw-r--r--apps/web/src/services/storage/migrations/v24-to-v25.ts16
12 files changed, 775 insertions, 720 deletions
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts
deleted file mode 100644
index 7ee1f221..00000000
--- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts
+++ /dev/null
@@ -1,190 +0,0 @@
-import { useEditor } from "@/hooks/use-editor";
-import {
- getKeyframeAtTime,
- hasKeyframesForPath,
- upsertElementKeyframe,
-} from "@/lib/animation";
-import type {
- AnimationPropertyPath,
- ElementAnimations,
- VectorValue,
-} from "@/lib/animation/types";
-import type { TimelineElement } from "@/lib/timeline";
-import { snapToStep } from "@/utils/math";
-import { usePropertyDraft } from "./use-property-draft";
-
-export function useKeyframedVectorProperty({
- trackId,
- elementId,
- animations,
- propertyPath,
- localTime,
- isPlayheadWithinElementRange,
- resolvedValue,
- displayX,
- displayY,
- parseComponent,
- step,
- buildBaseUpdates,
-}: {
- trackId: string;
- elementId: string;
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
- localTime: number;
- isPlayheadWithinElementRange: boolean;
- resolvedValue: VectorValue;
- displayX: string;
- displayY: string;
- parseComponent: (input: string) => number | null;
- step?: number;
- buildBaseUpdates: ({
- value,
- }: {
- value: VectorValue;
- }) => Partial<TimelineElement>;
-}) {
- const editor = useEditor();
- const snapComponentValue = (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 previewVector = ({ value }: { value: VectorValue }) => {
- const nextValue = {
- x: snapComponentValue(value.x),
- y: snapComponentValue(value.y),
- };
- if (shouldUseAnimatedChannel) {
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId,
- updates: {
- animations: upsertElementKeyframe({
- animations,
- propertyPath,
- time: localTime,
- value: nextValue,
- }),
- },
- },
- ],
- });
- return;
- }
-
- editor.timeline.previewElements({
- updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: nextValue }) }],
- });
- };
-
- const x = usePropertyDraft({
- displayValue: displayX,
- parse: (input) => {
- const parsedValue = parseComponent(input);
- return parsedValue === null ? null : snapComponentValue(parsedValue);
- },
- onPreview: (xVal) =>
- previewVector({ value: { x: xVal, y: resolvedValue.y } }),
- onCommit: () => editor.timeline.commitPreview(),
- });
-
- const y = usePropertyDraft({
- displayValue: displayY,
- parse: (input) => {
- const parsedValue = parseComponent(input);
- return parsedValue === null ? null : snapComponentValue(parsedValue);
- },
- onPreview: (yVal) =>
- previewVector({ value: { x: resolvedValue.x, y: yVal } }),
- 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: resolvedValue,
- },
- ],
- });
- };
-
- const commitX = ({ value }: { value: number }) => {
- const vector: VectorValue = {
- x: snapComponentValue(value),
- y: snapComponentValue(resolvedValue.y),
- };
- if (shouldUseAnimatedChannel) {
- editor.timeline.upsertKeyframes({
- keyframes: [
- { trackId, elementId, propertyPath, time: localTime, value: vector },
- ],
- });
- return;
- }
- editor.timeline.updateElements({
- updates: [
- { trackId, elementId, patch: buildBaseUpdates({ value: vector }) },
- ],
- });
- };
-
- const commitY = ({ value }: { value: number }) => {
- const vector: VectorValue = {
- x: snapComponentValue(resolvedValue.x),
- y: snapComponentValue(value),
- };
- if (shouldUseAnimatedChannel) {
- editor.timeline.upsertKeyframes({
- keyframes: [
- { trackId, elementId, propertyPath, time: localTime, value: vector },
- ],
- });
- return;
- }
- editor.timeline.updateElements({
- updates: [
- { trackId, elementId, patch: buildBaseUpdates({ value: vector }) },
- ],
- });
- };
-
- return {
- x,
- y,
- hasAnimatedKeyframes,
- isKeyframedAtTime,
- keyframeIdAtTime,
- toggleKeyframe,
- commitX,
- commitY,
- };
-}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx
index 40519c1d..c3cef2d6 100644
--- a/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx
+++ b/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx
@@ -1,430 +1,462 @@
-import { NumberField } from "@/components/ui/number-field";
-import { useEditor } from "@/hooks/use-editor";
-import { clamp, isNearlyEqual } from "@/utils/math";
-import type { VisualElement } from "@/lib/timeline";
-import {
- Section,
- SectionContent,
- SectionField,
- SectionFields,
- SectionHeader,
- SectionTitle,
-} from "@/components/section";
-import { Button } from "@/components/ui/button";
-import { HugeiconsIcon } from "@hugeicons/react";
-import {
- ArrowExpandIcon,
- Link05Icon,
- RotateClockwiseIcon,
-} from "@hugeicons/core-free-icons";
-import {
- getGroupKeyframesAtTime,
- hasGroupKeyframeAtTime,
- resolveTransformAtTime,
-} from "@/lib/animation";
-import { DEFAULTS } from "@/lib/timeline/defaults";
-import { useElementPlayhead } from "../hooks/use-element-playhead";
-import { KeyframeToggle } from "../components/keyframe-toggle";
-import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
-import { useKeyframedVectorProperty } from "../hooks/use-keyframed-vector-property";
-import { usePropertiesStore } from "../stores/properties-store";
-
-export function parseNumericInput({ input }: { input: string }): number | null {
- const parsed = parseFloat(input);
- return Number.isNaN(parsed) ? null : parsed;
-}
-
-export function isPropertyAtDefault({
- hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue,
- staticValue,
- defaultValue,
-}: {
- hasAnimatedKeyframes: boolean;
- isPlayheadWithinElementRange: boolean;
- resolvedValue: number;
- staticValue: number;
- defaultValue: number;
-}): boolean {
- if (hasAnimatedKeyframes && isPlayheadWithinElementRange) {
- return isNearlyEqual({
- leftValue: resolvedValue,
- rightValue: defaultValue,
- });
- }
-
- return staticValue === defaultValue;
-}
-
-export function TransformTab({
- element,
- trackId,
-}: {
- element: VisualElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const isScaleLocked = usePropertiesStore((s) => s.isTransformScaleLocked);
- const setTransformScaleLocked = usePropertiesStore(
- (s) => s.setTransformScaleLocked,
- );
- const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
- startTime: element.startTime,
- duration: element.duration,
- });
- const resolvedTransform = resolveTransformAtTime({
- baseTransform: element.transform,
- animations: element.animations,
- localTime,
- });
-
- const position = useKeyframedVectorProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "transform.position",
- localTime,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedTransform.position,
- displayX: Math.round(resolvedTransform.position.x).toString(),
- displayY: Math.round(resolvedTransform.position.y).toString(),
- parseComponent: (input) => parseNumericInput({ input }),
- step: 1,
- buildBaseUpdates: ({ value }) => ({
- transform: { ...element.transform, position: value },
- }),
- });
-
- const parseScale = (input: string) => {
- const parsed = parseNumericInput({ input });
- if (parsed === null) return null;
- return parsed / 100;
- };
-
- const scaleX = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "transform.scaleX",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedTransform.scaleX * 100).toString(),
- parse: parseScale,
- valueAtPlayhead: resolvedTransform.scaleX,
- step: 0.01,
- buildBaseUpdates: ({ value }) => ({
- transform: {
- ...element.transform,
- scaleX: value,
- ...(isScaleLocked ? { scaleY: value } : {}),
- },
- }),
- buildAdditionalKeyframes: isScaleLocked
- ? ({ value }) => [{ propertyPath: "transform.scaleY", value }]
- : undefined,
- });
-
- const scaleY = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "transform.scaleY",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedTransform.scaleY * 100).toString(),
- parse: parseScale,
- valueAtPlayhead: resolvedTransform.scaleY,
- step: 0.01,
- buildBaseUpdates: ({ value }) => ({
- transform: {
- ...element.transform,
- scaleY: value,
- ...(isScaleLocked ? { scaleX: value } : {}),
- },
- }),
- buildAdditionalKeyframes: isScaleLocked
- ? ({ value }) => [{ propertyPath: "transform.scaleX", value }]
- : undefined,
- });
-
- const scaleFieldPropsX = {
- value: scaleX.displayValue,
- onFocus: scaleX.onFocus,
- onChange: scaleX.onChange,
- onBlur: scaleX.onBlur,
- dragSensitivity: "slow" as const,
- onScrub: scaleX.scrubTo,
- onScrubEnd: scaleX.commitScrub,
- onReset: () =>
- scaleX.commitValue({ value: DEFAULTS.element.transform.scaleX }),
- isDefault: isPropertyAtDefault({
- hasAnimatedKeyframes: scaleX.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedTransform.scaleX,
- staticValue: element.transform.scaleX,
- defaultValue: DEFAULTS.element.transform.scaleX,
- }),
- };
-
- const scaleFieldPropsY = {
- value: scaleY.displayValue,
- onFocus: scaleY.onFocus,
- onChange: scaleY.onChange,
- onBlur: scaleY.onBlur,
- dragSensitivity: "slow" as const,
- onScrub: scaleY.scrubTo,
- onScrubEnd: scaleY.commitScrub,
- onReset: () =>
- scaleY.commitValue({ value: DEFAULTS.element.transform.scaleY }),
- isDefault: isPropertyAtDefault({
- hasAnimatedKeyframes: scaleY.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedTransform.scaleY,
- staticValue: element.transform.scaleY,
- defaultValue: DEFAULTS.element.transform.scaleY,
- }),
- };
-
- const rotation = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "transform.rotate",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedTransform.rotate).toString(),
- parse: (input) => {
- const parsed = parseNumericInput({ input });
- if (parsed === null) return null;
- return clamp({ value: parsed, min: -360, max: 360 });
- },
- valueAtPlayhead: resolvedTransform.rotate,
- step: 1,
- buildBaseUpdates: ({ value }) => ({
- transform: {
- ...element.transform,
- rotate: value,
- },
- }),
- });
-
- const hasScaleKeyframe = hasGroupKeyframeAtTime({
- animations: element.animations,
- group: "transform.scale",
- time: localTime,
- });
-
- const toggleScaleKeyframe = () => {
- if (!isPlayheadWithinElementRange) return;
- const existing = getGroupKeyframesAtTime({
- animations: element.animations,
- group: "transform.scale",
- time: localTime,
- });
- if (existing.length > 0) {
- editor.timeline.removeKeyframes({
- keyframes: existing.map((ref) => ({
- trackId,
- elementId: element.id,
- ...ref,
- })),
- });
- return;
- }
- editor.timeline.upsertKeyframes({
- keyframes: [
- {
- trackId,
- elementId: element.id,
- propertyPath: "transform.scaleX",
- time: localTime,
- value: resolvedTransform.scaleX,
- },
- {
- trackId,
- elementId: element.id,
- propertyPath: "transform.scaleY",
- time: localTime,
- value: resolvedTransform.scaleY,
- },
- ],
- });
- };
-
- const scaleLockButton = (
- <Button
- type="button"
- variant={isScaleLocked ? "secondary" : "ghost"}
- size="icon"
- aria-pressed={isScaleLocked}
- onClick={() => setTransformScaleLocked(!isScaleLocked)}
- >
- <HugeiconsIcon icon={Link05Icon} />
- </Button>
- );
-
- return (
- <Section collapsible sectionKey={`${element.id}:transform`}>
- <SectionHeader>
- <SectionTitle>Transform</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <SectionFields>
- <div className="flex items-end gap-2">
- {isScaleLocked ? (
- <>
- <SectionField
- label="Scale"
- className="min-w-0 flex-1"
- beforeLabel={
- <KeyframeToggle
- isActive={hasScaleKeyframe}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle scale keyframe"
- onToggle={toggleScaleKeyframe}
- />
- }
- >
- <NumberField
- icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
- {...scaleFieldPropsX}
- />
- </SectionField>
- {scaleLockButton}
- </>
- ) : (
- <>
- <SectionField
- label="Width"
- className="min-w-0 flex-1"
- beforeLabel={
- <KeyframeToggle
- isActive={scaleX.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle width scale keyframe"
- onToggle={scaleX.toggleKeyframe}
- />
- }
- >
- <NumberField icon="W" {...scaleFieldPropsX} />
- </SectionField>
- {scaleLockButton}
- <SectionField
- label="Height"
- className="min-w-0 flex-1"
- beforeLabel={
- <KeyframeToggle
- isActive={scaleY.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle height scale keyframe"
- onToggle={scaleY.toggleKeyframe}
- />
- }
- >
- <NumberField icon="H" {...scaleFieldPropsY} />
- </SectionField>
- </>
- )}
- </div>
- <SectionField
- label="Position"
- beforeLabel={
- <KeyframeToggle
- isActive={position.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle position keyframe"
- onToggle={position.toggleKeyframe}
- />
- }
- >
- <div className="flex items-center gap-2">
- <NumberField
- icon="X"
- className="flex-1"
- value={position.x.displayValue}
- onFocus={position.x.onFocus}
- onChange={position.x.onChange}
- onBlur={position.x.onBlur}
- onScrub={position.x.scrubTo}
- onScrubEnd={position.x.commitScrub}
- onReset={() =>
- position.commitX({
- value: DEFAULTS.element.transform.position.x,
- })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: position.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedTransform.position.x,
- staticValue: element.transform.position.x,
- defaultValue: DEFAULTS.element.transform.position.x,
- })}
- />
- <NumberField
- icon="Y"
- className="flex-1"
- value={position.y.displayValue}
- onFocus={position.y.onFocus}
- onChange={position.y.onChange}
- onBlur={position.y.onBlur}
- onScrub={position.y.scrubTo}
- onScrubEnd={position.y.commitScrub}
- onReset={() =>
- position.commitY({
- value: DEFAULTS.element.transform.position.y,
- })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: position.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedTransform.position.y,
- staticValue: element.transform.position.y,
- defaultValue: DEFAULTS.element.transform.position.y,
- })}
- />
- </div>
- </SectionField>
-
- <SectionField
- label="Rotation"
- beforeLabel={
- <KeyframeToggle
- isActive={rotation.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle rotation keyframe"
- onToggle={rotation.toggleKeyframe}
- />
- }
- >
- <div className="flex items-center gap-2">
- <NumberField
- icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
- className="flex-none"
- value={rotation.displayValue}
- onFocus={rotation.onFocus}
- onChange={rotation.onChange}
- onBlur={rotation.onBlur}
- dragSensitivity="slow"
- onScrub={rotation.scrubTo}
- onScrubEnd={rotation.commitScrub}
- onReset={() =>
- rotation.commitValue({
- value: DEFAULTS.element.transform.rotate,
- })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: rotation.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedTransform.rotate,
- staticValue: element.transform.rotate,
- defaultValue: DEFAULTS.element.transform.rotate,
- })}
- />
- </div>
- </SectionField>
- </SectionFields>
- </SectionContent>
- </Section>
- );
-}
+import { NumberField } from "@/components/ui/number-field";
+import { useEditor } from "@/hooks/use-editor";
+import { clamp, isNearlyEqual } from "@/utils/math";
+import type { VisualElement } from "@/lib/timeline";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { Button } from "@/components/ui/button";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ ArrowExpandIcon,
+ Link05Icon,
+ RotateClockwiseIcon,
+} from "@hugeicons/core-free-icons";
+import {
+ getGroupKeyframesAtTime,
+ hasGroupKeyframeAtTime,
+ resolveTransformAtTime,
+} from "@/lib/animation";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import { useElementPlayhead } from "../hooks/use-element-playhead";
+import { KeyframeToggle } from "../components/keyframe-toggle";
+import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
+import { usePropertiesStore } from "../stores/properties-store";
+
+export function parseNumericInput({ input }: { input: string }): number | null {
+ const parsed = parseFloat(input);
+ return Number.isNaN(parsed) ? null : parsed;
+}
+
+export function isPropertyAtDefault({
+ hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue,
+ staticValue,
+ defaultValue,
+}: {
+ hasAnimatedKeyframes: boolean;
+ isPlayheadWithinElementRange: boolean;
+ resolvedValue: number;
+ staticValue: number;
+ defaultValue: number;
+}): boolean {
+ if (hasAnimatedKeyframes && isPlayheadWithinElementRange) {
+ return isNearlyEqual({
+ leftValue: resolvedValue,
+ rightValue: defaultValue,
+ });
+ }
+
+ return staticValue === defaultValue;
+}
+
+export function TransformTab({
+ element,
+ trackId,
+}: {
+ element: VisualElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const isScaleLocked = usePropertiesStore((s) => s.isTransformScaleLocked);
+ const setTransformScaleLocked = usePropertiesStore(
+ (s) => s.setTransformScaleLocked,
+ );
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const resolvedTransform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
+
+ const positionX = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "transform.positionX",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedTransform.position.x).toString(),
+ parse: (input) => parseNumericInput({ input }),
+ valueAtPlayhead: resolvedTransform.position.x,
+ step: 1,
+ buildBaseUpdates: ({ value }) => ({
+ transform: {
+ ...element.transform,
+ position: { ...element.transform.position, x: value },
+ },
+ }),
+ });
+
+ const positionY = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "transform.positionY",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedTransform.position.y).toString(),
+ parse: (input) => parseNumericInput({ input }),
+ valueAtPlayhead: resolvedTransform.position.y,
+ step: 1,
+ buildBaseUpdates: ({ value }) => ({
+ transform: {
+ ...element.transform,
+ position: { ...element.transform.position, y: value },
+ },
+ }),
+ });
+
+ const parseScale = (input: string) => {
+ const parsed = parseNumericInput({ input });
+ if (parsed === null) return null;
+ return parsed / 100;
+ };
+
+ const scaleX = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "transform.scaleX",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedTransform.scaleX * 100).toString(),
+ parse: parseScale,
+ valueAtPlayhead: resolvedTransform.scaleX,
+ step: 0.01,
+ buildBaseUpdates: ({ value }) => ({
+ transform: {
+ ...element.transform,
+ scaleX: value,
+ ...(isScaleLocked ? { scaleY: value } : {}),
+ },
+ }),
+ buildAdditionalKeyframes: isScaleLocked
+ ? ({ value }) => [{ propertyPath: "transform.scaleY", value }]
+ : undefined,
+ });
+
+ const scaleY = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "transform.scaleY",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedTransform.scaleY * 100).toString(),
+ parse: parseScale,
+ valueAtPlayhead: resolvedTransform.scaleY,
+ step: 0.01,
+ buildBaseUpdates: ({ value }) => ({
+ transform: {
+ ...element.transform,
+ scaleY: value,
+ ...(isScaleLocked ? { scaleX: value } : {}),
+ },
+ }),
+ buildAdditionalKeyframes: isScaleLocked
+ ? ({ value }) => [{ propertyPath: "transform.scaleX", value }]
+ : undefined,
+ });
+
+ const scaleFieldPropsX = {
+ value: scaleX.displayValue,
+ onFocus: scaleX.onFocus,
+ onChange: scaleX.onChange,
+ onBlur: scaleX.onBlur,
+ dragSensitivity: "slow" as const,
+ onScrub: scaleX.scrubTo,
+ onScrubEnd: scaleX.commitScrub,
+ onReset: () =>
+ scaleX.commitValue({ value: DEFAULTS.element.transform.scaleX }),
+ isDefault: isPropertyAtDefault({
+ hasAnimatedKeyframes: scaleX.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.scaleX,
+ staticValue: element.transform.scaleX,
+ defaultValue: DEFAULTS.element.transform.scaleX,
+ }),
+ };
+
+ const scaleFieldPropsY = {
+ value: scaleY.displayValue,
+ onFocus: scaleY.onFocus,
+ onChange: scaleY.onChange,
+ onBlur: scaleY.onBlur,
+ dragSensitivity: "slow" as const,
+ onScrub: scaleY.scrubTo,
+ onScrubEnd: scaleY.commitScrub,
+ onReset: () =>
+ scaleY.commitValue({ value: DEFAULTS.element.transform.scaleY }),
+ isDefault: isPropertyAtDefault({
+ hasAnimatedKeyframes: scaleY.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.scaleY,
+ staticValue: element.transform.scaleY,
+ defaultValue: DEFAULTS.element.transform.scaleY,
+ }),
+ };
+
+ const rotation = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "transform.rotate",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedTransform.rotate).toString(),
+ parse: (input) => {
+ const parsed = parseNumericInput({ input });
+ if (parsed === null) return null;
+ return clamp({ value: parsed, min: -360, max: 360 });
+ },
+ valueAtPlayhead: resolvedTransform.rotate,
+ step: 1,
+ buildBaseUpdates: ({ value }) => ({
+ transform: {
+ ...element.transform,
+ rotate: value,
+ },
+ }),
+ });
+
+ const hasScaleKeyframe = hasGroupKeyframeAtTime({
+ animations: element.animations,
+ group: "transform.scale",
+ time: localTime,
+ });
+
+ const toggleScaleKeyframe = () => {
+ if (!isPlayheadWithinElementRange) return;
+ const existing = getGroupKeyframesAtTime({
+ animations: element.animations,
+ group: "transform.scale",
+ time: localTime,
+ });
+ if (existing.length > 0) {
+ editor.timeline.removeKeyframes({
+ keyframes: existing.map((ref) => ({
+ trackId,
+ elementId: element.id,
+ ...ref,
+ })),
+ });
+ return;
+ }
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ {
+ trackId,
+ elementId: element.id,
+ propertyPath: "transform.scaleX",
+ time: localTime,
+ value: resolvedTransform.scaleX,
+ },
+ {
+ trackId,
+ elementId: element.id,
+ propertyPath: "transform.scaleY",
+ time: localTime,
+ value: resolvedTransform.scaleY,
+ },
+ ],
+ });
+ };
+
+ const scaleLockButton = (
+ <Button
+ type="button"
+ variant={isScaleLocked ? "secondary" : "ghost"}
+ size="icon"
+ aria-pressed={isScaleLocked}
+ onClick={() => setTransformScaleLocked(!isScaleLocked)}
+ >
+ <HugeiconsIcon icon={Link05Icon} />
+ </Button>
+ );
+
+ return (
+ <Section collapsible sectionKey={`${element.id}:transform`}>
+ <SectionHeader>
+ <SectionTitle>Transform</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <SectionFields>
+ <div className="flex items-end gap-2">
+ {isScaleLocked ? (
+ <>
+ <SectionField
+ label="Scale"
+ className="min-w-0 flex-1"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={hasScaleKeyframe}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle scale keyframe"
+ onToggle={toggleScaleKeyframe}
+ />
+ }
+ >
+ <NumberField
+ icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
+ {...scaleFieldPropsX}
+ />
+ </SectionField>
+ {scaleLockButton}
+ </>
+ ) : (
+ <>
+ <SectionField
+ label="Width"
+ className="min-w-0 flex-1"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={scaleX.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle width scale keyframe"
+ onToggle={scaleX.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField icon="W" {...scaleFieldPropsX} />
+ </SectionField>
+ {scaleLockButton}
+ <SectionField
+ label="Height"
+ className="min-w-0 flex-1"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={scaleY.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle height scale keyframe"
+ onToggle={scaleY.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField icon="H" {...scaleFieldPropsY} />
+ </SectionField>
+ </>
+ )}
+ </div>
+ <div className="flex items-end gap-2">
+ <SectionField
+ label="X"
+ className="min-w-0 flex-1"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={positionX.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle X position keyframe"
+ onToggle={positionX.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField
+ icon="X"
+ value={positionX.displayValue}
+ onFocus={positionX.onFocus}
+ onChange={positionX.onChange}
+ onBlur={positionX.onBlur}
+ onScrub={positionX.scrubTo}
+ onScrubEnd={positionX.commitScrub}
+ onReset={() =>
+ positionX.commitValue({
+ value: DEFAULTS.element.transform.position.x,
+ })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: positionX.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.position.x,
+ staticValue: element.transform.position.x,
+ defaultValue: DEFAULTS.element.transform.position.x,
+ })}
+ />
+ </SectionField>
+ <SectionField
+ label="Y"
+ className="min-w-0 flex-1"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={positionY.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle Y position keyframe"
+ onToggle={positionY.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField
+ icon="Y"
+ value={positionY.displayValue}
+ onFocus={positionY.onFocus}
+ onChange={positionY.onChange}
+ onBlur={positionY.onBlur}
+ onScrub={positionY.scrubTo}
+ onScrubEnd={positionY.commitScrub}
+ onReset={() =>
+ positionY.commitValue({
+ value: DEFAULTS.element.transform.position.y,
+ })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: positionY.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.position.y,
+ staticValue: element.transform.position.y,
+ defaultValue: DEFAULTS.element.transform.position.y,
+ })}
+ />
+ </SectionField>
+ </div>
+
+ <SectionField
+ label="Rotation"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={rotation.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle rotation keyframe"
+ onToggle={rotation.toggleKeyframe}
+ />
+ }
+ >
+ <div className="flex items-center gap-2">
+ <NumberField
+ icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
+ className="flex-none"
+ value={rotation.displayValue}
+ onFocus={rotation.onFocus}
+ onChange={rotation.onChange}
+ onBlur={rotation.onBlur}
+ dragSensitivity="slow"
+ onScrub={rotation.scrubTo}
+ onScrubEnd={rotation.commitScrub}
+ onReset={() =>
+ rotation.commitValue({
+ value: DEFAULTS.element.transform.rotate,
+ })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: rotation.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.rotate,
+ staticValue: element.transform.rotate,
+ defaultValue: DEFAULTS.element.transform.rotate,
+ })}
+ />
+ </div>
+ </SectionField>
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
diff --git a/apps/web/src/lib/animation/binding-values.ts b/apps/web/src/lib/animation/binding-values.ts
index 344fd776..66fb2b5d 100644
--- a/apps/web/src/lib/animation/binding-values.ts
+++ b/apps/web/src/lib/animation/binding-values.ts
@@ -47,6 +47,18 @@ export function isVectorValue(value: unknown): value is VectorValue {
return isRecord(value) && typeof value.x === "number" && typeof value.y === "number";
}
+export type EasingMode = "independent" | "shared";
+
+/**
+ * Declares how easing curves apply to a binding's components.
+ * "shared" means all components always use the same curve (e.g. color — you never
+ * want to ease R independently from G/B/A). "independent" means each component
+ * can have its own curve.
+ */
+export function getEasingModeForKind(kind: AnimationBindingKind): EasingMode {
+ return kind === "color" ? "shared" : "independent";
+}
+
export function getBindingComponentKeys({
kind,
}: {
diff --git a/apps/web/src/lib/animation/graph-channels.ts b/apps/web/src/lib/animation/graph-channels.ts
index 240b00ed..19d53e27 100644
--- a/apps/web/src/lib/animation/graph-channels.ts
+++ b/apps/web/src/lib/animation/graph-channels.ts
@@ -1,4 +1,5 @@
import type {
+ AnimationBindingInstance,
AnimationPath,
ElementAnimations,
ScalarAnimationChannel,
@@ -6,6 +7,11 @@ import type {
ScalarGraphKeyframeContext,
} from "@/lib/animation/types";
+export interface EditableScalarChannels {
+ binding: AnimationBindingInstance;
+ channels: ScalarGraphChannel[];
+}
+
function isScalarAnimationChannel(
channel: ElementAnimations["channels"][string],
): channel is ScalarAnimationChannel {
@@ -18,13 +24,13 @@ export function getEditableScalarChannels({
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
-}): ScalarGraphChannel[] {
+}): EditableScalarChannels | null {
const binding = animations?.bindings[propertyPath];
if (!binding) {
- return [];
+ return null;
}
- return binding.components.flatMap((component) => {
+ const channels = binding.components.flatMap((component) => {
const channel = animations?.channels[component.channelId];
if (!isScalarAnimationChannel(channel)) {
return [];
@@ -36,9 +42,11 @@ export function getEditableScalarChannels({
componentKey: component.key,
channelId: component.channelId,
channel,
- },
+ } satisfies ScalarGraphChannel,
];
});
+
+ return { binding, channels };
}
export function getEditableScalarChannel({
@@ -50,12 +58,8 @@ export function getEditableScalarChannel({
propertyPath: AnimationPath;
componentKey: string;
}): ScalarGraphChannel | null {
- return (
- getEditableScalarChannels({
- animations,
- propertyPath,
- }).find((channel) => channel.componentKey === componentKey) ?? null
- );
+ const result = getEditableScalarChannels({ animations, propertyPath });
+ return result?.channels.find((channel) => channel.componentKey === componentKey) ?? null;
}
export function getScalarKeyframeContext({
diff --git a/apps/web/src/lib/animation/index.ts b/apps/web/src/lib/animation/index.ts
index 7d2646d9..384f3d17 100644
--- a/apps/web/src/lib/animation/index.ts
+++ b/apps/web/src/lib/animation/index.ts
@@ -49,6 +49,7 @@ export {
} from "./keyframe-query";
export {
+ type EditableScalarChannels,
getEditableScalarChannel,
getEditableScalarChannels,
getScalarKeyframeContext,
@@ -90,5 +91,6 @@ export {
} from "./property-groups";
export {
- isVectorValue,
+ type EasingMode,
+ getEasingModeForKind,
} from "./binding-values";
diff --git a/apps/web/src/lib/animation/keyframes.ts b/apps/web/src/lib/animation/keyframes.ts
index 05290c1d..a5efb6ee 100644
--- a/apps/web/src/lib/animation/keyframes.ts
+++ b/apps/web/src/lib/animation/keyframes.ts
@@ -173,7 +173,7 @@ function createScalarKey({
id: string;
time: number;
value: number;
- interpolation: AnimationInterpolation;
+ interpolation?: AnimationInterpolation;
previousKey?: ScalarAnimationKey;
}): ScalarAnimationKey {
return {
@@ -183,7 +183,8 @@ function createScalarKey({
leftHandle: previousKey?.leftHandle,
rightHandle: previousKey?.rightHandle,
segmentToNext:
- previousKey?.segmentToNext ?? getScalarSegmentType({ interpolation }),
+ previousKey?.segmentToNext ??
+ getScalarSegmentType({ interpolation: interpolation ?? "linear" }),
tangentMode: previousKey?.tangentMode ?? "flat",
};
}
@@ -332,12 +333,14 @@ function upsertScalarChannelKey({
time,
value,
interpolation,
+ defaultInterpolation,
keyframeId,
}: {
channel: ScalarAnimationChannel | undefined;
time: number;
value: number;
- interpolation: AnimationInterpolation;
+ interpolation?: AnimationInterpolation;
+ defaultInterpolation?: AnimationInterpolation;
keyframeId?: string;
}): ScalarAnimationChannel {
const normalizedChannel = normalizeChannel({
@@ -352,10 +355,13 @@ function upsertScalarChannelKey({
time,
value,
interpolation,
- previousKey: {
- ...keys[existingIndex],
- segmentToNext: getScalarSegmentType({ interpolation }),
- },
+ previousKey:
+ interpolation != null
+ ? {
+ ...keys[existingIndex],
+ segmentToNext: getScalarSegmentType({ interpolation }),
+ }
+ : keys[existingIndex],
});
return normalizeChannel({
channel: {
@@ -376,10 +382,13 @@ function upsertScalarChannelKey({
time: keys[existingAtTimeIndex].time,
value,
interpolation,
- previousKey: {
- ...keys[existingAtTimeIndex],
- segmentToNext: getScalarSegmentType({ interpolation }),
- },
+ previousKey:
+ interpolation != null
+ ? {
+ ...keys[existingAtTimeIndex],
+ segmentToNext: getScalarSegmentType({ interpolation }),
+ }
+ : keys[existingAtTimeIndex],
});
return normalizeChannel({
channel: {
@@ -395,7 +404,7 @@ function upsertScalarChannelKey({
id: keyframeId ?? generateUUID(),
time,
value,
- interpolation,
+ interpolation: interpolation ?? defaultInterpolation,
}),
);
return normalizeChannel({
@@ -472,9 +481,13 @@ export function upsertPathKeyframe({
return animations;
}
- const nextInterpolation = getInterpolationForBinding({
+ const explicitInterpolation =
+ interpolation != null
+ ? getInterpolationForBinding({ kind, interpolation })
+ : undefined;
+ const validatedDefaultInterpolation = getInterpolationForBinding({
kind,
- interpolation: interpolation ?? defaultInterpolation,
+ interpolation: defaultInterpolation,
});
nextAnimations.bindings[propertyPath] = binding;
for (const component of binding.components) {
@@ -503,7 +516,8 @@ export function upsertPathKeyframe({
channel: targetChannel,
time: targetKey.time,
value: nextValue as number,
- interpolation: nextInterpolation,
+ interpolation: explicitInterpolation,
+ defaultInterpolation: validatedDefaultInterpolation,
keyframeId: targetKey.id,
});
}
@@ -592,7 +606,7 @@ export function upsertKeyframe({
channel,
time,
value,
- interpolation: interpolation ?? "linear",
+ interpolation,
keyframeId,
});
}
diff --git a/apps/web/src/lib/animation/property-registry.ts b/apps/web/src/lib/animation/property-registry.ts
index ac15805e..65713e6d 100644
--- a/apps/web/src/lib/animation/property-registry.ts
+++ b/apps/web/src/lib/animation/property-registry.ts
@@ -3,9 +3,8 @@ import type {
AnimationInterpolation,
AnimationPropertyPath,
AnimationValue,
- VectorValue,
} from "@/lib/animation/types";
-import { isVectorValue, parseColorToLinearRgba } from "./binding-values";
+import { parseColorToLinearRgba } from "./binding-values";
import type { TimelineElement } from "@/lib/timeline";
import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants";
import {
@@ -116,24 +115,38 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
AnimationPropertyPath,
AnimationPropertyDefinition
> = {
- "transform.position": {
- kind: "vector2",
- defaultInterpolation: "linear",
+ "transform.positionX": createNumberPropertyDefinition({
+ numericRange: { step: 1 },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
- isVisualElement(element) ? element.transform.position : null,
- coerceValue: ({ value }) => (isVectorValue(value) ? value : null),
+ isVisualElement(element) ? element.transform.position.x : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? {
...element,
transform: {
...element.transform,
- position: value as VectorValue,
+ 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),
diff --git a/apps/web/src/lib/animation/resolve.ts b/apps/web/src/lib/animation/resolve.ts
index 67f4a487..42c1c73c 100644
--- a/apps/web/src/lib/animation/resolve.ts
+++ b/apps/web/src/lib/animation/resolve.ts
@@ -48,12 +48,20 @@ export function resolveTransformAtTime({
}): Transform {
const safeLocalTime = Math.max(0, localTime);
return {
- position: resolveAnimationPathValueAtTime({
- animations,
- propertyPath: "transform.position",
- localTime: safeLocalTime,
- fallbackValue: baseTransform.position,
- }),
+ 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",
diff --git a/apps/web/src/lib/animation/types.ts b/apps/web/src/lib/animation/types.ts
index 453723b0..115b4dbf 100644
--- a/apps/web/src/lib/animation/types.ts
+++ b/apps/web/src/lib/animation/types.ts
@@ -1,7 +1,8 @@
import type { ParamValues } from "@/lib/params";
export const ANIMATION_PROPERTY_PATHS = [
- "transform.position",
+ "transform.positionX",
+ "transform.positionY",
"transform.scaleX",
"transform.scaleY",
"transform.rotate",
@@ -34,7 +35,8 @@ export type VectorValue = { x: number; y: number };
export type DiscreteValue = boolean | string;
export type AnimationValue = number | string | boolean | VectorValue;
export interface AnimationPropertyValueMap {
- "transform.position": VectorValue;
+ "transform.positionX": number;
+ "transform.positionY": number;
"transform.scaleX": number;
"transform.scaleY": number;
"transform.rotate": number;
diff --git a/apps/web/src/services/storage/migrations/index.ts b/apps/web/src/services/storage/migrations/index.ts
index b7ee37f5..bb2d9709 100644
--- a/apps/web/src/services/storage/migrations/index.ts
+++ b/apps/web/src/services/storage/migrations/index.ts
@@ -1,56 +1,58 @@
-export { StorageMigration } from "./base";
-import { V0toV1Migration } from "./v0-to-v1";
-import { V1toV2Migration } from "./v1-to-v2";
-import { V2toV3Migration } from "./v2-to-v3";
-import { V3toV4Migration } from "./v3-to-v4";
-import { V4toV5Migration } from "./v4-to-v5";
-import { V5toV6Migration } from "./v5-to-v6";
-import { V6toV7Migration } from "./v6-to-v7";
-import { V7toV8Migration } from "./v7-to-v8";
-import { V8toV9Migration } from "./v8-to-v9";
-import { V9toV10Migration } from "./v9-to-v10";
-import { V10toV11Migration } from "./v10-to-v11";
-import { V11toV12Migration } from "./v11-to-v12";
-import { V12toV13Migration } from "./v12-to-v13";
-import { V13toV14Migration } from "./v13-to-v14";
-import { V14toV15Migration } from "./v14-to-v15";
-import { V15toV16Migration } from "./v15-to-v16";
-import { V16toV17Migration } from "./v16-to-v17";
-import { V17toV18Migration } from "./v17-to-v18";
-import { V18toV19Migration } from "./v18-to-v19";
-import { V19toV20Migration } from "./v19-to-v20";
-import { V20toV21Migration } from "./v20-to-v21";
-import { V21toV22Migration } from "./v21-to-v22";
-import { V22toV23Migration } from "./v22-to-v23";
-import { V23toV24Migration } from "./v23-to-v24";
-export { runStorageMigrations } from "./runner";
-export type { MigrationProgress } from "./runner";
-
-export const CURRENT_PROJECT_VERSION = 24;
-
-export const migrations = [
- new V0toV1Migration(),
- new V1toV2Migration(),
- new V2toV3Migration(),
- new V3toV4Migration(),
- new V4toV5Migration(),
- new V5toV6Migration(),
- new V6toV7Migration(),
- new V7toV8Migration(),
- new V8toV9Migration(),
- new V9toV10Migration(),
- new V10toV11Migration(),
- new V11toV12Migration(),
- new V12toV13Migration(),
- new V13toV14Migration(),
- new V14toV15Migration(),
- new V15toV16Migration(),
- new V16toV17Migration(),
- new V17toV18Migration(),
- new V18toV19Migration(),
- new V19toV20Migration(),
- new V20toV21Migration(),
- new V21toV22Migration(),
- new V22toV23Migration(),
- new V23toV24Migration(),
-];
+export { StorageMigration } from "./base";
+import { V0toV1Migration } from "./v0-to-v1";
+import { V1toV2Migration } from "./v1-to-v2";
+import { V2toV3Migration } from "./v2-to-v3";
+import { V3toV4Migration } from "./v3-to-v4";
+import { V4toV5Migration } from "./v4-to-v5";
+import { V5toV6Migration } from "./v5-to-v6";
+import { V6toV7Migration } from "./v6-to-v7";
+import { V7toV8Migration } from "./v7-to-v8";
+import { V8toV9Migration } from "./v8-to-v9";
+import { V9toV10Migration } from "./v9-to-v10";
+import { V10toV11Migration } from "./v10-to-v11";
+import { V11toV12Migration } from "./v11-to-v12";
+import { V12toV13Migration } from "./v12-to-v13";
+import { V13toV14Migration } from "./v13-to-v14";
+import { V14toV15Migration } from "./v14-to-v15";
+import { V15toV16Migration } from "./v15-to-v16";
+import { V16toV17Migration } from "./v16-to-v17";
+import { V17toV18Migration } from "./v17-to-v18";
+import { V18toV19Migration } from "./v18-to-v19";
+import { V19toV20Migration } from "./v19-to-v20";
+import { V20toV21Migration } from "./v20-to-v21";
+import { V21toV22Migration } from "./v21-to-v22";
+import { V22toV23Migration } from "./v22-to-v23";
+import { V23toV24Migration } from "./v23-to-v24";
+import { V24toV25Migration } from "./v24-to-v25";
+export { runStorageMigrations } from "./runner";
+export type { MigrationProgress } from "./runner";
+
+export const CURRENT_PROJECT_VERSION = 25;
+
+export const migrations = [
+ new V0toV1Migration(),
+ new V1toV2Migration(),
+ new V2toV3Migration(),
+ new V3toV4Migration(),
+ new V4toV5Migration(),
+ new V5toV6Migration(),
+ new V6toV7Migration(),
+ new V7toV8Migration(),
+ new V8toV9Migration(),
+ new V9toV10Migration(),
+ new V10toV11Migration(),
+ new V11toV12Migration(),
+ new V12toV13Migration(),
+ new V13toV14Migration(),
+ new V14toV15Migration(),
+ new V15toV16Migration(),
+ new V16toV17Migration(),
+ new V17toV18Migration(),
+ new V18toV19Migration(),
+ new V19toV20Migration(),
+ new V20toV21Migration(),
+ new V21toV22Migration(),
+ new V22toV23Migration(),
+ new V23toV24Migration(),
+ new V24toV25Migration(),
+];
diff --git a/apps/web/src/services/storage/migrations/transformers/v24-to-v25.ts b/apps/web/src/services/storage/migrations/transformers/v24-to-v25.ts
new file mode 100644
index 00000000..b3b48f42
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/transformers/v24-to-v25.ts
@@ -0,0 +1,140 @@
+import type { MigrationResult, ProjectRecord } from "./types";
+import { getProjectId, isRecord } from "./utils";
+
+export function transformProjectV24ToV25({
+ project,
+}: {
+ project: ProjectRecord;
+}): MigrationResult<ProjectRecord> {
+ if (!getProjectId({ project })) {
+ return { project, skipped: true, reason: "no project id" };
+ }
+
+ const version = project.version;
+ if (typeof version !== "number") {
+ return { project, skipped: true, reason: "invalid version" };
+ }
+ if (version >= 25) {
+ return { project, skipped: true, reason: "already v25" };
+ }
+ if (version !== 24) {
+ return { project, skipped: true, reason: "not v24" };
+ }
+
+ return {
+ project: {
+ ...migrateProject({ project }),
+ version: 25,
+ },
+ skipped: false,
+ };
+}
+
+function migrateProject({
+ project,
+}: {
+ project: ProjectRecord;
+}): ProjectRecord {
+ if (!Array.isArray(project.scenes)) {
+ return project;
+ }
+
+ return {
+ ...project,
+ scenes: project.scenes.map((scene) => migrateScene({ scene })),
+ };
+}
+
+function migrateScene({ scene }: { scene: unknown }): unknown {
+ if (!isRecord(scene) || !isRecord(scene.tracks)) {
+ return scene;
+ }
+
+ const tracks = scene.tracks;
+ const nextTracks: ProjectRecord = { ...tracks };
+
+ if (isRecord(tracks.main)) {
+ nextTracks.main = migrateTrack({ track: tracks.main });
+ }
+
+ if (Array.isArray(tracks.overlay)) {
+ nextTracks.overlay = tracks.overlay.map((track) =>
+ migrateTrack({ track }),
+ );
+ }
+
+ if (Array.isArray(tracks.audio)) {
+ nextTracks.audio = tracks.audio.map((track) => migrateTrack({ track }));
+ }
+
+ return { ...scene, tracks: nextTracks };
+}
+
+function migrateTrack({ track }: { track: unknown }): unknown {
+ if (!isRecord(track) || !Array.isArray(track.elements)) {
+ return track;
+ }
+
+ return {
+ ...track,
+ elements: track.elements.map((element) => migrateElement({ element })),
+ };
+}
+
+function migrateElement({ element }: { element: unknown }): unknown {
+ if (!isRecord(element) || !isRecord(element.animations)) {
+ return element;
+ }
+
+ const nextAnimations = migrateAnimations({ animations: element.animations });
+ if (nextAnimations === element.animations) {
+ return element;
+ }
+
+ return { ...element, animations: nextAnimations };
+}
+
+function migrateAnimations({
+ animations,
+}: {
+ animations: ProjectRecord;
+}): ProjectRecord {
+ if (!isRecord(animations.bindings) || !isRecord(animations.channels)) {
+ return animations;
+ }
+
+ const positionBinding = animations.bindings["transform.position"];
+ if (!isRecord(positionBinding) || positionBinding.kind !== "vector2") {
+ return animations;
+ }
+
+ const xChannel = animations.channels["transform.position:x"];
+ const yChannel = animations.channels["transform.position:y"];
+
+ const nextBindings: ProjectRecord = { ...animations.bindings };
+ const nextChannels: ProjectRecord = { ...animations.channels };
+
+ delete nextBindings["transform.position"];
+ delete nextChannels["transform.position:x"];
+ delete nextChannels["transform.position:y"];
+
+ nextBindings["transform.positionX"] = {
+ path: "transform.positionX",
+ kind: "number",
+ components: [{ key: "value", channelId: "transform.positionX:value" }],
+ };
+ nextBindings["transform.positionY"] = {
+ path: "transform.positionY",
+ kind: "number",
+ components: [{ key: "value", channelId: "transform.positionY:value" }],
+ };
+
+ if (isRecord(xChannel)) {
+ nextChannels["transform.positionX:value"] = xChannel;
+ }
+ if (isRecord(yChannel)) {
+ nextChannels["transform.positionY:value"] = yChannel;
+ }
+
+ return { ...animations, bindings: nextBindings, channels: nextChannels };
+}
diff --git a/apps/web/src/services/storage/migrations/v24-to-v25.ts b/apps/web/src/services/storage/migrations/v24-to-v25.ts
new file mode 100644
index 00000000..2e2a6935
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/v24-to-v25.ts
@@ -0,0 +1,16 @@
+import { StorageMigration } from "./base";
+import type { ProjectRecord } from "./transformers/types";
+import { transformProjectV24ToV25 } from "./transformers/v24-to-v25";
+
+export class V24toV25Migration extends StorageMigration {
+ from = 24;
+ to = 25;
+
+ async transform(project: ProjectRecord): Promise<{
+ project: ProjectRecord;
+ skipped: boolean;
+ reason?: string;
+ }> {
+ return transformProjectV24ToV25({ project });
+ }
+}