feat: masks, properties refactor, shaders, storage migrations, and more

This commit is contained in:
Maze Winther
2026-03-25 16:47:22 +01:00
parent 4aca4e7756
commit 08f5b92c3d
690 changed files with 35574 additions and 7308 deletions
+222
View File
@@ -0,0 +1,222 @@
import { useEditor } from "@/hooks/use-editor";
import { clamp } from "@/utils/math";
import { NumberField } from "@/components/ui/number-field";
import {
DEFAULT_BLEND_MODE,
DEFAULT_OPACITY,
} from "@/constants/timeline-constants";
import { OcCheckerboardIcon } from "@opencut/ui/icons";
import { Fragment, useRef } from "react";
import { Section, SectionContent, SectionField, SectionHeader, SectionTitle } from "../section";
import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { BlendMode } from "@/types/rendering";
import type { ElementType } from "@/types/timeline";
import type { ElementAnimations } from "@/types/animation";
import { HugeiconsIcon } from "@hugeicons/react";
import { RainDropIcon } from "@hugeicons/core-free-icons";
import { KeyframeToggle } from "../keyframe-toggle";
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
import { useElementPlayhead } from "../hooks/use-element-playhead";
import { resolveOpacityAtTime } from "@/lib/animation";
import { isPropertyAtDefault } from "./transform";
type BlendingElement = {
id: string;
opacity: number;
type: ElementType;
blendMode?: BlendMode;
startTime: number;
duration: number;
animations?: ElementAnimations;
};
const BLEND_MODE_GROUPS = [
[{ value: "normal", label: "Normal" }],
[
{ value: "darken", label: "Darken" },
{ value: "multiply", label: "Multiply" },
{ value: "color-burn", label: "Color Burn" },
],
[
{ value: "lighten", label: "Lighten" },
{ value: "screen", label: "Screen" },
{ value: "plus-lighter", label: "Plus Lighter" },
{ value: "color-dodge", label: "Color Dodge" },
],
[
{ value: "overlay", label: "Overlay" },
{ value: "soft-light", label: "Soft Light" },
{ value: "hard-light", label: "Hard Light" },
],
[
{ value: "difference", label: "Difference" },
{ value: "exclusion", label: "Exclusion" },
],
[
{ value: "hue", label: "Hue" },
{ value: "saturation", label: "Saturation" },
{ value: "color", label: "Color" },
{ value: "luminosity", label: "Luminosity" },
],
];
export function BlendingSection({
element,
trackId,
}: {
element: BlendingElement;
trackId: string;
}) {
const editor = useEditor();
const blendMode = element.blendMode ?? DEFAULT_BLEND_MODE;
const didSelectRef = useRef(false);
const committedBlendModeRef = useRef(blendMode);
if (!editor.timeline.isPreviewActive()) {
committedBlendModeRef.current = blendMode;
}
const previewBlendMode = ({ value }: { value: BlendMode }) =>
editor.timeline.previewElements({
updates: [
{ trackId, elementId: element.id, updates: { blendMode: value } },
],
});
const commitBlendMode = (value: string) => {
if (editor.timeline.isPreviewActive()) {
editor.timeline.commitPreview();
} else {
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: { blendMode: value as BlendMode },
},
],
});
}
didSelectRef.current = true;
};
const handleBlendModeOpenChange = (isOpen: boolean) => {
if (!isOpen) {
if (!didSelectRef.current) editor.timeline.discardPreview();
didSelectRef.current = false;
}
};
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
startTime: element.startTime,
duration: element.duration,
});
const resolvedOpacity = resolveOpacityAtTime({
baseOpacity: element.opacity,
animations: element.animations,
localTime,
});
const opacity = useKeyframedNumberProperty({
trackId,
elementId: element.id,
animations: element.animations,
propertyPath: "opacity",
localTime,
isPlayheadWithinElementRange,
displayValue: Math.round(resolvedOpacity * 100).toString(),
parse: (input) => {
const parsed = parseFloat(input);
if (Number.isNaN(parsed)) return null;
return clamp({ value: parsed, min: 0, max: 100 }) / 100;
},
valueAtPlayhead: resolvedOpacity,
buildBaseUpdates: ({ value }) => ({ opacity: value }),
});
return (
<Section collapsible sectionKey={`${element.type}:blending`}>
<SectionHeader><SectionTitle>Blending</SectionTitle></SectionHeader>
<SectionContent>
<div className="flex items-start gap-2">
<SectionField
label="Opacity"
className="w-1/2"
beforeLabel={
<KeyframeToggle
isActive={opacity.isKeyframedAtTime}
isDisabled={!isPlayheadWithinElementRange}
title="Toggle opacity keyframe"
onToggle={opacity.toggleKeyframe}
/>
}
>
<NumberField
className="w-full"
icon={
<OcCheckerboardIcon className="size-3.5 text-muted-foreground" />
}
value={opacity.displayValue}
min={0}
max={100}
onFocus={opacity.onFocus}
onChange={opacity.onChange}
onBlur={opacity.onBlur}
onScrub={opacity.scrubTo}
onScrubEnd={opacity.commitScrub}
onReset={() => opacity.commitValue({ value: DEFAULT_OPACITY })}
isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: opacity.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedOpacity,
staticValue: element.opacity,
defaultValue: DEFAULT_OPACITY,
})}
dragSensitivity="slow"
/>
</SectionField>
<SectionField label="Blend mode" className="w-1/2">
<Select
value={committedBlendModeRef.current}
onOpenChange={handleBlendModeOpenChange}
onValueChange={commitBlendMode}
>
<SelectTrigger
icon={<HugeiconsIcon icon={RainDropIcon} />}
className="w-full"
>
<SelectValue placeholder="Select blend mode" />
</SelectTrigger>
<SelectContent className="w-36">
{BLEND_MODE_GROUPS.map((group, groupIndex) => (
<Fragment key={group[0]?.value ?? `group-${groupIndex}`}>
{group.map((option) => (
<SelectItem
key={option.value}
value={option.value}
onPointerEnter={() =>
previewBlendMode({ value: option.value as BlendMode })
}
>
{option.label}
</SelectItem>
))}
{groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
<SelectSeparator />
) : null}
</Fragment>
))}
</SelectContent>
</Select>
</SectionField>
</div>
</SectionContent>
</Section>
);
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./transform";
export * from "./blending";
+382
View File
@@ -0,0 +1,382 @@
import { NumberField } from "@/components/ui/number-field";
import { useEditor } from "@/hooks/use-editor";
import { clamp, isNearlyEqual } from "@/utils/math";
import type { AnimationPropertyPath } from "@/types/animation";
import type { VisualElement } from "@/types/timeline";
import {
Section,
SectionContent,
SectionField,
SectionFields,
SectionHeader,
SectionTitle,
} from "../section";
import { Button } from "@/components/ui/button";
import { HugeiconsIcon } from "@hugeicons/react";
import {
ArrowExpandIcon,
Link05Icon,
RotateClockwiseIcon,
} 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";
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 TransformSection({
element,
trackId,
showTopBorder = true,
}: {
element: VisualElement;
trackId: string;
showTopBorder?: boolean;
}) {
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 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 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 = useKeyframedNumberProperty({
trackId,
elementId: element.id,
animations: element.animations,
propertyPath: "transform.scale",
localTime,
isPlayheadWithinElementRange,
displayValue: Math.round(resolvedTransform.scale * 100).toString(),
parse: (input) => {
const parsed = parseNumericInput({ input });
if (parsed === null) return null;
return Math.max(parsed, 1) / 100;
},
valueAtPlayhead: resolvedTransform.scale,
buildBaseUpdates: ({ value }) => ({
transform: {
...element.transform,
scale: value,
},
}),
});
const scaleFieldProps = {
className: "flex-1",
value: scale.displayValue,
onFocus: scale.onFocus,
onChange: scale.onChange,
onBlur: scale.onBlur,
dragSensitivity: "slow" as const,
onScrub: scale.scrubTo,
onScrubEnd: scale.commitScrub,
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 = 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,
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`}
showTopBorder={showTopBorder}
>
<SectionHeader><SectionTitle>Transform</SectionTitle></SectionHeader>
<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}
/>
}
>
<div className="flex items-center gap-2">
<NumberField
icon="X"
className="flex-1"
value={positionX.displayValue}
onFocus={positionX.onFocus}
onChange={positionX.onChange}
onBlur={positionX.onBlur}
onScrub={positionX.scrubTo}
onScrubEnd={positionX.commitScrub}
onReset={() =>
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"
className="flex-1"
value={positionY.displayValue}
onFocus={positionY.onFocus}
onChange={positionY.onChange}
onBlur={positionY.onBlur}
onScrub={positionY.scrubTo}
onScrubEnd={positionY.commitScrub}
onReset={() =>
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>
<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: 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>
);
}