diff options
Diffstat (limited to 'apps')
| -rw-r--r-- | apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts | 190 | ||||
| -rw-r--r-- | apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx | 892 | ||||
| -rw-r--r-- | apps/web/src/lib/animation/binding-values.ts | 12 | ||||
| -rw-r--r-- | apps/web/src/lib/animation/graph-channels.ts | 24 | ||||
| -rw-r--r-- | apps/web/src/lib/animation/index.ts | 4 | ||||
| -rw-r--r-- | apps/web/src/lib/animation/keyframes.ts | 46 | ||||
| -rw-r--r-- | apps/web/src/lib/animation/property-registry.ts | 31 | ||||
| -rw-r--r-- | apps/web/src/lib/animation/resolve.ts | 20 | ||||
| -rw-r--r-- | apps/web/src/lib/animation/types.ts | 6 | ||||
| -rw-r--r-- | apps/web/src/services/storage/migrations/index.ts | 114 | ||||
| -rw-r--r-- | apps/web/src/services/storage/migrations/transformers/v24-to-v25.ts | 140 | ||||
| -rw-r--r-- | apps/web/src/services/storage/migrations/v24-to-v25.ts | 16 |
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 }); + } +} |
