diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-02-27 16:33:57 +0100 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-02-27 16:33:57 +0100 |
| commit | 49426f19cd8eaed7cf6c47999b66fee7ee637a56 (patch) | |
| tree | 1b37451529cfcb36e6cae79494c61b9c616077df /apps/web/src/components/editor/panels/properties | |
| parent | 9b94f89def4f96836c4f42a23a73e97fe647a43c (diff) | |
| download | opencut-49426f19cd8eaed7cf6c47999b66fee7ee637a56.tar.gz | |
feat: implement keyframe animation system
Add keyframe support for transform, opacity, and volume properties.
Includes animation engine (interpolation, mutations, resolvers), timeline
markers with selection/snapping, properties panel toggles, keyframe-aware
renderer, and full undo/redo command support.
Also refactor element command constructors to object params, extract
timeline pixel math to pixel-utils.ts, and update cursor rules.
Diffstat (limited to 'apps/web/src/components/editor/panels/properties')
4 files changed, 482 insertions, 169 deletions
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts new file mode 100644 index 00000000..cd62ca75 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts @@ -0,0 +1,151 @@ +import { useEditor } from "@/hooks/use-editor"; +import { + getKeyframeAtTime, + hasKeyframesForPath, + upsertElementKeyframe, +} from "@/lib/animation"; +import type { AnimationPropertyPath, ElementAnimations } from "@/types/animation"; +import type { ElementUpdatePatch } from "@/types/timeline"; +import { usePropertyDraft } from "./use-property-draft"; + +export function useKeyframedNumberProperty({ + trackId, + elementId, + animations, + propertyPath, + localTime, + isPlayheadWithinElementRange, + displayValue, + parse, + valueAtPlayhead, + buildBaseUpdates, +}: { + trackId: string; + elementId: string; + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; + localTime: number; + isPlayheadWithinElementRange: boolean; + displayValue: string; + parse: (input: string) => number | null; + valueAtPlayhead: number; + buildBaseUpdates: ({ value }: { value: number }) => ElementUpdatePatch; +}) { + const editor = useEditor(); + + const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath }); + const keyframeAtTime = isPlayheadWithinElementRange + ? getKeyframeAtTime({ animations, propertyPath, time: localTime }) + : null; + const keyframeIdAtTime = keyframeAtTime?.id ?? null; + const isKeyframedAtTime = keyframeAtTime !== null; + const shouldUseAnimatedChannel = + hasAnimatedKeyframes && isPlayheadWithinElementRange; + + const previewValue = ({ value }: { value: number }) => { + if (shouldUseAnimatedChannel) { + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: { + animations: upsertElementKeyframe({ + animations, + propertyPath, + time: localTime, + value, + }), + }, + }, + ], + }); + return; + } + + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: buildBaseUpdates({ value }), + }, + ], + }); + }; + + const propertyDraft = usePropertyDraft({ + displayValue, + parse, + onPreview: (value) => previewValue({ value }), + onCommit: () => editor.timeline.commitPreview(), + }); + + const toggleKeyframe = () => { + if (!isPlayheadWithinElementRange) { + return; + } + + if (keyframeIdAtTime) { + editor.timeline.removeKeyframes({ + keyframes: [ + { + trackId, + elementId, + propertyPath, + keyframeId: keyframeIdAtTime, + }, + ], + }); + return; + } + + editor.timeline.upsertKeyframes({ + keyframes: [ + { + trackId, + elementId, + propertyPath, + time: localTime, + value: valueAtPlayhead, + }, + ], + }); + }; + + const commitValue = ({ value }: { value: number }) => { + if (shouldUseAnimatedChannel) { + editor.timeline.upsertKeyframes({ + keyframes: [ + { + trackId, + elementId, + propertyPath, + time: localTime, + value, + }, + ], + }); + return; + } + + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId, + updates: buildBaseUpdates({ value }), + }, + ], + }); + }; + + return { + ...propertyDraft, + hasAnimatedKeyframes, + isKeyframedAtTime, + keyframeIdAtTime, + toggleKeyframe, + commitValue, + }; +} diff --git a/apps/web/src/components/editor/panels/properties/keyframe-toggle.tsx b/apps/web/src/components/editor/panels/properties/keyframe-toggle.tsx new file mode 100644 index 00000000..b5b8685f --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/keyframe-toggle.tsx @@ -0,0 +1,32 @@ +import { Button } from "@/components/ui/button"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { KeyframeIcon } from "@hugeicons/core-free-icons"; +import { cn } from "@/utils/ui"; + +export function KeyframeToggle({ + isActive, + isDisabled = false, + title, + onToggle, +}: { + isActive: boolean; + isDisabled?: boolean; + title: string; + onToggle: () => void; +}) { + return ( + <Button + variant="text" + aria-pressed={isActive} + disabled={isDisabled} + title={title} + onClick={onToggle} + className="[&>svg]:size-3.5 mb-0.5" + > + <HugeiconsIcon + icon={KeyframeIcon} + className={cn(isActive && "text-primary fill-primary")} + /> + </Button> + ); +} diff --git a/apps/web/src/components/editor/panels/properties/section.tsx b/apps/web/src/components/editor/panels/properties/section.tsx index 72d78d80..3edd17b5 100644 --- a/apps/web/src/components/editor/panels/properties/section.tsx +++ b/apps/web/src/components/editor/panels/properties/section.tsx @@ -139,21 +139,28 @@ export function SectionFields({ children: React.ReactNode; className?: string; }) { - return <div className={cn("flex flex-col gap-3.5", className)}>{children}</div>; + return ( + <div className={cn("flex flex-col gap-3.5", className)}>{children}</div> + ); } export function SectionField({ label, + beforeLabel, children, className, }: { label: string; + beforeLabel?: React.ReactNode; children: React.ReactNode; className?: string; }) { return ( <div className={cn("flex flex-col gap-2", className)}> - <Label>{label}</Label> + <div className="flex items-center gap-1.5"> + {beforeLabel} + <Label>{label}</Label> + </div> {children} </div> ); diff --git a/apps/web/src/components/editor/panels/properties/sections/transform.tsx b/apps/web/src/components/editor/panels/properties/sections/transform.tsx index 21da36e9..3fdb47dc 100644 --- a/apps/web/src/components/editor/panels/properties/sections/transform.tsx +++ b/apps/web/src/components/editor/panels/properties/sections/transform.tsx @@ -1,9 +1,15 @@ import { NumberField } from "@/components/ui/number-field"; import { useEditor } from "@/hooks/use-editor"; -import { usePropertyDraft } from "../hooks/use-property-draft"; -import { clamp } from "@/utils/math"; -import type { ElementType, Transform } from "@/types/timeline"; -import { Section, SectionContent, SectionField, SectionFields, SectionHeader } from "../section"; +import { clamp, isNearlyEqual } from "@/utils/math"; +import type { AnimationPropertyPath } from "@/types/animation"; +import type { VisualElement } from "@/types/timeline"; +import { + Section, + SectionContent, + SectionField, + SectionFields, + SectionHeader, +} from "../section"; import { Button } from "@/components/ui/button"; import { HugeiconsIcon } from "@hugeicons/react"; import { @@ -13,71 +19,125 @@ import { } from "@hugeicons/core-free-icons"; import { useState } from "react"; import { DEFAULT_TRANSFORM } from "@/constants/timeline-constants"; +import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; +import { getElementLocalTime, resolveTransformAtTime } from "@/lib/animation"; +import { KeyframeToggle } from "../keyframe-toggle"; +import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property"; -type TransformElement = { - id: string; - transform: Transform; - type: ElementType; -}; - -function parseFloat_({ input }: { input: string }): number | null { +function parseNumericInput({ input }: { input: string }): number | null { const parsed = parseFloat(input); return Number.isNaN(parsed) ? null : parsed; } +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 TransformSection({ element, trackId, }: { - element: TransformElement; + element: VisualElement; trackId: string; }) { const editor = useEditor(); const [isScaleLocked, setIsScaleLocked] = useState(false); + const playheadTime = editor.playback.getCurrentTime(); + const localTime = getElementLocalTime({ + timelineTime: playheadTime, + elementStartTime: element.startTime, + elementDuration: element.duration, + }); + const resolvedTransform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); + const isPlayheadWithinElementRange = + playheadTime >= element.startTime - TIME_EPSILON_SECONDS && + playheadTime <= element.startTime + element.duration + TIME_EPSILON_SECONDS; - const previewTransform = (transform: Partial<Transform>) => { - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { transform: { ...element.transform, ...transform } }, + const positionX = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "transform.position.x", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedTransform.position.x).toString(), + parse: (input) => parseNumericInput({ input }), + valueAtPlayhead: resolvedTransform.position.x, + buildBaseUpdates: ({ value }) => ({ + transform: { + ...element.transform, + position: { + ...element.transform.position, + x: value, }, - ], - }); - }; - - const commit = () => editor.timeline.commitPreview(); - - const positionX = usePropertyDraft({ - displayValue: Math.round(element.transform.position.x).toString(), - parse: (input) => parseFloat_({ input }), - onPreview: (value) => - previewTransform({ - position: { ...element.transform.position, x: value }, - }), - onCommit: commit, + }, + }), }); - const positionY = usePropertyDraft({ - displayValue: Math.round(element.transform.position.y).toString(), - parse: (input) => parseFloat_({ input }), - onPreview: (value) => - previewTransform({ - position: { ...element.transform.position, y: value }, - }), - onCommit: commit, + const positionY = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "transform.position.y", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedTransform.position.y).toString(), + parse: (input) => parseNumericInput({ input }), + valueAtPlayhead: resolvedTransform.position.y, + buildBaseUpdates: ({ value }) => ({ + transform: { + ...element.transform, + position: { + ...element.transform.position, + y: value, + }, + }, + }), }); - const scale = usePropertyDraft({ - displayValue: Math.round(element.transform.scale * 100).toString(), + const scale = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "transform.scale", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedTransform.scale * 100).toString(), parse: (input) => { - const parsed = parseFloat_({ input }); + const parsed = parseNumericInput({ input }); if (parsed === null) return null; return Math.max(parsed, 1) / 100; }, - onPreview: (value) => previewTransform({ scale: value }), - onCommit: commit, + valueAtPlayhead: resolvedTransform.scale, + buildBaseUpdates: ({ value }) => ({ + transform: { + ...element.transform, + scale: value, + }, + }), }); const scaleFieldProps = { className: "flex-1", @@ -88,66 +148,148 @@ export function TransformSection({ dragSensitivity: "slow" as const, onScrub: scale.scrubTo, onScrubEnd: scale.commitScrub, - onReset: () => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - transform: { - ...element.transform, - scale: DEFAULT_TRANSFORM.scale, - }, - }, - }, - ], - }), - isDefault: element.transform.scale === DEFAULT_TRANSFORM.scale, + onReset: () => scale.commitValue({ value: DEFAULT_TRANSFORM.scale }), + isDefault: isPropertyAtDefault({ + hasAnimatedKeyframes: scale.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedTransform.scale, + staticValue: element.transform.scale, + defaultValue: DEFAULT_TRANSFORM.scale, + }), }; - const rotation = usePropertyDraft({ - displayValue: Math.round(element.transform.rotate).toString(), + 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 = parseFloat_({ input }); + const parsed = parseNumericInput({ input }); if (parsed === null) return null; return clamp({ value: parsed, min: -360, max: 360 }); }, - onPreview: (value) => previewTransform({ rotate: value }), - onCommit: commit, + valueAtPlayhead: resolvedTransform.rotate, + buildBaseUpdates: ({ value }) => ({ + transform: { + ...element.transform, + rotate: value, + }, + }), }); + const hasPositionKeyframe = + positionX.isKeyframedAtTime || positionY.isKeyframedAtTime; + + const togglePositionKeyframe = () => { + if (!isPlayheadWithinElementRange) { + return; + } + + if (positionX.keyframeIdAtTime || positionY.keyframeIdAtTime) { + const keyframesToRemove: Array<{ + trackId: string; + elementId: string; + propertyPath: AnimationPropertyPath; + keyframeId: string; + }> = []; + if (positionX.keyframeIdAtTime) { + keyframesToRemove.push({ + trackId, + elementId: element.id, + propertyPath: "transform.position.x" as const, + keyframeId: positionX.keyframeIdAtTime, + }); + } + if (positionY.keyframeIdAtTime) { + keyframesToRemove.push({ + trackId, + elementId: element.id, + propertyPath: "transform.position.y" as const, + keyframeId: positionY.keyframeIdAtTime, + }); + } + + editor.timeline.removeKeyframes({ + keyframes: keyframesToRemove, + }); + return; + } + + editor.timeline.upsertKeyframes({ + keyframes: [ + { + trackId, + elementId: element.id, + propertyPath: "transform.position.x", + time: localTime, + value: resolvedTransform.position.x, + }, + { + trackId, + elementId: element.id, + propertyPath: "transform.position.y", + time: localTime, + value: resolvedTransform.position.y, + }, + ], + }); + }; + return ( <Section collapsible sectionKey={`${element.type}:transform`}> <SectionHeader title="Transform" /> - <SectionContent> - <SectionFields> - <SectionField label="Scale"> - <div className="flex items-center gap-2"> - {isScaleLocked ? ( - <> - <NumberField icon="W" {...scaleFieldProps} /> - <NumberField icon="H" {...scaleFieldProps} /> - </> - ) : ( - <NumberField - icon={<HugeiconsIcon icon={ArrowExpandIcon} />} - {...scaleFieldProps} - className="flex-1" + <SectionContent> + <SectionFields> + <SectionField + label="Scale" + beforeLabel={ + <KeyframeToggle + isActive={scale.isKeyframedAtTime} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle scale keyframe" + onToggle={scale.toggleKeyframe} + /> + } + > + <div className="flex items-center gap-2"> + {isScaleLocked ? ( + <> + <NumberField icon="W" {...scaleFieldProps} /> + <NumberField icon="H" {...scaleFieldProps} /> + </> + ) : ( + <NumberField + icon={<HugeiconsIcon icon={ArrowExpandIcon} />} + {...scaleFieldProps} + className="flex-1" + /> + )} + <Button + type="button" + variant={isScaleLocked ? "secondary" : "ghost"} + size="icon" + aria-pressed={isScaleLocked} + onClick={() => setIsScaleLocked((isLocked) => !isLocked)} + > + <HugeiconsIcon icon={Link05Icon} /> + </Button> + </div> + </SectionField> + <SectionField + label="Position" + beforeLabel={ + <KeyframeToggle + isActive={hasPositionKeyframe} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle position keyframe" + onToggle={togglePositionKeyframe} /> - )} - <Button - variant={isScaleLocked ? "secondary" : "ghost"} - size="icon" - aria-pressed={isScaleLocked} - onClick={() => setIsScaleLocked((isLocked) => !isLocked)} - > - <HugeiconsIcon icon={Link05Icon} /> - </Button> - </div> - </SectionField> - <SectionField label="Position"> - <div className="flex items-center gap-2"> + } + > + <div className="flex items-center gap-2"> <NumberField icon="X" className="flex-1" @@ -158,27 +300,15 @@ export function TransformSection({ onScrub={positionX.scrubTo} onScrubEnd={positionX.commitScrub} onReset={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - transform: { - ...element.transform, - position: { - ...element.transform.position, - x: DEFAULT_TRANSFORM.position.x, - }, - }, - }, - }, - ], - }) - } - isDefault={ - element.transform.position.x === DEFAULT_TRANSFORM.position.x + positionX.commitValue({ value: DEFAULT_TRANSFORM.position.x }) } + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: positionX.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedTransform.position.x, + staticValue: element.transform.position.x, + defaultValue: DEFAULT_TRANSFORM.position.x, + })} /> <NumberField icon="Y" @@ -190,63 +320,56 @@ export function TransformSection({ onScrub={positionY.scrubTo} onScrubEnd={positionY.commitScrub} onReset={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - transform: { - ...element.transform, - position: { - ...element.transform.position, - y: DEFAULT_TRANSFORM.position.y, - }, - }, - }, - }, - ], - }) - } - isDefault={ - element.transform.position.y === DEFAULT_TRANSFORM.position.y + positionY.commitValue({ value: DEFAULT_TRANSFORM.position.y }) } + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: positionY.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedTransform.position.y, + staticValue: element.transform.position.y, + defaultValue: DEFAULT_TRANSFORM.position.y, + })} /> - </div> - </SectionField> + </div> + </SectionField> - <SectionField label="Rotation"> - <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={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - transform: { - ...element.transform, - rotate: DEFAULT_TRANSFORM.rotate, - }, - }, - }, - ], - }) + <SectionField + label="Rotation" + beforeLabel={ + <KeyframeToggle + isActive={rotation.isKeyframedAtTime} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle rotation keyframe" + onToggle={rotation.toggleKeyframe} + /> } - isDefault={element.transform.rotate === DEFAULT_TRANSFORM.rotate} - /> - </SectionField> - </SectionFields> - </SectionContent> + > + <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: DEFAULT_TRANSFORM.rotate }) + } + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: rotation.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedTransform.rotate, + staticValue: element.transform.rotate, + defaultValue: DEFAULT_TRANSFORM.rotate, + })} + /> + </div> + </SectionField> + </SectionFields> + </SectionContent> </Section> ); } |
