"use client"; import type { MaskableElement } from "@/lib/timeline"; import type { Mask, MaskType } from "@/lib/masks/types"; import type { NumberParamDefinition, SelectParamDefinition } from "@/lib/params"; import { masksRegistry, buildDefaultMaskInstance } from "@/lib/masks"; import { useEditor } from "@/hooks/use-editor"; import { useElementPreview } from "@/hooks/use-element-preview"; import { useMenuPreview } from "@/hooks/use-menu-preview"; import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds"; import { HugeiconsIcon } from "@hugeicons/react"; import { Delete02Icon, FeatherIcon, PlusSignIcon, RotateClockwiseIcon, } from "@hugeicons/core-free-icons"; import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; import { useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { ColorPicker } from "@/components/ui/color-picker"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { NumberField } from "@/components/ui/number-field"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { clamp, formatNumberForDisplay, getFractionDigitsForStep, snapToStep, } from "@/utils/math"; import { Section, SectionContent, SectionField, SectionFields, SectionHeader, SectionTitle, } from "@/components/section"; import { usePropertyDraft } from "../hooks/use-property-draft"; import { OcMirrorIcon, OcShapesIcon } from "@/components/icons"; import { cn } from "@/utils/ui"; type MasksTabProps = { element: MaskableElement; trackId: string; }; type MaskItemProps = { trackId: string; elementId: string; mask: Mask; previewParam: (key: string) => (value: number | string | boolean) => void; onCommit: () => void; }; type EmptyViewProps = { onAddMask: () => void; }; type PreviewParamHandler = ( key: string, ) => (value: number | string | boolean) => void; type RegisteredMaskDefinition = ReturnType<(typeof masksRegistry)["get"]>; export function MasksTab({ element, trackId }: MasksTabProps) { const editor = useEditor(); const { renderElement, previewUpdates, commit } = useElementPreview({ trackId, elementId: element.id, fallback: element, }); const maskDefs = masksRegistry.getAll(); const tracks = useEditor((e) => e.timeline.getRenderTracks()); const currentTime = useEditor((e) => e.playback.getCurrentTime()); const mediaAssets = useEditor((e) => e.media.getAssets()); const canvasSize = useEditor( (e) => e.project.getActive().settings.canvasSize, ); const masks = element.masks ?? []; const renderMasks = renderElement.masks ?? masks; const hasMask = masks.length > 0; const { onPointerLeave, onOpenChange, markCommitted } = useMenuPreview(); const [isDropdownOpen, setIsDropdownOpen] = useState(false); const elementBounds = useMemo(() => { const clampedTime = Math.min( Math.max(currentTime, element.startTime), element.startTime + element.duration - TIME_EPSILON_SECONDS, ); return ( getVisibleElementsWithBounds({ tracks, currentTime: clampedTime, canvasSize, mediaAssets, }).find( (item) => item.trackId === trackId && item.elementId === element.id, )?.bounds ?? null ); }, [ canvasSize, currentTime, element.duration, element.id, element.startTime, mediaAssets, trackId, tracks, ]); const handleDropdownOpenChange = (open: boolean) => { if (hasMask && open) return; setIsDropdownOpen(open); onOpenChange(open); }; const previewMask = ({ maskType }: { maskType: MaskType }) => { editor.timeline.previewElements({ updates: [ { trackId, elementId: element.id, updates: { masks: [ buildDefaultMaskInstance({ maskType, elementSize: elementBounds ? { width: elementBounds.width, height: elementBounds.height, } : undefined, }), ], } as Partial, }, ], }); }; const commitMask = ({ maskType }: { maskType: MaskType }) => { if (editor.timeline.isPreviewActive()) { editor.timeline.commitPreview(); } else { editor.timeline.updateElements({ updates: [ { trackId, elementId: element.id, updates: { masks: [ buildDefaultMaskInstance({ maskType, elementSize: elementBounds ? { width: elementBounds.width, height: elementBounds.height, } : undefined, }), ], } as Partial, }, ], }); } markCommitted(); setIsDropdownOpen(false); }; const previewMaskParam = ({ index, key }: { index: number; key: string }) => (value: number | string | boolean) => { if (!renderMasks[index]) { return; } const updatedMasks = renderMasks.map((existingMask, maskIndex) => maskIndex !== index ? existingMask : { ...existingMask, params: { ...existingMask.params, [key]: value, }, }, ); previewUpdates({ masks: updatedMasks } as Partial); }; return (
Masks {hasMask ? ( Only one mask is supported right now. If you need more, duplicate the clip and apply a different mask to each copy. ) : ( )} {maskDefs.map((definition) => ( previewMask({ maskType: definition.type }) } onClick={() => commitMask({ maskType: definition.type })} > {definition.name} ))}
{masks.length === 0 ? ( setIsDropdownOpen(true)} /> ) : ( masks.map((mask, index) => ( previewMaskParam({ index, key: paramKey }) } onCommit={commit} /> )) )}
); } function MaskItem({ trackId, elementId, mask, previewParam, onCommit, }: MaskItemProps) { const editor = useEditor(); const definition = masksRegistry.get(mask.type); return (
} >
{definition.name}
); } function MaskParamsFields({ mask, definition, previewParam, onCommit, }: { mask: Mask; definition: RegisteredMaskDefinition; previewParam: PreviewParamHandler; onCommit: () => void; }) { const featherParam = getNumberParamDefinition({ definition, key: "feather", }); const strokeWidthParam = getNumberParamDefinition({ definition, key: "strokeWidth", }); const previewNumberParam = (key: string) => (value: number) => previewParam(key)(value); const previewStrokeColor = previewParam("strokeColor"); const strokeAlignParam = definition.params.find( (param): param is SelectParamDefinition => param.key === "strokeAlign" && param.type === "select", ); return ( {definition.features.hasPosition && "centerX" in mask.params && "centerY" in mask.params && (
)} {definition.features.sizeMode === "width-height" && "width" in mask.params && "height" in mask.params && (
)} {definition.features.sizeMode === "uniform" && "scale" in mask.params && ( )} {definition.features.hasRotation && "rotation" in mask.params && ( } param={getNumberParamDefinition({ definition, key: "rotation", })} value={getMaskNumber({ params: mask.params, key: "rotation", })} onPreview={previewNumberParam("rotation")} onCommit={onCommit} /> )} } param={featherParam} value={getMaskNumber({ params: mask.params, key: "feather", })} onPreview={previewNumberParam("feather")} onCommit={onCommit} />
previewStrokeColor(`#${color}`)} onChangeEnd={(color) => { previewStrokeColor(`#${color}`); onCommit(); }} />
{strokeAlignParam ? ( ) : null}
); } function getNumberParamDefinition({ definition, key, }: { definition: RegisteredMaskDefinition; key: string; }): NumberParamDefinition { const param = definition.params.find((candidate) => candidate.key === key); if (!param || param.type !== "number") { throw new Error(`Missing number param definition for mask key "${key}"`); } return param; } function getMaskNumber({ params, key, }: { params: Mask["params"]; key: string; }): number { const value = params[key]; if (typeof value !== "number") { throw new Error(`Expected numeric mask param for "${key}"`); } return value; } function MaskNumberField({ param, value, onPreview, onCommit, icon, className, }: { param: NumberParamDefinition; value: number; onPreview: (value: number) => void; onCommit: () => void; icon?: React.ReactNode; className?: string; }) { const isPercent = param.unit === "percent"; const percentMax = param.max ?? 100; const displayMultiplier = isPercent ? 100 / percentMax : (param.displayMultiplier ?? 1); const min = isPercent ? 0 : param.min; const max = isPercent ? 100 : param.max; const step = isPercent ? 1 : param.step; const displayValue = value * displayMultiplier; const maxFractionDigits = getFractionDigitsForStep({ step }); const clampDisplay = (nextDisplayValue: number) => max !== undefined ? clamp({ value: nextDisplayValue, min, max }) : Math.max(min, nextDisplayValue); const previewFromDisplay = (nextDisplayValue: number) => { onPreview( clampDisplay(snapToStep({ value: nextDisplayValue, step })) / displayMultiplier, ); }; const draft = usePropertyDraft({ displayValue: formatNumberForDisplay({ value: displayValue, maxFractionDigits, }), parse: (input) => { const parsed = parseFloat(input); if (Number.isNaN(parsed)) return null; return clampDisplay(snapToStep({ value: parsed, step })) / displayMultiplier; }, onPreview, onCommit, }); return ( ); } function EmptyView({ onAddMask }: EmptyViewProps) { return (

No masks

Add a mask to hide or reveal parts of this layer.

); }