diff options
Diffstat (limited to 'apps/web/src')
148 files changed, 10950 insertions, 1943 deletions
diff --git a/apps/web/src/app/changelog/[version]/page.tsx b/apps/web/src/app/changelog/[version]/page.tsx new file mode 100644 index 00000000..af382822 --- /dev/null +++ b/apps/web/src/app/changelog/[version]/page.tsx @@ -0,0 +1,105 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { BasePage } from "@/app/base-page"; +import { allChangelogs } from "content-collections"; +import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; +import { getSortedReleases } from "../utils"; +import { + ReleaseArticle, + ReleaseMeta, + ReleaseTitle, + ReleaseDescription, + ReleaseChanges, +} from "../components/release"; +import { CopyMarkdownButton } from "../components/copy-markdown-button"; + +type Props = { params: Promise<{ version: string }> }; + +export async function generateStaticParams() { + return allChangelogs.map((r) => ({ version: r.version })); +} + +export async function generateMetadata({ params }: Props): Promise<Metadata> { + const { version } = await params; + const release = allChangelogs.find((r) => r.version === version); + if (!release) return {}; + return { + title: `${release.title} (${release.version}) - OpenCut Changelog`, + description: release.description, + }; +} + +export default async function ReleaseDetailPage({ params }: Props) { + const { version } = await params; + const releases = getSortedReleases(); + const index = releases.findIndex((r) => r.version === version); + + if (index === -1) notFound(); + + const release = releases[index]; + const newer = index > 0 ? releases[index - 1] : null; + const older = index < releases.length - 1 ? releases[index + 1] : null; + + return ( + <BasePage> + <div className="mx-auto w-full max-w-3xl flex flex-col gap-12"> + <Link + href="/changelog" + className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 w-fit" + > + <ChevronLeftIcon className="size-4" /> + All releases + </Link> + + <ReleaseArticle variant="detail"> + <div className="flex flex-col gap-4"> + <div className="flex items-center justify-between"> + <ReleaseMeta release={release} /> + <CopyMarkdownButton + description={release.description} + changes={release.changes} + /> + </div> + <ReleaseTitle as="h1">{release.title}</ReleaseTitle> + {release.description && ( + <ReleaseDescription>{release.description}</ReleaseDescription> + )} + </div> + <ReleaseChanges release={release} /> + </ReleaseArticle> + + <nav className="flex items-center justify-between border-t pt-8"> + {older ? ( + <Link + href={`/changelog/${older.version}`} + className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground group" + > + <ChevronLeftIcon className="size-4" /> + <div className="flex flex-col"> + <span className="text-xs text-muted-foreground/60">Older</span> + <span className="font-medium">{older.title}</span> + </div> + </Link> + ) : ( + <div /> + )} + {newer ? ( + <Link + href={`/changelog/${newer.version}`} + className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground group text-right" + > + <div className="flex flex-col"> + <span className="text-xs text-muted-foreground/60">Newer</span> + <span className="font-medium">{newer.title}</span> + </div> + <ChevronRightIcon className="size-4" /> + </Link> + ) : ( + <div /> + )} + </nav> + </div> + </BasePage> + ); +} diff --git a/apps/web/src/app/changelog/components/copy-markdown-button.tsx b/apps/web/src/app/changelog/components/copy-markdown-button.tsx new file mode 100644 index 00000000..2366550a --- /dev/null +++ b/apps/web/src/app/changelog/components/copy-markdown-button.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { useState } from "react"; +import { CheckIcon, ClipboardIcon } from "lucide-react"; +import { getSectionTitle, groupAndOrderChanges } from "../utils"; +import type { Change } from "../utils"; +import { cn } from "@/utils/ui"; +import { Button } from "@/components/ui/button"; + +function buildMarkdown({ + description, + changes, +}: { + description?: string; + changes: Change[]; +}): string { + const lines: string[] = []; + + if (description) { + lines.push(description, ""); + } + + const { grouped, orderedTypes } = groupAndOrderChanges({ changes }); + + for (const type of orderedTypes) { + lines.push(`## ${getSectionTitle(type)}`); + for (const change of grouped[type]) { + lines.push(`- ${change.text}`); + } + lines.push(""); + } + + return lines.join("\n").trimEnd(); +} + +export function CopyMarkdownButton({ + description, + changes, +}: { + description?: string; + changes: Change[]; +}) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + const markdown = buildMarkdown({ description, changes }); + await navigator.clipboard.writeText(markdown); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( + <Button + size="sm" + variant="text" + onClick={handleCopy} + className={cn("flex items-center gap-1.5", copied && "pointer-events-none")} + title="Copy as markdown" + > + {copied ? ( + <CheckIcon className="size-4" /> + ) : ( + <ClipboardIcon className="size-4" /> + )} + {copied ? "Copied!" : "Copy markdown"} + </Button> + ); +} diff --git a/apps/web/src/app/changelog/components/release.tsx b/apps/web/src/app/changelog/components/release.tsx new file mode 100644 index 00000000..eb04c1f0 --- /dev/null +++ b/apps/web/src/app/changelog/components/release.tsx @@ -0,0 +1,106 @@ +import type { ReactNode } from "react"; +import Link from "next/link"; +import { cn } from "@/utils/ui"; +import { getSectionTitle, groupAndOrderChanges } from "../utils"; +import type { Release } from "../utils"; + +export function ReleaseArticle({ + variant, + isLatest, + children, +}: { + variant: "list" | "detail"; + isLatest?: boolean; + children: ReactNode; +}) { + if (variant === "list") { + return ( + <article className="relative sm:pl-10"> + <div aria-hidden className="absolute left-0 top-[3px] hidden sm:block"> + <div + className={cn( + "size-[11px] rounded-full border-[1.5px]", + isLatest + ? "border-foreground bg-foreground" + : "border-muted-foreground/30 bg-background", + )} + /> + </div> + <div className="flex flex-col gap-5">{children}</div> + </article> + ); + } + + return <article className="flex flex-col gap-8">{children}</article>; +} + +export function ReleaseMeta({ release }: { release: Release }) { + return ( + <span className="text-sm font-medium tracking-widest text-muted-foreground"> + {release.version} — {release.date} + </span> + ); +} + +const titleSizes: Record<"h1" | "h2", string> = { + h1: "text-4xl", + h2: "text-2xl", +}; + +export function ReleaseTitle({ + as: As, + href, + children, +}: { + as: "h1" | "h2"; + href?: string; + children: ReactNode; +}) { + return ( + <As className={cn("font-bold tracking-tight", titleSizes[As])}> + {href ? ( + <Link href={href} className="hover:underline underline-offset-4"> + {children} + </Link> + ) : ( + children + )} + </As> + ); +} + +export function ReleaseDescription({ children }: { children: ReactNode }) { + return ( + <p className="text-base text-foreground leading-relaxed max-w-xl"> + {children} + </p> + ); +} + +export function ReleaseChanges({ release }: { release: Release }) { + const { grouped, orderedTypes } = groupAndOrderChanges({ + changes: release.changes, + }); + + return ( + <div className="flex flex-col gap-4"> + {orderedTypes.map((type) => ( + <div key={type} className="flex flex-col gap-1.5"> + <h3 className="text-base font-semibold text-foreground"> + {getSectionTitle(type)}: + </h3> + <ul className="list-disc pl-5 space-y-1.5"> + {grouped[type].map((change) => ( + <li + key={change.text} + className="text-base text-foreground leading-relaxed" + > + {change.text} + </li> + ))} + </ul> + </div> + ))} + </div> + ); +} diff --git a/apps/web/src/app/changelog/page.tsx b/apps/web/src/app/changelog/page.tsx new file mode 100644 index 00000000..8cb585b6 --- /dev/null +++ b/apps/web/src/app/changelog/page.tsx @@ -0,0 +1,66 @@ +import type { Metadata } from "next"; +import { BasePage } from "@/app/base-page"; +import { Separator } from "@/components/ui/separator"; +import { type Release as ReleaseType, getSortedReleases } from "./utils"; +import { + ReleaseArticle, + ReleaseMeta, + ReleaseTitle, + ReleaseDescription, + ReleaseChanges, +} from "./components/release"; + +export const metadata: Metadata = { + title: "Changelog - OpenCut", + description: "Every update, improvement, and fix to OpenCut — documented.", + openGraph: { + title: "Changelog - OpenCut", + description: "Every update, improvement, and fix to OpenCut — documented.", + type: "website", + }, +}; + +export default function ChangelogPage() { + const releases = getSortedReleases(); + + return ( + <BasePage title="Changelog" description="See what's new in OpenCut"> + <div className="mx-auto w-full max-w-3xl"> + <div className="relative"> + <div + aria-hidden + className="absolute top-2 bottom-0 left-[5px] w-px bg-border hidden sm:block" + /> + + <div className="flex flex-col"> + {releases.map((release, releaseIndex) => ( + <div key={release.version} className="flex flex-col"> + <ReleaseEntry release={release} /> + {releaseIndex < releases.length - 1 && ( + <Separator className="my-10 sm:ml-1.5" /> + )} + </div> + ))} + </div> + </div> + </div> + </BasePage> + ); +} + +function ReleaseEntry({ release }: { release: ReleaseType }) { + return ( + <ReleaseArticle variant="list" isLatest={release.isLatest}> + <ReleaseMeta release={release} /> + <div className="flex flex-col gap-4"> + <ReleaseTitle as="h2" href={`/changelog/${release.version}`}> + {release.title} + </ReleaseTitle> + {release.description && ( + <ReleaseDescription>{release.description}</ReleaseDescription> + )} + </div> + <ReleaseChanges release={release} /> + </ReleaseArticle> + ); +} diff --git a/apps/web/src/app/changelog/utils.ts b/apps/web/src/app/changelog/utils.ts new file mode 100644 index 00000000..163207c0 --- /dev/null +++ b/apps/web/src/app/changelog/utils.ts @@ -0,0 +1,43 @@ +import { allChangelogs } from "content-collections"; + +export type Change = { type: string; text: string }; +export type Release = (typeof allChangelogs)[number]; + +const knownSectionOrder = ["new", "improved", "fixed", "breaking"]; + +const knownSectionTitles: Record<string, string> = { + new: "Features", + improved: "Improvements", + fixed: "Fixes", + breaking: "Breaking Changes", +}; + +export function getSectionTitle(type: string): string { + return ( + knownSectionTitles[type] ?? type.charAt(0).toUpperCase() + type.slice(1) + ); +} + +export function groupAndOrderChanges({ changes }: { changes: Change[] }) { + const grouped = changes.reduce<Record<string, Change[]>>((acc, change) => { + if (!acc[change.type]) acc[change.type] = []; + acc[change.type].push(change); + return acc; + }, {}); + + const customTypes = Object.keys(grouped).filter( + (type) => !knownSectionOrder.includes(type), + ); + const orderedTypes = [ + ...knownSectionOrder.filter((type) => grouped[type]?.length > 0), + ...customTypes, + ]; + + return { grouped, orderedTypes }; +} + +export function getSortedReleases() { + return [...allChangelogs].sort((a, b) => + b.version.localeCompare(a.version, undefined, { numeric: true }), + ); +} diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx index 987af7c6..3ec6ddb8 100644 --- a/apps/web/src/app/editor/[project_id]/page.tsx +++ b/apps/web/src/app/editor/[project_id]/page.tsx @@ -16,22 +16,25 @@ import { Onboarding } from "@/components/editor/onboarding"; import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog"; import { usePanelStore } from "@/stores/panel-store"; import { usePasteMedia } from "@/hooks/use-paste-media"; +import { MobileGate } from "@/components/editor/mobile-gate"; export default function Editor() { const params = useParams(); const projectId = params.project_id as string; return ( - <EditorProvider projectId={projectId}> - <div className="bg-background flex h-screen w-screen flex-col overflow-hidden"> - <EditorHeader /> - <div className="min-h-0 min-w-0 flex-1"> - <EditorLayout /> + <MobileGate> + <EditorProvider projectId={projectId}> + <div className="bg-background flex h-screen w-screen flex-col overflow-hidden"> + <EditorHeader /> + <div className="min-h-0 min-w-0 flex-1"> + <EditorLayout /> + </div> + <Onboarding /> + <MigrationDialog /> </div> - <Onboarding /> - <MigrationDialog /> - </div> - </EditorProvider> + </EditorProvider> + </MobileGate> ); } diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 2d051c93..6d3e984f 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -60,9 +60,7 @@ --muted-foreground: hsl(0 0% 48%); --accent: hsl(0, 0%, 93%); --accent-foreground: hsl(0 0% 5%); - --destructive: hsl(0, 83%, 50%); --destructive-foreground: hsl(0, 0%, 98%); - --constructive: hsl(141, 71%, 48%); --constructive-foreground: hsl(0, 0%, 98%); --border: hsl(0 0% 89%); --input: hsl(0 0% 93%); @@ -83,7 +81,6 @@ --muted: hsl(0 0% 20%); --accent: hsl(0, 0%, 14%); --accent-foreground: hsl(0 0% 95%); - --constructive: hsl(141, 71%, 52%); --border: hsl(0 0% 16%); --input: hsl(0 0% 5%); --ring: hsl(0, 0%, 50%); @@ -103,13 +100,12 @@ --foreground: hsl(0 0% 85%); --card: hsl(0, 0%, 10%); --card-foreground: hsl(0 0% 85%); - --secondary: hsl(204, 100%, 12%); - --secondary-border: hsl(204, 100%, 17%); + --secondary: hsl(204, 67%, 9%); + --secondary-border: hsl(204, 100%, 14%); --secondary-foreground: hsl(200, 98%, 63%); --muted: hsl(0 0% 22%); --accent: hsl(0, 0%, 15%); --accent-foreground: hsl(0 0% 93%); - --constructive: hsl(141, 71%, 52%); --border: hsl(0 0% 18%); --input: hsl(0 0% 22%); --ring: hsl(0, 0%, 52%); diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 2b3d0c30..c2608bd9 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -28,6 +28,13 @@ export default function RootLayout({ <html lang="en" suppressHydrationWarning> <head> <BotIdClient protect={protectedRoutes} /> + {process.env.NODE_ENV === "development" && ( + <Script + src="//unpkg.com/react-scan/dist/auto.global.js" + crossOrigin="anonymous" + strategy="beforeInteractive" + /> + )} </head> <body className={`${siteFont.className} font-sans antialiased`}> <ThemeProvider diff --git a/apps/web/src/app/roadmap/page.tsx b/apps/web/src/app/roadmap/page.tsx index 77d9a0cc..59c36d78 100644 --- a/apps/web/src/app/roadmap/page.tsx +++ b/apps/web/src/app/roadmap/page.tsx @@ -112,7 +112,7 @@ export default function RoadmapPage() { function RoadmapItem({ item, index }: { item: RoadmapItem; index: number }) { return ( <div className="flex flex-col gap-2"> - <div className="flex items-center gap-2 text-lg font-medium"> + <div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-lg font-medium"> <span className="leading-normal select-none">{index + 1}</span> <h3>{item.title}</h3> <StatusBadge status={item.status} className="ml-1" /> diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx index bc44e650..ddf8308d 100644 --- a/apps/web/src/components/editor/export-button.tsx +++ b/apps/web/src/components/editor/export-button.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useRef } from "react"; +import { useState } from "react"; import { TransitionTopIcon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { @@ -14,35 +14,47 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Progress } from "@/components/ui/progress"; import { Checkbox } from "@/components/ui/checkbox"; import { cn } from "@/utils/ui"; -import { getExportMimeType, getExportFileExtension } from "@/lib/export"; +import { getExportMimeType, getExportFileExtension, downloadBuffer } from "@/lib/export"; import { Check, Copy, Download, RotateCcw } from "lucide-react"; import { EXPORT_FORMAT_VALUES, EXPORT_QUALITY_VALUES, type ExportFormat, type ExportQuality, - type ExportResult, } from "@/types/export"; import { Section, SectionContent, SectionHeader, + SectionTitle, } from "@/components/editor/panels/properties/section"; import { useEditor } from "@/hooks/use-editor"; import { DEFAULT_EXPORT_OPTIONS } from "@/constants/export-constants"; +function isExportFormat(value: string): value is ExportFormat { + return EXPORT_FORMAT_VALUES.some((formatValue) => formatValue === value); +} + +function isExportQuality(value: string): value is ExportQuality { + return EXPORT_QUALITY_VALUES.some((qualityValue) => qualityValue === value); +} + export function ExportButton() { const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false); const editor = useEditor(); - const handleExport = () => { - setIsExportPopoverOpen(true); - }; + const hasProject = !!editor.project.getActiveOrNull(); - const hasProject = !!editor.project.getActive(); + const handlePopoverOpenChange = ({ open }: { open: boolean }) => { + if (!open) { + editor.project.cancelExport(); + editor.project.clearExportState(); + } + setIsExportPopoverOpen(open); + }; return ( - <Popover open={isExportPopoverOpen} onOpenChange={setIsExportPopoverOpen}> + <Popover open={isExportPopoverOpen} onOpenChange={(open) => handlePopoverOpenChange({ open })}> <PopoverTrigger asChild> <button type="button" @@ -50,12 +62,12 @@ export function ExportButton() { "flex items-center gap-1.5 rounded-md bg-[#38BDF8] px-[0.12rem] py-[0.12rem] text-white", hasProject ? "cursor-pointer" : "cursor-not-allowed opacity-50", )} - onClick={hasProject ? handleExport : undefined} + onClick={hasProject ? () => setIsExportPopoverOpen(true) : undefined} disabled={!hasProject} onKeyDown={(event) => { if (hasProject && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); - handleExport(); + setIsExportPopoverOpen(true); } }} > @@ -80,71 +92,49 @@ function ExportPopover({ }) { const editor = useEditor(); const activeProject = editor.project.getActive(); + const { isExporting, progress, result: exportResult } = + editor.project.getExportState(); const [format, setFormat] = useState<ExportFormat>( DEFAULT_EXPORT_OPTIONS.format, ); const [quality, setQuality] = useState<ExportQuality>( DEFAULT_EXPORT_OPTIONS.quality, ); - const [includeAudio, setIncludeAudio] = useState<boolean>( - DEFAULT_EXPORT_OPTIONS.includeAudio || true, + const [shouldIncludeAudio, setShouldIncludeAudio] = useState<boolean>( + DEFAULT_EXPORT_OPTIONS.includeAudio ?? true, ); - const [isExporting, setIsExporting] = useState(false); - const [progress, setProgress] = useState(0); - const [exportResult, setExportResult] = useState<ExportResult | null>(null); - const cancelRequestedRef = useRef(false); const handleExport = async () => { if (!activeProject) return; - cancelRequestedRef.current = false; - setIsExporting(true); - setProgress(0); - setExportResult(null); - const result = await editor.project.export({ options: { - format, - quality, - fps: activeProject.settings.fps, - includeAudio, - onProgress: ({ progress }) => setProgress(progress), - onCancel: () => cancelRequestedRef.current, + format, + quality, + fps: activeProject.settings.fps, + includeAudio: shouldIncludeAudio, }, }); - setIsExporting(false); - if (result.cancelled) { - setExportResult(null); - setProgress(0); + editor.project.clearExportState(); return; } - setExportResult(result); - if (result.success && result.buffer) { - const mimeType = getExportMimeType({ format }); - const extension = getExportFileExtension({ format }); - const blob = new Blob([result.buffer], { type: mimeType }); - const url = URL.createObjectURL(blob); - - const a = document.createElement("a"); - a.href = url; - a.download = `${activeProject.metadata.name}${extension}`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); + downloadBuffer({ + buffer: result.buffer, + filename: `${activeProject.metadata.name}${getExportFileExtension({ format })}`, + mimeType: getExportMimeType({ format }), + }); + editor.project.clearExportState(); onOpenChange(false); - setExportResult(null); - setProgress(0); } }; const handleCancel = () => { - cancelRequestedRef.current = true; + editor.project.cancelExport(); }; return ( @@ -166,8 +156,10 @@ function ExportPopover({ {!isExporting && ( <> <div className="flex flex-col"> - <Section collapsible defaultOpen={false} hasBorderTop={false}> - <SectionHeader title="Format" /> + <Section collapsible defaultOpen={false} showTopBorder={false}> + <SectionHeader> + <SectionTitle>Format</SectionTitle> + </SectionHeader> <SectionContent> <RadioGroup value={format} @@ -194,7 +186,9 @@ function ExportPopover({ </Section> <Section collapsible defaultOpen={false}> - <SectionHeader title="Quality" /> + <SectionHeader> + <SectionTitle>Quality</SectionTitle> + </SectionHeader> <SectionContent> <RadioGroup value={quality} @@ -219,7 +213,7 @@ function ExportPopover({ <div className="flex items-center space-x-2"> <RadioGroupItem value="very_high" id="very_high" /> <Label htmlFor="very_high"> - Very High - Largest file size + Very high - Largest file size </Label> </div> </RadioGroup> @@ -227,15 +221,17 @@ function ExportPopover({ </Section> <Section collapsible defaultOpen={false}> - <SectionHeader title="Audio" /> + <SectionHeader> + <SectionTitle>Audio</SectionTitle> + </SectionHeader> <SectionContent> <div className="flex items-center space-x-2"> <Checkbox id="include-audio" - checked={includeAudio} - onCheckedChange={(checked) => - setIncludeAudio(!!checked) - } + checked={shouldIncludeAudio} + onCheckedChange={(checked) => + setShouldIncludeAudio(!!checked) + } /> <Label htmlFor="include-audio"> Include audio in export @@ -256,15 +252,15 @@ function ExportPopover({ {isExporting && ( <div className="space-y-4 p-3"> - <div className="flex flex-col"> - <div className="flex items-center justify-between text-center"> - <p className="text-muted-foreground mb-2 text-sm"> - {Math.round(progress * 100)}% - </p> - <p className="text-muted-foreground mb-2 text-sm">100%</p> - </div> - <Progress value={progress * 100} className="w-full" /> + <div className="flex flex-col gap-2"> + <div className="flex items-center justify-between text-center"> + <p className="text-muted-foreground text-sm"> + {Math.round(progress * 100)}% + </p> + <p className="text-muted-foreground text-sm">100%</p> </div> + <Progress value={progress * 100} className="w-full" /> + </div> <Button variant="outline" @@ -282,14 +278,6 @@ function ExportPopover({ ); } -function isExportFormat(value: string): value is ExportFormat { - return EXPORT_FORMAT_VALUES.some((formatValue) => formatValue === value); -} - -function isExportQuality(value: string): value is ExportQuality { - return EXPORT_QUALITY_VALUES.some((qualityValue) => qualityValue === value); -} - function ExportError({ error, onRetry, @@ -306,7 +294,7 @@ function ExportError({ }; return ( - <div className="space-y-4"> + <div className="space-y-4 p-3"> <div className="flex flex-col gap-1.5"> <p className="text-destructive text-sm font-medium">Export failed</p> <p className="text-muted-foreground text-xs">{error}</p> diff --git a/apps/web/src/components/editor/mobile-gate.tsx b/apps/web/src/components/editor/mobile-gate.tsx new file mode 100644 index 00000000..685ce178 --- /dev/null +++ b/apps/web/src/components/editor/mobile-gate.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { Button } from "../ui/button"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { ArrowLeft01Icon, ArrowRight01Icon } from "@hugeicons/core-free-icons"; +import { useRouter } from "next/navigation"; + +const STORAGE_KEY = "mobile-acknowledged"; + +interface MobileGateProps { + children: React.ReactNode; +} + +export function MobileGate({ children }: MobileGateProps) { + const router = useRouter(); + const [show, setShow] = useState<boolean | null>(null); + + useEffect(() => { + const isMobile = window.innerWidth < 1024; + const acknowledged = localStorage.getItem(STORAGE_KEY) === "true"; + setShow(isMobile && !acknowledged); + }, []); + + if (show === null) return null; + if (!show) return <>{children}</>; + + const handleContinue = () => { + localStorage.setItem(STORAGE_KEY, "true"); + setShow(false); + }; + + const handleGoBack = () => { + router.back(); + }; + + return ( + <div className="dark bg-background relative flex h-screen w-screen flex-col overflow-hidden"> + <Button + variant="text" + className="absolute top-6 left-6 flex items-center gap-1 text-muted-foreground" + onClick={handleGoBack} + > + <HugeiconsIcon icon={ArrowLeft01Icon} className="size-4" /> + <span className=" text-sm">Go back</span> + </Button> + + <div className="flex flex-1 flex-col justify-center gap-5 px-7"> + <div className="flex flex-col gap-3"> + <h1 className="text-foreground text-3xl font-bold tracking-tight"> + Desktop only (for now) + </h1> + <p className="text-muted-foreground text-sm leading-relaxed"> + OpenCut isn't optimized for mobile or iPad yet. Things will break + and the layout will be a mess. Come back on a desktop for the real + experience. + </p> + </div> + <div className="flex items-center gap-3"> + <Button variant="foreground" onClick={handleContinue}> + Take a look anyway + </Button> + <Button variant="ghost" asChild> + <Link href="/roadmap" className="flex items-center gap-1"> + Roadmap + <HugeiconsIcon icon={ArrowRight01Icon} size={14} /> + </Link> + </Button> + </div> + </div> + </div> + ); +} diff --git a/apps/web/src/components/editor/panels/assets/index.tsx b/apps/web/src/components/editor/panels/assets/index.tsx index 3600d00b..fe7db329 100644 --- a/apps/web/src/components/editor/panels/assets/index.tsx +++ b/apps/web/src/components/editor/panels/assets/index.tsx @@ -9,6 +9,7 @@ import { SettingsView } from "./views/settings"; import { SoundsView } from "./views/sounds"; import { StickersView } from "./views/stickers"; import { TextView } from "./views/text"; +import { EffectsView } from "./views/effects"; export function AssetsPanel() { const { activeTab } = useAssetsPanelStore(); @@ -18,11 +19,7 @@ export function AssetsPanel() { sounds: <SoundsView />, text: <TextView />, stickers: <StickersView />, - effects: ( - <div className="text-muted-foreground p-4"> - Effects view coming soon... - </div> - ), + effects: <EffectsView />, transitions: ( <div className="text-muted-foreground p-4"> Transitions view coming soon... diff --git a/apps/web/src/components/editor/panels/assets/views/assets.tsx b/apps/web/src/components/editor/panels/assets/views/assets.tsx index 808961f1..4e7ce5f6 100644 --- a/apps/web/src/components/editor/panels/assets/views/assets.tsx +++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx @@ -31,7 +31,12 @@ import { useFileUpload } from "@/hooks/use-file-upload"; import { useRevealItem } from "@/hooks/use-reveal-item"; import { processMediaAssets } from "@/lib/media/processing"; import { buildElementFromMedia } from "@/lib/timeline/element-utils"; -import { useAssetsPanelStore } from "@/stores/assets-panel-store"; +import { + type MediaSortKey, + type MediaSortOrder, + type MediaViewMode, + useAssetsPanelStore, +} from "@/stores/assets-panel-store"; import type { MediaAsset } from "@/types/assets"; import { cn } from "@/utils/ui"; import { @@ -50,8 +55,15 @@ export function MediaView() { const mediaFiles = editor.media.getAssets(); const activeProject = editor.project.getActive(); - const { mediaViewMode, setMediaViewMode, highlightMediaId, clearHighlight } = - useAssetsPanelStore(); + const { + mediaViewMode, + setMediaViewMode, + highlightMediaId, + clearHighlight, + mediaSortBy, + mediaSortOrder, + setMediaSort, + } = useAssetsPanelStore(); const { highlightedId, registerElement } = useRevealItem( highlightMediaId, clearHighlight, @@ -59,10 +71,6 @@ export function MediaView() { const [isProcessing, setIsProcessing] = useState(false); const [progress, setProgress] = useState(0); - const [sortBy, setSortBy] = useState<"name" | "type" | "duration" | "size">( - "name", - ); - const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); const processFiles = async ({ files }: { files: FileList }) => { if (!files || files.length === 0) return; @@ -121,27 +129,12 @@ export function MediaView() { }); }; - const addElementAtTime = ({ - asset, - startTime, - }: { - asset: MediaAsset; - startTime: number; - }): boolean => { - const duration = - asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; - const element = buildElementFromMedia({ - mediaId: asset.id, - mediaType: asset.type, - name: asset.name, - duration, - startTime, - }); - editor.timeline.insertElement({ - element, - placement: { mode: "auto" }, - }); - return true; + const handleSort = ({ key }: { key: MediaSortKey }) => { + if (mediaSortBy === key) { + setMediaSort(key, mediaSortOrder === "asc" ? "desc" : "asc"); + } else { + setMediaSort(key, "asc"); + } }; const filteredMediaItems = useMemo(() => { @@ -151,7 +144,7 @@ export function MediaView() { let valueA: string | number; let valueB: string | number; - switch (sortBy) { + switch (mediaSortBy) { case "name": valueA = a.name.toLowerCase(); valueB = b.name.toLowerCase(); @@ -172,154 +165,13 @@ export function MediaView() { return 0; } - if (valueA < valueB) return sortOrder === "asc" ? -1 : 1; - if (valueA > valueB) return sortOrder === "asc" ? 1 : -1; + if (valueA < valueB) return mediaSortOrder === "asc" ? -1 : 1; + if (valueA > valueB) return mediaSortOrder === "asc" ? 1 : -1; return 0; }); return filtered; - }, [mediaFiles, sortBy, sortOrder]); - - const previewComponents = useMemo(() => { - const previews = new Map<string, React.ReactNode>(); - - filteredMediaItems.forEach((item) => { - previews.set(item.id, <MediaPreview item={item} />); - previews.set( - `compact-${item.id}`, - <MediaPreview item={item} variant="compact" />, - ); - }); - - return previews; - }, [filteredMediaItems]); - - const renderPreview = (item: MediaAsset) => previewComponents.get(item.id); - const renderCompactPreview = (item: MediaAsset) => - previewComponents.get(`compact-${item.id}`); - - const mediaActions = ( - <div> - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> - <Button - size="icon" - variant="ghost" - onClick={() => - setMediaViewMode(mediaViewMode === "grid" ? "list" : "grid") - } - disabled={isProcessing} - className="items-center justify-center" - > - {mediaViewMode === "grid" ? ( - <HugeiconsIcon icon={LeftToRightListDashIcon} /> - ) : ( - <HugeiconsIcon icon={GridViewIcon} /> - )} - </Button> - </TooltipTrigger> - <TooltipContent> - <p> - {mediaViewMode === "grid" - ? "Switch to list view" - : "Switch to grid view"} - </p> - </TooltipContent> - <Tooltip> - <DropdownMenu> - <TooltipTrigger asChild> - <DropdownMenuTrigger asChild> - <Button - size="icon" - variant="ghost" - disabled={isProcessing} - className="items-center justify-center" - > - <HugeiconsIcon icon={SortingOneNineIcon} /> - </Button> - </DropdownMenuTrigger> - </TooltipTrigger> - <DropdownMenuContent align="end"> - <SortMenuItem - label="Name" - sortKey="name" - currentSortBy={sortBy} - currentSortOrder={sortOrder} - onSort={({ key }) => { - if (sortBy === key) { - setSortOrder(sortOrder === "asc" ? "desc" : "asc"); - } else { - setSortBy(key); - setSortOrder("asc"); - } - }} - /> - <SortMenuItem - label="Type" - sortKey="type" - currentSortBy={sortBy} - currentSortOrder={sortOrder} - onSort={({ key }) => { - if (sortBy === key) { - setSortOrder(sortOrder === "asc" ? "desc" : "asc"); - } else { - setSortBy(key); - setSortOrder("asc"); - } - }} - /> - <SortMenuItem - label="Duration" - sortKey="duration" - currentSortBy={sortBy} - currentSortOrder={sortOrder} - onSort={({ key }) => { - if (sortBy === key) { - setSortOrder(sortOrder === "asc" ? "desc" : "asc"); - } else { - setSortBy(key); - setSortOrder("asc"); - } - }} - /> - <SortMenuItem - label="File size" - sortKey="size" - currentSortBy={sortBy} - currentSortOrder={sortOrder} - onSort={({ key }) => { - if (sortBy === key) { - setSortOrder(sortOrder === "asc" ? "desc" : "asc"); - } else { - setSortBy(key); - setSortOrder("asc"); - } - }} - /> - </DropdownMenuContent> - </DropdownMenu> - <TooltipContent> - <p> - Sort by {sortBy} ( - {sortOrder === "asc" ? "ascending" : "descending"}) - </p> - </TooltipContent> - </Tooltip> - </Tooltip> - </TooltipProvider> - <Button - variant="outline" - onClick={openFilePicker} - disabled={isProcessing} - size="sm" - className="items-center justify-center gap-1.5 ml-1.5" - > - <HugeiconsIcon icon={CloudUploadIcon} /> - Import - </Button> - </div> - ); + }, [mediaFiles, mediaSortBy, mediaSortOrder]); return ( <> @@ -327,8 +179,18 @@ export function MediaView() { <PanelView title="Assets" - actions={mediaActions} - className={isDragOver ? "bg-accent/30" : ""} + actions={ + <MediaActions + mediaViewMode={mediaViewMode} + setMediaViewMode={setMediaViewMode} + isProcessing={isProcessing} + sortBy={mediaSortBy} + sortOrder={mediaSortOrder} + onSort={handleSort} + onImport={openFilePicker} + /> + } + className={cn(isDragOver && "bg-accent/30")} {...dragProps} > {isDragOver || filteredMediaItems.length === 0 ? ( @@ -338,21 +200,11 @@ export function MediaView() { progress={progress} onClick={openFilePicker} /> - ) : mediaViewMode === "grid" ? ( - <GridView - items={filteredMediaItems} - renderPreview={renderPreview} - onRemove={handleRemove} - onAddToTimeline={addElementAtTime} - highlightedId={highlightedId} - registerElement={registerElement} - /> ) : ( - <ListView + <MediaItemList items={filteredMediaItems} - renderPreview={renderCompactPreview} + mode={mediaViewMode} onRemove={handleRemove} - onAddToTimeline={addElementAtTime} highlightedId={highlightedId} registerElement={registerElement} /> @@ -362,6 +214,67 @@ export function MediaView() { ); } +function MediaAssetDraggable({ + item, + preview, + isHighlighted, + variant, + isRounded, +}: { + item: MediaAsset; + preview: React.ReactNode; + isHighlighted: boolean; + variant: "card" | "compact"; + isRounded?: boolean; +}) { + const editor = useEditor(); + + const addElementAtTime = ({ + asset, + startTime, + }: { + asset: MediaAsset; + startTime: number; + }) => { + const duration = + asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; + const element = buildElementFromMedia({ + mediaId: asset.id, + mediaType: asset.type, + name: asset.name, + duration, + startTime, + }); + editor.timeline.insertElement({ + element, + placement: { mode: "auto" }, + }); + }; + + return ( + <DraggableItem + name={item.name} + preview={preview} + dragData={{ + id: item.id, + type: "media", + mediaType: item.type, + name: item.name, + ...(item.type !== "audio" && { + targetElementTypes: ["video", "image"] as const, + }), + }} + shouldShowPlusOnDrag={false} + onAddToTimeline={({ currentTime }) => + addElementAtTime({ asset: item, startTime: currentTime }) + } + variant={variant} + isRounded={isRounded} + isHighlighted={isHighlighted} + /> + ); +} + function MediaItemWithContextMenu({ item, children, @@ -387,101 +300,41 @@ function MediaItemWithContextMenu({ ); } -function GridView({ +function MediaItemList({ items, - renderPreview, + mode, onRemove, - onAddToTimeline, highlightedId, registerElement, }: { items: MediaAsset[]; - renderPreview: (item: MediaAsset) => React.ReactNode; + mode: MediaViewMode; onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void; - onAddToTimeline: ({ - asset, - startTime, - }: { - asset: MediaAsset; - startTime: number; - }) => boolean; highlightedId: string | null; registerElement: (id: string, element: HTMLElement | null) => void; }) { + const isGrid = mode === "grid"; + return ( <div - className="grid gap-2" - style={{ - gridTemplateColumns: "repeat(auto-fill, 160px)", - }} + className={cn(isGrid ? "grid gap-2" : "flex flex-col gap-1")} + style={ + isGrid ? { gridTemplateColumns: "repeat(auto-fill, 160px)" } : undefined + } > {items.map((item) => ( - <div key={item.id} ref={(el) => registerElement(item.id, el)}> + <div key={item.id} ref={(element) => registerElement(item.id, element)}> <MediaItemWithContextMenu item={item} onRemove={onRemove}> - <DraggableItem - name={item.name} - preview={renderPreview(item)} - dragData={{ - id: item.id, - type: "media", - mediaType: item.type, - name: item.name, - }} - shouldShowPlusOnDrag={false} - onAddToTimeline={({ currentTime }) => - onAddToTimeline({ asset: item, startTime: currentTime }) - } - isRounded={false} - variant="card" - isHighlighted={highlightedId === item.id} - /> - </MediaItemWithContextMenu> - </div> - ))} - </div> - ); -} - -function ListView({ - items, - renderPreview, - onRemove, - onAddToTimeline, - highlightedId, - registerElement, -}: { - items: MediaAsset[]; - renderPreview: (item: MediaAsset) => React.ReactNode; - onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void; - onAddToTimeline: ({ - asset, - startTime, - }: { - asset: MediaAsset; - startTime: number; - }) => boolean; - highlightedId: string | null; - registerElement: (id: string, element: HTMLElement | null) => void; -}) { - return ( - <div className="space-y-1"> - {items.map((item) => ( - <div key={item.id} ref={(el) => registerElement(item.id, el)}> - <MediaItemWithContextMenu item={item} onRemove={onRemove}> - <DraggableItem - name={item.name} - preview={renderPreview(item)} - dragData={{ - id: item.id, - type: "media", - mediaType: item.type, - name: item.name, - }} - shouldShowPlusOnDrag={false} - onAddToTimeline={({ currentTime }) => - onAddToTimeline({ asset: item, startTime: currentTime }) + <MediaAssetDraggable + item={item} + preview={ + <MediaPreview + item={item} + variant={isGrid ? "grid" : "compact"} + /> } - variant="compact" + variant={isGrid ? "card" : "compact"} + isRounded={isGrid ? false : undefined} isHighlighted={highlightedId === item.id} /> </MediaItemWithContextMenu> @@ -491,11 +344,11 @@ function ListView({ ); } -const formatDuration = ({ duration }: { duration: number }) => { +export function formatDuration({ duration }: { duration: number }) { const min = Math.floor(duration / 60); const sec = Math.floor(duration % 60); return `${min}:${sec.toString().padStart(2, "0")}`; -}; +} function MediaDurationBadge({ duration }: { duration?: number }) { if (!duration) return null; @@ -613,6 +466,119 @@ function MediaPreview({ ); } +function MediaActions({ + mediaViewMode, + setMediaViewMode, + isProcessing, + sortBy, + sortOrder, + onSort, + onImport, +}: { + mediaViewMode: MediaViewMode; + setMediaViewMode: (mode: MediaViewMode) => void; + isProcessing: boolean; + sortBy: MediaSortKey; + sortOrder: MediaSortOrder; + onSort: ({ key }: { key: MediaSortKey }) => void; + onImport: () => void; +}) { + return ( + <div className="flex gap-1.5"> + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + size="icon" + variant="ghost" + onClick={() => + setMediaViewMode(mediaViewMode === "grid" ? "list" : "grid") + } + disabled={isProcessing} + className="items-center justify-center" + > + {mediaViewMode === "grid" ? ( + <HugeiconsIcon icon={LeftToRightListDashIcon} /> + ) : ( + <HugeiconsIcon icon={GridViewIcon} /> + )} + </Button> + </TooltipTrigger> + <TooltipContent> + <p> + {mediaViewMode === "grid" + ? "Switch to list view" + : "Switch to grid view"} + </p> + </TooltipContent> + </Tooltip> + <Tooltip> + <DropdownMenu> + <TooltipTrigger asChild> + <DropdownMenuTrigger asChild> + <Button + size="icon" + variant="ghost" + disabled={isProcessing} + className="items-center justify-center" + > + <HugeiconsIcon icon={SortingOneNineIcon} /> + </Button> + </DropdownMenuTrigger> + </TooltipTrigger> + <DropdownMenuContent align="end"> + <SortMenuItem + label="Name" + sortKey="name" + currentSortBy={sortBy} + currentSortOrder={sortOrder} + onSort={onSort} + /> + <SortMenuItem + label="Type" + sortKey="type" + currentSortBy={sortBy} + currentSortOrder={sortOrder} + onSort={onSort} + /> + <SortMenuItem + label="Duration" + sortKey="duration" + currentSortBy={sortBy} + currentSortOrder={sortOrder} + onSort={onSort} + /> + <SortMenuItem + label="File size" + sortKey="size" + currentSortBy={sortBy} + currentSortOrder={sortOrder} + onSort={onSort} + /> + </DropdownMenuContent> + </DropdownMenu> + <TooltipContent> + <p> + Sort by {sortBy} ( + {sortOrder === "asc" ? "ascending" : "descending"}) + </p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + <Button + variant="outline" + onClick={onImport} + disabled={isProcessing} + size="sm" + className="items-center justify-center gap-1.5" + > + <HugeiconsIcon icon={CloudUploadIcon} /> + Import + </Button> + </div> + ); +} + function SortMenuItem({ label, sortKey, @@ -621,10 +587,10 @@ function SortMenuItem({ onSort, }: { label: string; - sortKey: "name" | "type" | "duration" | "size"; - currentSortBy: string; - currentSortOrder: "asc" | "desc"; - onSort: ({ key }: { key: "name" | "type" | "duration" | "size" }) => void; + sortKey: MediaSortKey; + currentSortBy: MediaSortKey; + currentSortOrder: MediaSortOrder; + onSort: ({ key }: { key: MediaSortKey }) => void; }) { const isActive = currentSortBy === sortKey; const arrow = isActive ? (currentSortOrder === "asc" ? "↑" : "↓") : ""; diff --git a/apps/web/src/components/editor/panels/assets/views/effects.tsx b/apps/web/src/components/editor/panels/assets/views/effects.tsx new file mode 100644 index 00000000..c9b8b2dd --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/views/effects.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useEffect, useRef, useCallback } from "react"; +import { PanelView } from "@/components/editor/panels/assets/views/base-view"; +import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; +import { getAllEffects, EFFECT_TARGET_ELEMENT_TYPES } from "@/lib/effects"; +import { + effectPreviewService, + onPreviewImageReady, +} from "@/services/renderer/effect-preview"; +import { useEditor } from "@/hooks/use-editor"; +import { buildEffectElement } from "@/lib/timeline/element-utils"; +import type { EffectDefinition } from "@/types/effects"; + +export function EffectsView() { + const effects = getAllEffects(); + + return ( + <PanelView title="Effects"> + <EffectsGrid effects={effects} /> + </PanelView> + ); +} + +function EffectsGrid({ effects }: { effects: EffectDefinition[] }) { + return ( + <div + className="grid gap-2" + style={{ gridTemplateColumns: "repeat(auto-fill, minmax(96px, 1fr))" }} + > + {effects.map((effect) => ( + <EffectItem key={effect.type} effect={effect} /> + ))} + </div> + ); +} + +function EffectPreviewCanvas({ effectType }: { effectType: string }) { + const canvasRef = useRef<HTMLCanvasElement>(null); + + useEffect(() => { + const render = () => { + if (canvasRef.current) { + effectPreviewService.renderPreview({ + effectType, + params: {}, + targetCanvas: canvasRef.current, + }); + } + }; + + render(); + return onPreviewImageReady({ callback: render }); + }, [effectType]); + + return <canvas ref={canvasRef} className="size-full" />; +} + +function EffectItem({ effect }: { effect: EffectDefinition }) { + const editor = useEditor(); + + const handleAddToTimeline = useCallback(() => { + const currentTime = editor.playback.getCurrentTime(); + const element = buildEffectElement({ + effectType: effect.type, + startTime: currentTime, + }); + + editor.timeline.insertElement({ + placement: { mode: "auto", trackType: "effect" }, + element, + }); + }, [editor, effect.type]); + + const preview = <EffectPreviewCanvas effectType={effect.type} />; + + return ( + <DraggableItem + name={effect.name} + preview={preview} + dragData={{ + id: effect.type, + name: effect.name, + type: "effect", + effectType: effect.type, + targetElementTypes: EFFECT_TARGET_ELEMENT_TYPES, + }} + onAddToTimeline={handleAddToTimeline} + aspectRatio={1} + isRounded + variant="card" + containerClassName="w-full" + /> + ); +} diff --git a/apps/web/src/components/editor/panels/assets/views/settings.tsx b/apps/web/src/components/editor/panels/assets/views/settings.tsx index 7e10ac9d..d46e9d1f 100644 --- a/apps/web/src/components/editor/panels/assets/views/settings.tsx +++ b/apps/web/src/components/editor/panels/assets/views/settings.tsx @@ -16,6 +16,7 @@ import { Section, SectionContent, SectionHeader, + SectionTitle, } from "@/components/editor/panels/properties/section"; import { Label } from "@/components/ui/label"; import { @@ -23,25 +24,46 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; -import { useState } from "react"; -export function SettingsView() { - const [open, setOpen] = useState(false); +const ORIGINAL_PRESET_VALUE = "original"; +export function findPresetIndexByAspectRatio({ + presets, + targetAspectRatio, +}: { + presets: Array<{ width: number; height: number }>; + targetAspectRatio: string; +}) { + for (let index = 0; index < presets.length; index++) { + const preset = presets[index]; + const presetAspectRatio = dimensionToAspectRatio({ + width: preset.width, + height: preset.height, + }); + if (presetAspectRatio === targetAspectRatio) { + return index; + } + } + return -1; +} + +export function SettingsView() { return ( <PanelView contentClassName="px-0" hideHeader> <div className="flex flex-col"> - <Section hasBorderTop={false}> + <Section showTopBorder={false}> <SectionContent> <ProjectInfoContent /> </SectionContent> </Section> - <Popover open={open} onOpenChange={setOpen}> + <Popover> <Section className="cursor-pointer"> <PopoverTrigger asChild> <div> - <SectionHeader title="Background"> - <div className="size-4 rounded-sm bg-red-500" /> + <SectionHeader + trailing={<div className="size-4 rounded-sm bg-red-500" />} + > + <SectionTitle>Background</SectionTitle> </SectionHeader> </div> </PopoverTrigger> @@ -60,26 +82,6 @@ function ProjectInfoContent() { const activeProject = editor.project.getActive(); const { canvasPresets } = useEditorStore(); - const findPresetIndexByAspectRatio = ({ - presets, - targetAspectRatio, - }: { - presets: Array<{ width: number; height: number }>; - targetAspectRatio: string; - }) => { - for (let index = 0; index < presets.length; index++) { - const preset = presets[index]; - const presetAspectRatio = dimensionToAspectRatio({ - width: preset.width, - height: preset.height, - }); - if (presetAspectRatio === targetAspectRatio) { - return index; - } - } - return -1; - }; - const currentCanvasSize = activeProject.settings.canvasSize; const currentAspectRatio = dimensionToAspectRatio(currentCanvasSize); const originalCanvasSize = activeProject.settings.originalCanvasSize ?? null; @@ -87,12 +89,11 @@ function ProjectInfoContent() { presets: canvasPresets, targetAspectRatio: currentAspectRatio, }); - const originalPresetValue = "original"; const selectedPresetValue = - presetIndex !== -1 ? presetIndex.toString() : originalPresetValue; + presetIndex !== -1 ? presetIndex.toString() : ORIGINAL_PRESET_VALUE; const handleAspectRatioChange = ({ value }: { value: string }) => { - if (value === originalPresetValue) { + if (value === ORIGINAL_PRESET_VALUE) { const canvasSize = originalCanvasSize ?? currentCanvasSize; editor.project.updateSettings({ settings: { canvasSize }, @@ -106,7 +107,7 @@ function ProjectInfoContent() { } }; - const handleFpsChange = (value: string) => { + const handleFpsChange = ({ value }: { value: string }) => { const fps = parseFloat(value); editor.project.updateSettings({ settings: { fps } }); }; @@ -129,7 +130,7 @@ function ProjectInfoContent() { <SelectValue placeholder="Select an aspect ratio" /> </SelectTrigger> <SelectContent> - <SelectItem value={originalPresetValue}>Original</SelectItem> + <SelectItem value={ORIGINAL_PRESET_VALUE}>Original</SelectItem> {canvasPresets.map((preset, index) => { const label = dimensionToAspectRatio({ width: preset.width, @@ -148,7 +149,7 @@ function ProjectInfoContent() { <Label>Frame rate</Label> <Select value={activeProject.settings.fps.toString()} - onValueChange={handleFpsChange} + onValueChange={(value) => handleFpsChange({ value })} > <SelectTrigger className="w-fit"> <SelectValue placeholder="Select a frame rate" /> diff --git a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx index fdd4055d..26649b26 100644 --- a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx +++ b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx @@ -56,7 +56,7 @@ export function TextEditOverlay({ }, [editor.timeline, trackId, elementId]); const handleKeyDown = useCallback( - (event: React.KeyboardEvent) => { + ({ event }: { event: React.KeyboardEvent }) => { const { key } = event; if (key === "Escape") { event.preventDefault(); @@ -98,10 +98,13 @@ export function TextEditOverlay({ const fontWeight = element.fontWeight === "bold" ? "bold" : "normal"; const fontStyle = element.fontStyle === "italic" ? "italic" : "normal"; const letterSpacing = element.letterSpacing ?? 0; - const backgroundColor = - element.background.color === "transparent" - ? "transparent" - : element.background.color; + const shouldShowBackground = + element.background.enabled && + element.background.color && + element.background.color !== "transparent"; + const backgroundColor = shouldShowBackground + ? element.background.color + : "transparent"; return ( <div @@ -134,13 +137,12 @@ export function TextEditOverlay({ backgroundColor, minHeight: displayFontSize * lineHeight, textDecoration: element.textDecoration ?? "none", - padding: - backgroundColor === "transparent" ? 0 : TEXT_BACKGROUND_PADDING, + padding: shouldShowBackground ? TEXT_BACKGROUND_PADDING : 0, minWidth: 1, }} onInput={handleInput} onBlur={onCommit} - onKeyDown={handleKeyDown} + onKeyDown={(event) => handleKeyDown({ event })} > {element.content || ""} </div> diff --git a/apps/web/src/components/editor/panels/preview/transform-handles.tsx b/apps/web/src/components/editor/panels/preview/transform-handles.tsx index 976c3019..2fcf8096 100644 --- a/apps/web/src/components/editor/panels/preview/transform-handles.tsx +++ b/apps/web/src/components/editor/panels/preview/transform-handles.tsx @@ -23,7 +23,7 @@ const CORNERS: Corner[] = [ "bottom-right", ]; -function getCornerPosition({ +export function getCornerPosition({ bounds, corner, }: { @@ -47,7 +47,7 @@ function getCornerPosition({ }; } -function getRotationHandlePosition({ bounds }: { bounds: ElementBounds }): { +export function getRotationHandlePosition({ bounds }: { bounds: ElementBounds }): { x: number; y: number; } { @@ -61,7 +61,7 @@ function getRotationHandlePosition({ bounds }: { bounds: ElementBounds }): { }; } -function getOverlayContext({ +export function getOverlayContext({ canvasRef, containerRef, }: { @@ -196,7 +196,7 @@ function BoundingBoxOutline({ height: outlineHeight, transform: `rotate(${rotation}deg)`, transformOrigin: "center center", - border: "1px solid white", + border: "1px dashed white", boxSizing: "border-box", opacity: 0.75, }} diff --git a/apps/web/src/components/editor/panels/properties/audio-properties.tsx b/apps/web/src/components/editor/panels/properties/audio-properties.tsx index 0b83a4b6..2ad46497 100644 --- a/apps/web/src/components/editor/panels/properties/audio-properties.tsx +++ b/apps/web/src/components/editor/panels/properties/audio-properties.tsx @@ -1,5 +1,3 @@ -import type { AudioElement } from "@/types/timeline"; - -export function AudioProperties({ _element }: { _element: AudioElement }) { +export function AudioProperties() { return <div className="space-y-4 p-5">Audio properties</div>; } diff --git a/apps/web/src/components/editor/panels/properties/clip-effects-properties.tsx b/apps/web/src/components/editor/panels/properties/clip-effects-properties.tsx new file mode 100644 index 00000000..1caf73eb --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/clip-effects-properties.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { useState } from "react"; +import type { Effect } from "@/types/effects"; +import type { VisualElement } from "@/types/timeline"; +import { getEffect } from "@/lib/effects/registry"; +import { useEditor } from "@/hooks/use-editor"; +import { usePropertiesStore } from "@/stores/properties-store"; +import { + Section, + SectionContent, + SectionHeader, + SectionTitle, + SectionFields, +} from "./section"; +import { EffectParamField } from "./effect-param-field"; +import { Button } from "@/components/ui/button"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + ArrowLeft01Icon, + Delete02Icon, + ViewIcon, + ViewOffSlashIcon, +} from "@hugeicons/core-free-icons"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { cn } from "@/utils/ui"; +export function ClipEffectsProperties({ + element, + trackId, +}: { + element: VisualElement; + trackId: string; +}) { + const closeClipEffects = usePropertiesStore( + (state) => state.closeClipEffects, + ); + const editor = useEditor(); + const effects = element.effects ?? []; + + const [dragIndex, setDragIndex] = useState<number | null>(null); + const [dropIndex, setDropIndex] = useState<number | null>(null); + + const handleDragStart = ({ index }: { index: number }) => { + setDragIndex(index); + }; + + const handleDragOver = ({ event, index }: { event: React.DragEvent; index: number }) => { + event.preventDefault(); + if (index !== dropIndex) setDropIndex(index); + }; + + const handleDrop = ({ toIndex }: { toIndex: number }) => { + if (dragIndex !== null && dragIndex !== toIndex) { + editor.timeline.reorderClipEffects({ + trackId, + elementId: element.id, + fromIndex: dragIndex, + toIndex, + }); + } + setDragIndex(null); + setDropIndex(null); + }; + + const handleDragEnd = () => { + setDragIndex(null); + setDropIndex(null); + }; + + return ( + <div className="flex h-full flex-col"> + <div className="flex h-11 shrink-0 items-center gap-2 border-b px-1.5"> + <Button + variant="ghost" + size="icon" + onClick={closeClipEffects} + aria-label="Back to properties" + > + <HugeiconsIcon icon={ArrowLeft01Icon} /> + </Button> + <span className="text-sm font-medium">Effects</span> + </div> + <ScrollArea className="flex-1 scrollbar-hidden"> + {effects.map((effect, index) => ( + // biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop list reorder + <div + key={effect.id} + draggable + onDragStart={() => handleDragStart({ index })} + onDragOver={(event) => handleDragOver({ event, index })} + onDrop={() => handleDrop({ toIndex: index })} + onDragEnd={handleDragEnd} + className={cn( + "group", + dragIndex === index && "opacity-40", + dropIndex === index && + dragIndex !== null && + dragIndex !== index && + (index < dragIndex + ? "border-t-2 border-primary" + : "border-b-2 border-primary"), + )} + > + <ClipEffectSection + effect={effect} + element={element} + trackId={trackId} + /> + </div> + ))} + </ScrollArea> + </div> + ); +} + +function ClipEffectSection({ + effect, + element, + trackId, +}: { + effect: Effect; + element: VisualElement; + trackId: string; +}) { + const editor = useEditor(); + const definition = getEffect({ effectType: effect.type }); + + const previewParam = ({ key }: { key: string }) => (value: number | string | boolean) => { + const updatedEffects = (element.effects ?? []).map((existing) => + existing.id !== effect.id + ? existing + : { ...existing, params: { ...existing.params, [key]: value } }, + ); + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { effects: updatedEffects }, + }, + ], + }); + }; + + const commitParam = () => editor.timeline.commitPreview(); + + const toggleEffect = () => + editor.timeline.toggleClipEffect({ + trackId, + elementId: element.id, + effectId: effect.id, + }); + + const removeEffect = () => + editor.timeline.removeClipEffect({ + trackId, + elementId: element.id, + effectId: effect.id, + }); + + return ( + <Section sectionKey={`clip-effect:${effect.id}`} showTopBorder={false}> + <SectionHeader + className="cursor-move" + trailing={ + <> + <Button + variant={effect.enabled ? "secondary" : "ghost"} + size="icon" + aria-label={`Toggle ${definition.name}`} + onClick={toggleEffect} + > + <HugeiconsIcon + icon={effect.enabled ? ViewIcon : ViewOffSlashIcon} + /> + </Button> + <Button + variant="ghost" + size="icon" + aria-label={`Remove ${definition.name}`} + onClick={removeEffect} + > + <HugeiconsIcon icon={Delete02Icon} /> + </Button> + </> + } + > + <SectionTitle + className={cn(!effect.enabled && "text-muted-foreground")} + > + {definition.name} + </SectionTitle> + </SectionHeader> + {effect.enabled && ( + <SectionContent> + <SectionFields> + {definition.params.map((param) => ( + <EffectParamField + key={param.key} + param={param} + value={effect.params[param.key] ?? param.default} + onPreview={previewParam({ key: param.key })} + onCommit={commitParam} + /> + ))} + </SectionFields> + </SectionContent> + )} + </Section> + ); +} diff --git a/apps/web/src/components/editor/panels/properties/effect-param-field.tsx b/apps/web/src/components/editor/panels/properties/effect-param-field.tsx new file mode 100644 index 00000000..408d430a --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/effect-param-field.tsx @@ -0,0 +1,152 @@ +"use client"; + +import type { EffectParamDefinition, NumberEffectParamDefinition } from "@/types/effects"; +import { clamp } from "@/utils/math"; +import { SectionField } from "./section"; +import { Slider } from "@/components/ui/slider"; +import { NumberField } from "@/components/ui/number-field"; +import { Switch } from "@/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { usePropertyDraft } from "./hooks/use-property-draft"; + +export function EffectParamField({ + param, + value, + onPreview, + onCommit, +}: { + param: EffectParamDefinition; + value: number | string | boolean; + onPreview: (value: number | string | boolean) => void; + onCommit: () => void; +}) { + return ( + <SectionField label={param.label}> + <EffectParamInput param={param} value={value} onPreview={onPreview} onCommit={onCommit} /> + </SectionField> + ); +} + +function EffectParamInput({ + param, + value, + onPreview, + onCommit, +}: { + param: EffectParamDefinition; + value: number | string | boolean; + onPreview: (value: number | string | boolean) => void; + onCommit: () => void; +}) { + if (param.type === "number") { + return ( + <NumberParamField + param={param} + value={typeof value === "number" ? value : Number(value)} + onPreview={onPreview} + onCommit={onCommit} + /> + ); + } + + if (param.type === "boolean") { + return ( + <Switch + checked={Boolean(value)} + onCheckedChange={(checked) => { + onPreview(checked); + onCommit(); + }} + /> + ); + } + + if (param.type === "select") { + return ( + <Select + value={String(value)} + onValueChange={(selected) => { + onPreview(selected); + onCommit(); + }} + > + <SelectTrigger className="w-full"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {param.options.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + ); + } + + if (param.type === "color") { + return ( + <input + type="color" + className="h-8 w-full cursor-pointer rounded border" + value={String(value)} + onChange={(event) => onPreview(event.target.value)} + onBlur={onCommit} + /> + ); + } + + return null; +} + +function NumberParamField({ + param, + value, + onPreview, + onCommit, +}: { + param: NumberEffectParamDefinition; + value: number; + onPreview: (value: number) => void; + onCommit: () => void; +}) { + const { min, max, step } = param; + + const draft = usePropertyDraft({ + displayValue: String(value), + parse: (input) => { + const parsed = parseFloat(input); + if (Number.isNaN(parsed)) return null; + return clamp({ value: parsed, min, max }); + }, + onPreview, + onCommit, + }); + + return ( + <div className="flex items-center gap-3"> + <Slider + className="flex-1" + min={min} + max={max} + step={step} + value={[value]} + onValueChange={([newValue]) => onPreview(newValue)} + onValueCommit={onCommit} + /> + <NumberField + className="w-16 shrink-0" + value={draft.displayValue} + onFocus={draft.onFocus} + onChange={draft.onChange} + onBlur={draft.onBlur} + /> + </div> + ); +} diff --git a/apps/web/src/components/editor/panels/properties/effect-properties.tsx b/apps/web/src/components/editor/panels/properties/effect-properties.tsx new file mode 100644 index 00000000..75fc30a1 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/effect-properties.tsx @@ -0,0 +1,58 @@ +"use client"; + +import type { EffectElement } from "@/types/timeline"; +import { getEffect } from "@/lib/effects/registry"; +import { useEditor } from "@/hooks/use-editor"; +import { + Section, + SectionContent, + SectionHeader, + SectionFields, + SectionTitle, +} from "./section"; +import { EffectParamField } from "./effect-param-field"; + +export function EffectProperties({ + element, + trackId, +}: { + element: EffectElement; + trackId: string; +}) { + const editor = useEditor(); + const definition = getEffect({ effectType: element.effectType }); + + const previewParam = + ({ key }: { key: string }) => + (value: number | string | boolean) => + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { params: { ...element.params, [key]: value } }, + }, + ], + }); + + return ( + <Section showTopBorder={false}> + <SectionHeader> + <SectionTitle>{definition.name}</SectionTitle> + </SectionHeader> + <SectionContent> + <SectionFields> + {definition.params.map((param) => ( + <EffectParamField + key={param.key} + param={param} + value={element.params[param.key] ?? param.default} + onPreview={previewParam({ key: param.key })} + onCommit={() => editor.timeline.commitPreview()} + /> + ))} + </SectionFields> + </SectionContent> + </Section> + ); +} diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts b/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts new file mode 100644 index 00000000..46e6cb6d --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts @@ -0,0 +1,24 @@ +import { useEditor } from "@/hooks/use-editor"; +import { getElementLocalTime } from "@/lib/animation"; +import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; + +export function useElementPlayhead({ + startTime, + duration, +}: { + startTime: number; + duration: number; +}) { + const editor = useEditor(); + const playheadTime = editor.playback.getCurrentTime(); + const localTime = getElementLocalTime({ + timelineTime: playheadTime, + elementStartTime: startTime, + elementDuration: duration, + }); + const isPlayheadWithinElementRange = + playheadTime >= startTime - TIME_EPSILON_SECONDS && + playheadTime <= startTime + duration + TIME_EPSILON_SECONDS; + + return { localTime, isPlayheadWithinElementRange }; +} diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts new file mode 100644 index 00000000..dbd19e4c --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts @@ -0,0 +1,95 @@ +import { useEditor } from "@/hooks/use-editor"; +import { + getKeyframeAtTime, + hasKeyframesForPath, + upsertElementKeyframe, +} from "@/lib/animation"; +import type { AnimationPropertyPath, ElementAnimations } from "@/types/animation"; +import type { TimelineElement } from "@/types/timeline"; + +export function useKeyframedColorProperty({ + trackId, + elementId, + animations, + propertyPath, + localTime, + isPlayheadWithinElementRange, + resolvedColor, + buildBaseUpdates, +}: { + trackId: string; + elementId: string; + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; + localTime: number; + isPlayheadWithinElementRange: boolean; + resolvedColor: string; + buildBaseUpdates: ({ value }: { value: string }) => Partial<TimelineElement>; +}) { + 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 onChange = ({ color }: { color: string }) => { + if (shouldUseAnimatedChannel) { + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: { + animations: upsertElementKeyframe({ + animations, + propertyPath, + time: localTime, + value: color, + }), + }, + }, + ], + }); + return; + } + + editor.timeline.previewElements({ + updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: color }) }], + }); + }; + + const onChangeEnd = () => 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: resolvedColor }, + ], + }); + }; + + return { + isKeyframedAtTime, + hasAnimatedKeyframes, + keyframeIdAtTime, + onChange, + onChangeEnd, + toggleKeyframe, + }; +} 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..11aa049c --- /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 { TimelineElement } 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 }) => Partial<TimelineElement>; +}) { + 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/index.tsx b/apps/web/src/components/editor/panels/properties/index.tsx index 4f285f84..cb45996d 100644 --- a/apps/web/src/components/editor/panels/properties/index.tsx +++ b/apps/web/src/components/editor/panels/properties/index.tsx @@ -4,46 +4,79 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { AudioProperties } from "./audio-properties"; import { VideoProperties } from "./video-properties"; import { TextProperties } from "./text-properties"; +import { EffectProperties } from "./effect-properties"; +import { ClipEffectsProperties } from "./clip-effects-properties"; import { EmptyView } from "./empty-view"; import { useEditor } from "@/hooks/use-editor"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; +import { usePropertiesStore } from "@/stores/properties-store"; +import { isVisualElement } from "@/lib/timeline"; +import type { TimelineElement, TimelineTrack } from "@/types/timeline"; + +function ElementProperties({ + track, + element, +}: { + track: TimelineTrack; + element: TimelineElement; +}) { + if (element.type === "text") { + return <TextProperties element={element} trackId={track.id} />; + } + if (element.type === "audio") { + return <AudioProperties />; + } + if ( + element.type === "video" || + element.type === "image" || + element.type === "sticker" + ) { + return <VideoProperties element={element} trackId={track.id} />; + } + if (element.type === "effect") { + return <EffectProperties element={element} trackId={track.id} />; + } + return null; +} export function PropertiesPanel() { const editor = useEditor(); const { selectedElements } = useElementSelection(); + const clipEffectsTarget = usePropertiesStore( + (state) => state.clipEffectsTarget, + ); + + const clipEffectsTrack = clipEffectsTarget + ? editor.timeline.getTrackById({ trackId: clipEffectsTarget.trackId }) + : null; + const clipEffectsElement = clipEffectsTrack?.elements.find( + (element) => element.id === clipEffectsTarget?.elementId, + ); + const isShowingClipEffects = + clipEffectsTrack && + clipEffectsElement && + isVisualElement(clipEffectsElement); const elementsWithTracks = editor.timeline.getElementsWithTracks({ elements: selectedElements, }); return ( - <div className="panel bg-background h-full rounded-sm border border-t-0 overflow-hidden"> - {selectedElements.length > 0 ? ( + <div className="panel bg-background h-full rounded-sm border overflow-hidden"> + {isShowingClipEffects ? ( + <ClipEffectsProperties + element={clipEffectsElement} + trackId={clipEffectsTrack.id} + /> + ) : selectedElements.length > 0 ? ( <ScrollArea className="h-full scrollbar-hidden"> - {elementsWithTracks.map(({ track, element }) => { - if (element.type === "text") { - return ( - <div key={element.id}> - <TextProperties element={element} trackId={track.id} /> - </div> - ); - } - if (element.type === "audio") { - return <AudioProperties key={element.id} _element={element} />; - } - if ( - element.type === "video" || - element.type === "image" || - element.type === "sticker" - ) { - return ( - <div key={element.id}> - <VideoProperties element={element} trackId={track.id} /> - </div> - ); - } - return null; - })} + {elementsWithTracks.map(({ track, element }) => ( + <ElementProperties + key={element.id} + track={track} + element={element} + /> + ))} </ScrollArea> ) : ( <EmptyView /> 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..5989108b 100644 --- a/apps/web/src/components/editor/panels/properties/section.tsx +++ b/apps/web/src/components/editor/panels/properties/section.tsx @@ -1,10 +1,12 @@ -import { createContext, useContext, useState } from "react"; +import { createContext, useContext, useEffect, useState } from "react"; import { cn } from "@/utils/ui"; import { HugeiconsIcon } from "@hugeicons/react"; import { ArrowDownIcon } from "@hugeicons/core-free-icons"; import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; const sectionExpandedCache = new Map<string, boolean>(); +const mountedSectionKeys = new Set<string>(); interface SectionContext { isOpen: boolean; @@ -24,8 +26,8 @@ interface SectionProps { defaultOpen?: boolean; sectionKey?: string; className?: string; - hasBorderTop?: boolean; - hasBorderBottom?: boolean; + showTopBorder?: boolean; + showBottomBorder?: boolean; } export function Section({ @@ -34,12 +36,21 @@ export function Section({ defaultOpen = true, sectionKey, className, - hasBorderTop = true, - hasBorderBottom = true, + showTopBorder = true, + showBottomBorder = true, }: SectionProps) { const cached = sectionKey ? sectionExpandedCache.get(sectionKey) : undefined; const [isOpen, setIsOpen] = useState(cached ?? defaultOpen); + useEffect(() => { + if (!sectionKey) return; + if (process.env.NODE_ENV !== "production" && mountedSectionKeys.has(sectionKey)) { + console.error(`[Section] duplicate sectionKey mounted simultaneously: "${sectionKey}"`); + } + mountedSectionKeys.add(sectionKey); + return () => { mountedSectionKeys.delete(sectionKey); }; + }, [sectionKey]); + const toggle = () => { const next = !isOpen; setIsOpen(next); @@ -51,8 +62,8 @@ export function Section({ <div className={cn( "flex flex-col", - hasBorderTop && "border-t", - hasBorderBottom && "last:border-b", + showTopBorder && "border-t", + showBottomBorder && "last:border-b", className, )} > @@ -63,15 +74,19 @@ export function Section({ } interface SectionHeaderProps { - title: string; children?: React.ReactNode; + trailing?: React.ReactNode; + leading?: React.ReactNode; + actions?: React.ReactNode; onClick?: () => void; className?: string; } export function SectionHeader({ - title, children, + trailing, + leading, + actions, onClick, className, }: SectionHeaderProps) { @@ -79,57 +94,108 @@ export function SectionHeader({ const isCollapsible = ctx?.collapsible ?? false; const isOpen = ctx?.isOpen ?? true; const isInteractive = isCollapsible || !!onClick; - const handleClick = isCollapsible ? ctx?.toggle : onClick; - const content = ( + const chevronIcon = isCollapsible ? ( + <HugeiconsIcon + icon={ArrowDownIcon} + className={cn( + "size-4 shrink-0 transition-transform duration-200 ease-out", + isOpen ? "rotate-0 text-foreground" : "-rotate-90 text-muted-foreground", + )} + /> + ) : null; + + const headerContent = ( <> - <span - className={cn( - "text-sm font-medium", - isOpen ? "text-foreground" : "text-muted-foreground", - )} - > - {title} - </span> - <div className="flex items-center gap-1"> - {children} - {isCollapsible && ( - <HugeiconsIcon - icon={ArrowDownIcon} - className={cn( - "size-3 shrink-0 transition-transform duration-200 ease-out", - isOpen - ? "rotate-0 text-foreground" - : "-rotate-90 text-muted-foreground", - )} - /> - )} - </div> + {leading} + <div className="min-w-0 flex-1">{children}</div> + {(trailing || chevronIcon) && ( + <div className="flex items-center"> + {trailing} + {chevronIcon && ( + <Button + variant="ghost" + size="icon" + aria-label={isOpen ? "Collapse section" : "Expand section"} + onClick={(event) => { + event.stopPropagation(); + handleClick?.(); + }} + > + {chevronIcon} + </Button> + )} + </div> + )} + {actions} </> ); - const baseClassName = cn( - "flex w-full items-center justify-between h-11 px-3.5", - className, + if (!isInteractive) { + return ( + <div className={cn("flex h-11 w-full items-center gap-2 px-3.5", className)}> + {headerContent} + </div> + ); + } + + return ( + // biome-ignore lint/a11y/useSemanticElements: outer div intentionally wraps a nested <Button> (chevron), making <button> invalid HTML here + <div + role="button" + tabIndex={0} + className={cn( + "flex h-11 w-full cursor-pointer items-center gap-2 px-3.5", + className, + )} + onClick={handleClick} + onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") handleClick?.(); }} + > + {headerContent} + </div> ); +} - if (isInteractive) { +export function SectionTitle({ + children, + className, + onClick, +}: { + children: React.ReactNode; + className?: string; + onClick?: () => void; +}) { + const ctx = useSectionContext(); + const isOpen = ctx?.isOpen ?? true; + + if (onClick) { return ( <button type="button" - className={cn(baseClassName, "cursor-pointer text-left")} - onClick={(event) => { - handleClick?.(); - event.currentTarget.blur(); - }} + className={cn( + "cursor-pointer text-sm font-medium", + isOpen ? "text-foreground" : "text-muted-foreground", + className, + )} + onClick={onClick} > - {content} + {children} </button> ); } - return <div className={baseClassName}>{content}</div>; + return ( + <span + className={cn( + "text-sm font-medium", + isOpen ? "text-foreground" : "text-muted-foreground", + className, + )} + > + {children} + </span> + ); } export function SectionFields({ @@ -139,21 +205,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 h-4 items-center gap-1.5"> + {beforeLabel} + <Label>{label}</Label> + </div> {children} </div> ); diff --git a/apps/web/src/components/editor/panels/properties/sections/blending.tsx b/apps/web/src/components/editor/panels/properties/sections/blending.tsx index 4343c692..a50e84e7 100644 --- a/apps/web/src/components/editor/panels/properties/sections/blending.tsx +++ b/apps/web/src/components/editor/panels/properties/sections/blending.tsx @@ -1,5 +1,4 @@ import { useEditor } from "@/hooks/use-editor"; -import { usePropertyDraft } from "../hooks/use-property-draft"; import { clamp } from "@/utils/math"; import { NumberField } from "@/components/ui/number-field"; import { @@ -8,7 +7,7 @@ import { } from "@/constants/timeline-constants"; import { OcCheckerboardIcon } from "@opencut/ui/icons"; import { Fragment, useRef } from "react"; -import { Section, SectionContent, SectionField, SectionHeader } from "../section"; +import { Section, SectionContent, SectionField, SectionHeader, SectionTitle } from "../section"; import { Select, SelectContent, @@ -19,14 +18,23 @@ import { } 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 = [ @@ -74,7 +82,7 @@ export function BlendingSection({ committedBlendModeRef.current = blendMode; } - const previewBlendMode = (value: BlendMode) => + const previewBlendMode = ({ value }: { value: BlendMode }) => editor.timeline.previewElements({ updates: [ { trackId, elementId: element.id, updates: { blendMode: value } }, @@ -105,28 +113,50 @@ export function BlendingSection({ } }; - const opacity = usePropertyDraft({ - displayValue: Math.round(element.opacity * 100).toString(), + 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; }, - onPreview: (value) => - editor.timeline.previewElements({ - updates: [ - { trackId, elementId: element.id, updates: { opacity: value } }, - ], - }), - onCommit: () => editor.timeline.commitPreview(), + valueAtPlayhead: resolvedOpacity, + buildBaseUpdates: ({ value }) => ({ opacity: value }), }); return ( <Section collapsible sectionKey={`${element.type}:blending`}> - <SectionHeader title="Blending" /> + <SectionHeader><SectionTitle>Blending</SectionTitle></SectionHeader> <SectionContent> <div className="flex items-start gap-2"> - <SectionField label="Opacity" className="w-1/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={ @@ -140,18 +170,14 @@ export function BlendingSection({ onBlur={opacity.onBlur} onScrub={opacity.scrubTo} onScrubEnd={opacity.commitScrub} - onReset={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { opacity: DEFAULT_OPACITY }, - }, - ], - }) - } - isDefault={element.opacity === DEFAULT_OPACITY} + onReset={() => opacity.commitValue({ value: DEFAULT_OPACITY })} + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: opacity.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedOpacity, + staticValue: element.opacity, + defaultValue: DEFAULT_OPACITY, + })} dragSensitivity="slow" /> </SectionField> @@ -175,7 +201,7 @@ export function BlendingSection({ key={option.value} value={option.value} onPointerEnter={() => - previewBlendMode(option.value as BlendMode) + previewBlendMode({ value: option.value as BlendMode }) } > {option.label} 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..6aaaa1fe 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,16 @@ 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, + SectionTitle, +} from "../section"; import { Button } from "@/components/ui/button"; import { HugeiconsIcon } from "@hugeicons/react"; import { @@ -13,71 +20,127 @@ 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 { +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: TransformElement; + 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 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 +151,152 @@ 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" + <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} /> - )} - <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 +307,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 +327,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> ); } diff --git a/apps/web/src/components/editor/panels/properties/text-properties.tsx b/apps/web/src/components/editor/panels/properties/text-properties.tsx index 1a17c26a..ae6de420 100644 --- a/apps/web/src/components/editor/panels/properties/text-properties.tsx +++ b/apps/web/src/components/editor/panels/properties/text-properties.tsx @@ -3,13 +3,23 @@ import { FontPicker } from "@/components/ui/font-picker"; import type { TextElement } from "@/types/timeline"; import { NumberField } from "@/components/ui/number-field"; import { useRef } from "react"; -import { Section, SectionContent, SectionField, SectionFields, SectionHeader } from "./section"; +import { + Section, + SectionContent, + SectionField, + SectionFields, + SectionHeader, + SectionTitle, +} from "./section"; import { ColorPicker } from "@/components/ui/color-picker"; +import { Button } from "@/components/ui/button"; import { uppercase } from "@/utils/string"; import { clamp } from "@/utils/math"; import { useEditor } from "@/hooks/use-editor"; import { DEFAULT_COLOR } from "@/constants/project-constants"; import { + CORNER_RADIUS_MAX, + CORNER_RADIUS_MIN, DEFAULT_LETTER_SPACING, DEFAULT_LINE_HEIGHT, DEFAULT_TEXT_BACKGROUND, @@ -18,32 +28,21 @@ import { MIN_FONT_SIZE, } from "@/constants/text-constants"; import { usePropertyDraft } from "./hooks/use-property-draft"; +import { useKeyframedColorProperty } from "./hooks/use-keyframed-color-property"; +import { useKeyframedNumberProperty } from "./hooks/use-keyframed-number-property"; +import { useElementPlayhead } from "./hooks/use-element-playhead"; import { TransformSection, BlendingSection } from "./sections"; +import { KeyframeToggle } from "./keyframe-toggle"; +import { isPropertyAtDefault } from "./sections/transform"; +import { resolveColorAtTime, resolveNumberAtTime } from "@/lib/animation"; import { HugeiconsIcon } from "@hugeicons/react"; -import { TextFontIcon } from "@hugeicons/core-free-icons"; +import { + TextFontIcon, + ViewIcon, + ViewOffSlashIcon, +} from "@hugeicons/core-free-icons"; import { OcTextHeightIcon, OcTextWidthIcon } from "@opencut/ui/icons"; - -function createOffsetConverter({ - defaultValue, - scale = 1, - min, -}: { - defaultValue: number; - scale?: number; - min?: number; -}) { - return { - toDisplay: (value: number) => Math.round((value - defaultValue) * scale), - fromDisplay: (display: number) => { - const stored = defaultValue + display / scale; - return min !== undefined ? Math.max(min, stored) : stored; - }, - }; -} - -const lineHeightConverter = createOffsetConverter({ defaultValue: DEFAULT_LINE_HEIGHT, scale: 10 }); -const paddingXConverter = createOffsetConverter({ defaultValue: DEFAULT_TEXT_BACKGROUND.paddingX, min: 0 }); -const paddingYConverter = createOffsetConverter({ defaultValue: DEFAULT_TEXT_BACKGROUND.paddingY, min: 0 }); +import { cn } from "@/utils/ui"; export function TextProperties({ element, @@ -86,8 +85,10 @@ function ContentSection({ }); return ( - <Section collapsible sectionKey="text:content" hasBorderTop={false}> - <SectionHeader title="Content" /> + <Section collapsible sectionKey="text:content" showTopBorder={false}> + <SectionHeader> + <SectionTitle>Content</SectionTitle> + </SectionHeader> <SectionContent> <Textarea placeholder="Name" @@ -110,6 +111,27 @@ function TypographySection({ trackId: string; }) { const editor = useEditor(); + const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ + startTime: element.startTime, + duration: element.duration, + }); + const resolvedTextColor = resolveColorAtTime({ + baseColor: element.color, + animations: element.animations, + propertyPath: "color", + localTime, + }); + + const textColor = useKeyframedColorProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "color", + localTime, + isPlayheadWithinElementRange, + resolvedColor: resolvedTextColor, + buildBaseUpdates: ({ value }) => ({ color: value }), + }); const fontSize = usePropertyDraft({ displayValue: element.fontSize.toString(), @@ -129,71 +151,73 @@ function TypographySection({ return ( <Section collapsible sectionKey="text:typography"> - <SectionHeader title="Typography" /> - <SectionContent> - <SectionFields> - <SectionField label="Font"> - <FontPicker - defaultValue={element.fontFamily} - onValueChange={(value) => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { fontFamily: value }, - }, - ], - }) - } - /> - </SectionField> - <SectionField label="Size"> - <NumberField - value={fontSize.displayValue} - min={MIN_FONT_SIZE} - max={MAX_FONT_SIZE} - onFocus={fontSize.onFocus} - onChange={fontSize.onChange} - onBlur={fontSize.onBlur} - onScrub={fontSize.scrubTo} - onScrubEnd={fontSize.commitScrub} - onReset={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { fontSize: DEFAULT_TEXT_ELEMENT.fontSize }, - }, - ], - }) - } - isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize} - icon={<HugeiconsIcon icon={TextFontIcon} />} - /> - </SectionField> - <SectionField label="Color"> - <ColorPicker - value={uppercase({ - string: (element.color || "FFFFFF").replace("#", ""), - })} - onChange={(color) => - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { color: `#${color}` }, - }, - ], - }) + <SectionHeader> + <SectionTitle>Typography</SectionTitle> + </SectionHeader> + <SectionContent> + <SectionFields> + <SectionField label="Font"> + <FontPicker + defaultValue={element.fontFamily} + onValueChange={(value) => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { fontFamily: value }, + }, + ], + }) + } + /> + </SectionField> + <SectionField label="Size"> + <NumberField + value={fontSize.displayValue} + min={MIN_FONT_SIZE} + max={MAX_FONT_SIZE} + onFocus={fontSize.onFocus} + onChange={fontSize.onChange} + onBlur={fontSize.onBlur} + onScrub={fontSize.scrubTo} + onScrubEnd={fontSize.commitScrub} + onReset={() => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { fontSize: DEFAULT_TEXT_ELEMENT.fontSize }, + }, + ], + }) + } + isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize} + icon={<HugeiconsIcon icon={TextFontIcon} />} + /> + </SectionField> + <SectionField + label="Color" + beforeLabel={ + <KeyframeToggle + isActive={textColor.isKeyframedAtTime} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle text color keyframe" + onToggle={textColor.toggleKeyframe} + /> } - onChangeEnd={() => editor.timeline.commitPreview()} - /> - </SectionField> - </SectionFields> - </SectionContent> + > + <ColorPicker + value={uppercase({ + string: resolvedTextColor.replace("#", ""), + })} + onChange={(color) => textColor.onChange({ color: `#${color}` })} + onChangeEnd={textColor.onChangeEnd} + /> + </SectionField> + </SectionFields> + </SectionContent> </Section> ); } @@ -208,72 +232,100 @@ function SpacingSection({ const editor = useEditor(); const letterSpacing = usePropertyDraft({ - displayValue: Math.round(element.letterSpacing ?? DEFAULT_LETTER_SPACING).toString(), + displayValue: Math.round( + element.letterSpacing ?? DEFAULT_LETTER_SPACING, + ).toString(), parse: (input) => { const parsed = parseFloat(input); return Number.isNaN(parsed) ? null : Math.round(parsed); }, onPreview: (value) => editor.timeline.previewElements({ - updates: [{ trackId, elementId: element.id, updates: { letterSpacing: value } }], + updates: [ + { trackId, elementId: element.id, updates: { letterSpacing: value } }, + ], }), onCommit: () => editor.timeline.commitPreview(), }); const lineHeight = usePropertyDraft({ - displayValue: lineHeightConverter.toDisplay(element.lineHeight ?? DEFAULT_LINE_HEIGHT).toString(), + displayValue: (element.lineHeight ?? DEFAULT_LINE_HEIGHT).toFixed(1), parse: (input) => { const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : lineHeightConverter.fromDisplay(Math.round(parsed)); + return Number.isNaN(parsed) + ? null + : Math.max(0.1, Math.round(parsed * 10) / 10); }, onPreview: (value) => editor.timeline.previewElements({ - updates: [{ trackId, elementId: element.id, updates: { lineHeight: value } }], + updates: [ + { trackId, elementId: element.id, updates: { lineHeight: value } }, + ], }), onCommit: () => editor.timeline.commitPreview(), }); return ( - <Section collapsible sectionKey="text:spacing" hasBorderBottom={false}> - <SectionHeader title="Spacing" /> - <SectionContent> - <div className="flex items-start gap-2"> - <SectionField label="Letter spacing" className="w-1/2"> - <NumberField - value={letterSpacing.displayValue} - onFocus={letterSpacing.onFocus} - onChange={letterSpacing.onChange} - onBlur={letterSpacing.onBlur} - onScrub={letterSpacing.scrubTo} - onScrubEnd={letterSpacing.commitScrub} - onReset={() => - editor.timeline.updateElements({ - updates: [{ trackId, elementId: element.id, updates: { letterSpacing: DEFAULT_LETTER_SPACING } }], - }) - } - isDefault={(element.letterSpacing ?? DEFAULT_LETTER_SPACING) === DEFAULT_LETTER_SPACING} - icon={<OcTextWidthIcon size={14} />} - /> - </SectionField> - <SectionField label="Line height" className="w-1/2"> - <NumberField - value={lineHeight.displayValue} - onFocus={lineHeight.onFocus} - onChange={lineHeight.onChange} - onBlur={lineHeight.onBlur} - onScrub={lineHeight.scrubTo} - onScrubEnd={lineHeight.commitScrub} - onReset={() => - editor.timeline.updateElements({ - updates: [{ trackId, elementId: element.id, updates: { lineHeight: DEFAULT_LINE_HEIGHT } }], - }) - } - isDefault={(element.lineHeight ?? DEFAULT_LINE_HEIGHT) === DEFAULT_LINE_HEIGHT} - icon={<OcTextHeightIcon size={14} />} - /> - </SectionField> - </div> - </SectionContent> + <Section collapsible sectionKey="text:spacing" showBottomBorder={false}> + <SectionHeader> + <SectionTitle>Spacing</SectionTitle> + </SectionHeader> + <SectionContent> + <div className="flex items-start gap-2"> + <SectionField label="Letter spacing" className="w-1/2"> + <NumberField + value={letterSpacing.displayValue} + onFocus={letterSpacing.onFocus} + onChange={letterSpacing.onChange} + onBlur={letterSpacing.onBlur} + onScrub={letterSpacing.scrubTo} + onScrubEnd={letterSpacing.commitScrub} + onReset={() => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { letterSpacing: DEFAULT_LETTER_SPACING }, + }, + ], + }) + } + isDefault={ + (element.letterSpacing ?? DEFAULT_LETTER_SPACING) === + DEFAULT_LETTER_SPACING + } + icon={<OcTextWidthIcon size={14} />} + /> + </SectionField> + <SectionField label="Line height" className="w-1/2"> + <NumberField + value={lineHeight.displayValue} + onFocus={lineHeight.onFocus} + onChange={lineHeight.onChange} + onBlur={lineHeight.onBlur} + onScrub={lineHeight.scrubTo} + onScrubEnd={lineHeight.commitScrub} + onReset={() => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { lineHeight: DEFAULT_LINE_HEIGHT }, + }, + ], + }) + } + isDefault={ + (element.lineHeight ?? DEFAULT_LINE_HEIGHT) === + DEFAULT_LINE_HEIGHT + } + icon={<OcTextHeightIcon size={14} />} + /> + </SectionField> + </div> + </SectionContent> </Section> ); } @@ -287,286 +339,390 @@ function BackgroundSection({ }) { const editor = useEditor(); const lastSelectedColor = useRef(DEFAULT_COLOR); + const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ + startTime: element.startTime, + duration: element.duration, + }); + const resolvedBgColor = resolveColorAtTime({ + baseColor: element.background.color, + animations: element.animations, + propertyPath: "background.color", + localTime, + }); + + const bgColor = useKeyframedColorProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "background.color", + localTime, + isPlayheadWithinElementRange, + resolvedColor: resolvedBgColor, + buildBaseUpdates: ({ value }) => ({ + background: { ...element.background, color: value }, + }), + }); - const cornerRadius = usePropertyDraft({ - displayValue: Math.round(element.background.cornerRadius ?? 0).toString(), + const bg = element.background; + + const resolvedPaddingX = resolveNumberAtTime({ + baseValue: bg.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX, + animations: element.animations, + propertyPath: "background.paddingX", + localTime, + }); + const resolvedPaddingY = resolveNumberAtTime({ + baseValue: bg.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY, + animations: element.animations, + propertyPath: "background.paddingY", + localTime, + }); + const resolvedOffsetX = resolveNumberAtTime({ + baseValue: bg.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX, + animations: element.animations, + propertyPath: "background.offsetX", + localTime, + }); + const resolvedOffsetY = resolveNumberAtTime({ + baseValue: bg.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY, + animations: element.animations, + propertyPath: "background.offsetY", + localTime, + }); + const resolvedCornerRadius = resolveNumberAtTime({ + baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN, + animations: element.animations, + propertyPath: "background.cornerRadius", + localTime, + }); + + const paddingX = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "background.paddingX", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedPaddingX).toString(), parse: (input) => { const parsed = parseFloat(input); return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed)); }, - onPreview: (value) => - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - background: { ...element.background, cornerRadius: value }, - }, - }, - ], - }), - onCommit: () => editor.timeline.commitPreview(), + valueAtPlayhead: resolvedPaddingX, + buildBaseUpdates: ({ value }) => ({ + background: { ...bg, paddingX: value }, + }), }); - const paddingX = usePropertyDraft({ - displayValue: paddingXConverter.toDisplay(element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX).toString(), + const paddingY = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "background.paddingY", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedPaddingY).toString(), parse: (input) => { const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : paddingXConverter.fromDisplay(Math.round(parsed)); + return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed)); }, - onPreview: (value) => - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { background: { ...element.background, paddingX: value } }, - }, - ], - }), - onCommit: () => editor.timeline.commitPreview(), + valueAtPlayhead: resolvedPaddingY, + buildBaseUpdates: ({ value }) => ({ + background: { ...bg, paddingY: value }, + }), }); - const paddingY = usePropertyDraft({ - displayValue: paddingYConverter.toDisplay(element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY).toString(), + const offsetX = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "background.offsetX", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedOffsetX).toString(), parse: (input) => { const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : paddingYConverter.fromDisplay(Math.round(parsed)); + return Number.isNaN(parsed) ? null : Math.round(parsed); }, - onPreview: (value) => - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { background: { ...element.background, paddingY: value } }, - }, - ], - }), - onCommit: () => editor.timeline.commitPreview(), + valueAtPlayhead: resolvedOffsetX, + buildBaseUpdates: ({ value }) => ({ + background: { ...bg, offsetX: value }, + }), }); - const offsetX = usePropertyDraft({ - displayValue: Math.round(element.background.offsetX ?? 0).toString(), + const offsetY = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "background.offsetY", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedOffsetY).toString(), parse: (input) => { const parsed = parseFloat(input); return Number.isNaN(parsed) ? null : Math.round(parsed); }, - onPreview: (value) => - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { background: { ...element.background, offsetX: value } }, - }, - ], - }), - onCommit: () => editor.timeline.commitPreview(), + valueAtPlayhead: resolvedOffsetY, + buildBaseUpdates: ({ value }) => ({ + background: { ...bg, offsetY: value }, + }), }); - const offsetY = usePropertyDraft({ - displayValue: Math.round(element.background.offsetY ?? 0).toString(), + const cornerRadius = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "background.cornerRadius", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedCornerRadius).toString(), parse: (input) => { const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : Math.round(parsed); + if (Number.isNaN(parsed)) return null; + return clamp({ value: Math.round(parsed), min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX }); }, - onPreview: (value) => - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { background: { ...element.background, offsetY: value } }, - }, - ], - }), - onCommit: () => editor.timeline.commitPreview(), + valueAtPlayhead: resolvedCornerRadius, + buildBaseUpdates: ({ value }) => ({ + background: { ...bg, cornerRadius: value }, + }), }); + const toggleBackgroundEnabled = () => { + const enabled = !element.background.enabled; + const color = + enabled && element.background.color === "transparent" + ? lastSelectedColor.current + : element.background.color; + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + background: { + ...element.background, + enabled, + color, + }, + }, + }, + ], + }); + }; + return ( - <Section collapsible sectionKey="text:background"> - <SectionHeader title="Background" /> - <SectionContent> + <Section + collapsible + defaultOpen={element.background.enabled} + sectionKey="text:background" + > + <SectionHeader + trailing={ + <Button + variant="ghost" + size="icon" + onClick={(event) => { + event.stopPropagation(); + toggleBackgroundEnabled(); + }} + > + <HugeiconsIcon + icon={element.background.enabled ? ViewIcon : ViewOffSlashIcon} + /> + </Button> + } + > + <SectionTitle>Background</SectionTitle> + </SectionHeader> + <SectionContent + className={cn( + !element.background.enabled && "pointer-events-none opacity-50", + )} + > <SectionFields> - <SectionField label="Color"> + <SectionField + label="Color" + beforeLabel={ + <KeyframeToggle + isActive={bgColor.isKeyframedAtTime} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle background color keyframe" + onToggle={bgColor.toggleKeyframe} + /> + } + > <ColorPicker value={ + !element.background.enabled || element.background.color === "transparent" ? lastSelectedColor.current.replace("#", "") - : element.background.color.replace("#", "") + : resolvedBgColor.replace("#", "") } onChange={(color) => { const hexColor = `#${color}`; if (color !== "transparent") { lastSelectedColor.current = hexColor; } - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - background: { ...element.background, color: hexColor }, - }, - }, - ], - }); + bgColor.onChange({ color: hexColor }); }} - onChangeEnd={() => editor.timeline.commitPreview()} - className={ - element.background.color === "transparent" - ? "pointer-events-none opacity-50" - : "" - } + onChangeEnd={bgColor.onChangeEnd} /> </SectionField> <div className="flex items-start gap-2"> - <SectionField label="Width" className="w-1/2"> - <NumberField - icon="W" - value={paddingX.displayValue} - min={0} + <SectionField + label="Width" + className="w-1/2" + beforeLabel={ + <KeyframeToggle + isActive={paddingX.isKeyframedAtTime} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle background width keyframe" + onToggle={paddingX.toggleKeyframe} + /> + } + > + <NumberField + icon="W" + value={paddingX.displayValue} + min={0} onFocus={paddingX.onFocus} onChange={paddingX.onChange} onBlur={paddingX.onBlur} onScrub={paddingX.scrubTo} onScrubEnd={paddingX.commitScrub} - onReset={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - background: { - ...element.background, - paddingX: DEFAULT_TEXT_BACKGROUND.paddingX, - }, - }, - }, - ], - }) - } - isDefault={ - (element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX) === - DEFAULT_TEXT_BACKGROUND.paddingX - } + onReset={() => paddingX.commitValue({ value: DEFAULT_TEXT_BACKGROUND.paddingX })} + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: paddingX.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedPaddingX, + staticValue: bg.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX, + defaultValue: DEFAULT_TEXT_BACKGROUND.paddingX, + })} /> </SectionField> - <SectionField label="Height" className="w-1/2"> - <NumberField - icon="H" - value={paddingY.displayValue} - min={0} + <SectionField + label="Height" + className="w-1/2" + beforeLabel={ + <KeyframeToggle + isActive={paddingY.isKeyframedAtTime} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle background height keyframe" + onToggle={paddingY.toggleKeyframe} + /> + } + > + <NumberField + icon="H" + value={paddingY.displayValue} + min={0} onFocus={paddingY.onFocus} onChange={paddingY.onChange} onBlur={paddingY.onBlur} onScrub={paddingY.scrubTo} onScrubEnd={paddingY.commitScrub} - onReset={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - background: { - ...element.background, - paddingY: DEFAULT_TEXT_BACKGROUND.paddingY, - }, - }, - }, - ], - }) - } - isDefault={ - (element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY) === - DEFAULT_TEXT_BACKGROUND.paddingY - } + onReset={() => paddingY.commitValue({ value: DEFAULT_TEXT_BACKGROUND.paddingY })} + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: paddingY.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedPaddingY, + staticValue: bg.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY, + defaultValue: DEFAULT_TEXT_BACKGROUND.paddingY, + })} /> </SectionField> </div> <div className="flex items-start gap-2"> - <SectionField label="X-offset" className="w-1/2"> - <NumberField - icon="X" - value={offsetX.displayValue} + <SectionField + label="X-offset" + className="w-1/2" + beforeLabel={ + <KeyframeToggle + isActive={offsetX.isKeyframedAtTime} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle x-offset keyframe" + onToggle={offsetX.toggleKeyframe} + /> + } + > + <NumberField + icon="X" + value={offsetX.displayValue} onFocus={offsetX.onFocus} onChange={offsetX.onChange} onBlur={offsetX.onBlur} onScrub={offsetX.scrubTo} onScrubEnd={offsetX.commitScrub} - onReset={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - background: { ...element.background, offsetX: 0 }, - }, - }, - ], - }) - } - isDefault={(element.background.offsetX ?? 0) === 0} + onReset={() => offsetX.commitValue({ value: DEFAULT_TEXT_BACKGROUND.offsetX })} + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: offsetX.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedOffsetX, + staticValue: bg.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX, + defaultValue: DEFAULT_TEXT_BACKGROUND.offsetX, + })} /> </SectionField> - <SectionField label="Y-offset" className="w-1/2"> - <NumberField - icon="Y" - value={offsetY.displayValue} + <SectionField + label="Y-offset" + className="w-1/2" + beforeLabel={ + <KeyframeToggle + isActive={offsetY.isKeyframedAtTime} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle y-offset keyframe" + onToggle={offsetY.toggleKeyframe} + /> + } + > + <NumberField + icon="Y" + value={offsetY.displayValue} onFocus={offsetY.onFocus} onChange={offsetY.onChange} onBlur={offsetY.onBlur} onScrub={offsetY.scrubTo} onScrubEnd={offsetY.commitScrub} - onReset={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - background: { ...element.background, offsetY: 0 }, - }, - }, - ], - }) - } - isDefault={(element.background.offsetY ?? 0) === 0} + onReset={() => offsetY.commitValue({ value: DEFAULT_TEXT_BACKGROUND.offsetY })} + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: offsetY.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedOffsetY, + staticValue: bg.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY, + defaultValue: DEFAULT_TEXT_BACKGROUND.offsetY, + })} /> </SectionField> </div> - <SectionField label="Corner Radius"> - <NumberField - icon="R" - value={cornerRadius.displayValue} - min={0} + <SectionField + label="Corner radius" + beforeLabel={ + <KeyframeToggle + isActive={cornerRadius.isKeyframedAtTime} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle corner radius keyframe" + onToggle={cornerRadius.toggleKeyframe} + /> + } + > + <NumberField + icon="R" + value={cornerRadius.displayValue} + min={CORNER_RADIUS_MIN} + max={CORNER_RADIUS_MAX} onFocus={cornerRadius.onFocus} onChange={cornerRadius.onChange} onBlur={cornerRadius.onBlur} onScrub={cornerRadius.scrubTo} onScrubEnd={cornerRadius.commitScrub} - onReset={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - background: { - ...element.background, - cornerRadius: 0, - }, - }, - }, - ], - }) - } - isDefault={(element.background.cornerRadius ?? 0) === 0} + onReset={() => cornerRadius.commitValue({ value: CORNER_RADIUS_MIN })} + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: cornerRadius.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedCornerRadius, + staticValue: bg.cornerRadius ?? CORNER_RADIUS_MIN, + defaultValue: CORNER_RADIUS_MIN, + })} /> </SectionField> </SectionFields> diff --git a/apps/web/src/components/editor/panels/properties/video-properties.tsx b/apps/web/src/components/editor/panels/properties/video-properties.tsx index 3588cbb7..49dd8fe5 100644 --- a/apps/web/src/components/editor/panels/properties/video-properties.tsx +++ b/apps/web/src/components/editor/panels/properties/video-properties.tsx @@ -1,4 +1,8 @@ -import type { ImageElement, StickerElement, VideoElement } from "@/types/timeline"; +import type { + ImageElement, + StickerElement, + VideoElement, +} from "@/types/timeline"; import { BlendingSection, TransformSection } from "./sections"; export function VideoProperties({ @@ -10,7 +14,11 @@ export function VideoProperties({ }) { return ( <div className="flex h-full flex-col"> - <TransformSection element={element} trackId={trackId} /> + <TransformSection + element={element} + trackId={trackId} + showTopBorder={false} + /> <BlendingSection element={element} trackId={trackId} /> </div> ); diff --git a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx index 64824f9a..864bb557 100644 --- a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx +++ b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx @@ -5,10 +5,7 @@ import type { EditorCore } from "@/core"; import { useEditor } from "@/hooks/use-editor"; import type { BookmarkDragState } from "@/hooks/timeline/use-bookmark-drag"; import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks"; -import { - DEFAULT_BOOKMARK_COLOR, - TIMELINE_CONSTANTS, -} from "@/constants/timeline-constants"; +import { DEFAULT_BOOKMARK_COLOR } from "@/constants/timeline-constants"; import { DEFAULT_FPS } from "@/constants/project-constants"; import { getSnappedSeekTime } from "@/lib/time"; import { @@ -28,8 +25,8 @@ import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { uppercase } from "@/utils/string"; import { clamp } from "@/utils/math"; +import { timelineTimeToPixels, timelineTimeToSnappedPixels } from "@/lib/timeline"; -const PIXELS_PER_SECOND = TIMELINE_CONSTANTS.PIXELS_PER_SECOND; const MIN_BOOKMARK_WIDTH_PX = 2; const BOOKMARK_MARKER_WIDTH_PX = 12; const BOOKMARK_MARKER_HEIGHT_PX = 15; @@ -139,10 +136,12 @@ function TimelineBookmark({ const time = bookmark.time; const bookmarkDuration = bookmark.duration ?? 0; const durationWidth = - bookmarkDuration > 0 ? bookmarkDuration * PIXELS_PER_SECOND * zoomLevel : 0; + bookmarkDuration > 0 + ? timelineTimeToPixels({ time: bookmarkDuration, zoomLevel }) + : 0; const hasDurationRange = durationWidth > MIN_BOOKMARK_WIDTH_PX; const bookmarkWidth = BOOKMARK_MARKER_WIDTH_PX + Math.max(durationWidth, 0); - const left = displayTime * PIXELS_PER_SECOND * zoomLevel; + const left = timelineTimeToSnappedPixels({ time: displayTime, zoomLevel }); const bookmarkLeft = left - BOOKMARK_HALF_WIDTH_PX; const rightHalfLeft = BOOKMARK_HALF_WIDTH_PX + Math.max(durationWidth, 0); const iconColor = bookmark.color ?? DEFAULT_BOOKMARK_COLOR; diff --git a/apps/web/src/components/editor/panels/timeline/drag-line.tsx b/apps/web/src/components/editor/panels/timeline/drag-line.tsx index f31c6e28..291a4633 100644 --- a/apps/web/src/components/editor/panels/timeline/drag-line.tsx +++ b/apps/web/src/components/editor/panels/timeline/drag-line.tsx @@ -21,7 +21,7 @@ export function DragLine({ return ( <div - className="bg-primary pointer-events-none absolute right-0 left-0 z-50 h-0.5" + className="bg-primary pointer-events-none absolute right-0 left-0 h-0.5" style={{ top: `${lineTop}px` }} /> ); diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx index 18f768a9..593976a8 100644 --- a/apps/web/src/components/editor/panels/timeline/index.tsx +++ b/apps/web/src/components/editor/panels/timeline/index.tsx @@ -27,7 +27,7 @@ import type { SnapPoint } from "@/lib/timeline/snap-utils"; import type { TimelineTrack } from "@/types/timeline"; import { TIMELINE_CONSTANTS, - TRACK_ICONS, + TRACK_CONFIG, } from "@/constants/timeline-constants"; import { useElementInteraction } from "@/hooks/timeline/element/use-element-interaction"; import { @@ -55,16 +55,18 @@ import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead"; import { DragLine } from "./drag-line"; import { invokeAction } from "@/lib/actions"; +const TRACKS_CONTAINER_MAX_HEIGHT = 800; +const FALLBACK_CONTAINER_WIDTH = 1000; + export function Timeline() { - const tracksContainerHeight = { min: 0, max: 800 }; - const { snappingEnabled } = useTimelineStore(); + const tracksContainerHeight = { min: 0, max: TRACKS_CONTAINER_MAX_HEIGHT }; + const snappingEnabled = useTimelineStore((s) => s.snappingEnabled); const { clearElementSelection, setElementSelection } = useElementSelection(); const editor = useEditor(); const timeline = editor.timeline; const tracks = timeline.getTracks(); const seek = (time: number) => editor.playback.seek({ time }); - // refs const timelineRef = useRef<HTMLDivElement>(null); const timelineHeaderRef = useRef<HTMLDivElement>(null); const rulerRef = useRef<HTMLDivElement>(null); @@ -74,7 +76,6 @@ export function Timeline() { const playheadRef = useRef<HTMLDivElement>(null); const trackLabelsScrollRef = useRef<HTMLDivElement>(null); - // state const [isResizing, setIsResizing] = useState(false); const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>( null, @@ -151,6 +152,7 @@ export function Timeline() { const { isDragOver, dropTarget, dragProps } = useTimelineDragDrop({ containerRef: tracksContainerRef, headerRef: timelineHeaderRef, + tracksScrollRef, zoomLevel, }); @@ -161,6 +163,7 @@ export function Timeline() { shouldIgnoreClick, } = useSelectionBox({ containerRef: tracksContainerRef, + headerRef: timelineHeaderRef, onSelectionComplete: (elements) => { setElementSelection({ elements }); }, @@ -168,7 +171,7 @@ export function Timeline() { zoomLevel, }); - const containerWidth = tracksContainerRef.current?.clientWidth || 1000; + const containerWidth = tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH; const contentWidth = timelineDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; const paddingPx = getTimelinePaddingPx({ @@ -326,7 +329,7 @@ export function Timeline() { <DragLine dropTarget={dropTarget} tracks={timeline.getTracks()} - isVisible={isDragOver} + isVisible={isDragOver && !dropTarget?.targetElement} headerHeight={timelineHeaderHeight} /> <DragLine @@ -421,7 +424,20 @@ export function Timeline() { {tracks.length === 0 ? ( <div /> ) : ( - tracks.map((track, index) => ( + [...tracks] + .map((track, index) => ({ track, index })) + .sort((a, b) => { + const aHasDragged = a.track.elements.some( + (element) => element.id === dragState.elementId, + ); + const bHasDragged = b.track.elements.some( + (element) => element.id === dragState.elementId, + ); + if (aHasDragged) return 1; + if (bHasDragged) return -1; + return 0; + }) + .map(({ track, index }) => ( <ContextMenu key={track.id}> <ContextMenuTrigger asChild> <div @@ -453,56 +469,61 @@ export function Timeline() { }} onTrackClick={handleTracksClick} shouldIgnoreClick={shouldIgnoreClick} + targetElementId={ + isDragOver + ? dropTarget?.targetElement?.elementId ?? null + : null + } /> </div> </ContextMenuTrigger> <ContextMenuContent className="w-40"> <ContextMenuItem icon={<HugeiconsIcon icon={TaskAdd02Icon} />} - onClick={(e) => { - e.stopPropagation(); - invokeAction("paste-copied"); - }} - > - Paste elements - </ContextMenuItem> - <ContextMenuItem - onClick={(e) => { - e.stopPropagation(); - timeline.toggleTrackMute({ - trackId: track.id, - }); - }} - > - <HugeiconsIcon icon={VolumeHighIcon} /> - <span> - {canTracktHaveAudio(track) && track.muted - ? "Unmute track" - : "Mute track"} - </span> - </ContextMenuItem> - <ContextMenuItem - onClick={(e) => { - e.stopPropagation(); - timeline.toggleTrackVisibility({ - trackId: track.id, - }); - }} - > - <HugeiconsIcon icon={ViewIcon} /> - <span> - {canTrackBeHidden(track) && track.hidden - ? "Show track" - : "Hide track"} - </span> - </ContextMenuItem> - <ContextMenuItem - onClick={(e) => { - e.stopPropagation(); - timeline.removeTrack({ - trackId: track.id, - }); - }} + onClick={(event) => { + event.stopPropagation(); + invokeAction("paste-copied"); + }} + > + Paste elements + </ContextMenuItem> + <ContextMenuItem + onClick={(event) => { + event.stopPropagation(); + timeline.toggleTrackMute({ + trackId: track.id, + }); + }} + > + <HugeiconsIcon icon={VolumeHighIcon} /> + <span> + {canTracktHaveAudio(track) && track.muted + ? "Unmute track" + : "Mute track"} + </span> + </ContextMenuItem> + <ContextMenuItem + onClick={(event) => { + event.stopPropagation(); + timeline.toggleTrackVisibility({ + trackId: track.id, + }); + }} + > + <HugeiconsIcon icon={ViewIcon} /> + <span> + {canTrackBeHidden(track) && track.hidden + ? "Show track" + : "Hide track"} + </span> + </ContextMenuItem> + <ContextMenuItem + onClick={(event) => { + event.stopPropagation(); + timeline.removeTrack({ + trackId: track.id, + }); + }} variant="destructive" > <HugeiconsIcon icon={Delete02Icon} /> @@ -523,7 +544,7 @@ export function Timeline() { } function TrackIcon({ track }: { track: TimelineTrack }) { - return <>{TRACK_ICONS[track.type]}</>; + return <>{TRACK_CONFIG[track.type].icon}</>; } function TrackToggleIcon({ diff --git a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx index 54ce5237..927d336a 100644 --- a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx +++ b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx @@ -2,6 +2,10 @@ import { useSnapIndicatorPosition } from "@/hooks/timeline/use-snap-indicator-position"; import type { SnapPoint } from "@/lib/timeline/snap-utils"; +import { + getCenteredLineLeft, + TIMELINE_INDICATOR_LINE_WIDTH_PX, +} from "@/lib/timeline"; import type { TimelineTrack } from "@/types/timeline"; interface SnapIndicatorProps { @@ -40,10 +44,10 @@ export function SnapIndicator({ <div className="pointer-events-none absolute" style={{ - left: `${leftPosition}px`, + left: `${getCenteredLineLeft({ centerPixel: leftPosition })}px`, top: topPosition, height: `${height}px`, - width: "2px", + width: `${TIMELINE_INDICATOR_LINE_WIDTH_PX}px`, }} > <div className={"bg-primary/40 h-full w-0.5 opacity-80"} /> diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx index 657e9a8b..d5b8cade 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx @@ -4,14 +4,21 @@ import { useEditor } from "@/hooks/use-editor"; import { useAssetsPanelStore } from "@/stores/assets-panel-store"; import AudioWaveform from "./audio-waveform"; import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize"; +import { + useKeyframeDrag, + type KeyframeDragState, +} from "@/hooks/timeline/element/use-keyframe-drag"; +import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection"; import type { SnapPoint } from "@/lib/timeline/snap-utils"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { getElementKeyframes } from "@/lib/animation"; import { getTrackClasses, getTrackHeight, canElementHaveAudio, canElementBeHidden, hasMediaId, + timelineTimeToPixels, + timelineTimeToSnappedPixels, } from "@/lib/timeline"; import { ContextMenu, @@ -19,10 +26,11 @@ import { ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, -} from "../../../ui/context-menu"; +} from "@/components/ui/context-menu"; import type { TimelineElement as TimelineElementType, TimelineTrack, + VisualElement, ElementDragState, } from "@/types/timeline"; import type { MediaAsset } from "@/types/assets"; @@ -42,13 +50,111 @@ import { VolumeMute02Icon, Search01Icon, Exchange01Icon, + KeyframeIcon, + MagicWand05Icon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { uppercase } from "@/utils/string"; -import type { ComponentProps } from "react"; +import type { ComponentProps, ReactNode } from "react"; +import type { SelectedKeyframeRef, ElementKeyframe } from "@/types/animation"; +import { cn } from "@/utils/ui"; +import { Button } from "@/components/ui/button"; +import { usePropertiesStore } from "@/stores/properties-store"; + +const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40; +const ELEMENT_RING_WIDTH_PX = 1.5; + +interface KeyframeIndicator { + time: number; + offsetPx: number; + keyframes: SelectedKeyframeRef[]; +} + +export function buildKeyframeIndicator({ + keyframe, + trackId, + elementId, + displayedStartTime, + zoomLevel, + elementLeft, +}: { + keyframe: ElementKeyframe; + trackId: string; + elementId: string; + displayedStartTime: number; + zoomLevel: number; + elementLeft: number; +}): { + time: number; + offsetPx: number; + keyframeRef: SelectedKeyframeRef; +} { + const keyframeRef = { + trackId, + elementId, + propertyPath: keyframe.propertyPath, + keyframeId: keyframe.id, + }; + const keyframeLeft = timelineTimeToSnappedPixels({ + time: displayedStartTime + keyframe.time, + zoomLevel, + }); + return { + time: keyframe.time, + offsetPx: keyframeLeft - elementLeft, + keyframeRef, + }; +} -function getDisplayShortcut(action: TAction) { - const { defaultShortcuts } = getActionDefinition(action); +export function getKeyframeIndicators({ + keyframes, + trackId, + elementId, + displayedStartTime, + zoomLevel, + elementLeft, + elementWidth, +}: { + keyframes: ElementKeyframe[]; + trackId: string; + elementId: string; + displayedStartTime: number; + zoomLevel: number; + elementLeft: number; + elementWidth: number; +}): KeyframeIndicator[] { + if (elementWidth < KEYFRAME_INDICATOR_MIN_WIDTH_PX) { + return []; + } + + const keyframesByTime = new Map<number, KeyframeIndicator>(); + for (const keyframe of keyframes) { + const indicator = buildKeyframeIndicator({ + keyframe, + trackId, + elementId, + displayedStartTime, + zoomLevel, + elementLeft, + }); + const existingIndicator = keyframesByTime.get(indicator.time); + if (!existingIndicator) { + keyframesByTime.set(indicator.time, { + time: indicator.time, + offsetPx: indicator.offsetPx, + keyframes: [indicator.keyframeRef], + }); + continue; + } + + existingIndicator.keyframes.push(indicator.keyframeRef); + } + + return [...keyframesByTime.values()].sort((a, b) => a.time - b.time); +} + +export function getDisplayShortcut({ action }: { action: TAction }) { + const { defaultShortcuts } = getActionDefinition({ action }); if (!defaultShortcuts?.length) { return ""; } @@ -66,11 +172,15 @@ interface TimelineElementProps { onSnapPointChange?: (snapPoint: SnapPoint | null) => void; onResizeStateChange?: (params: { isResizing: boolean }) => void; onElementMouseDown: ( - e: React.MouseEvent, + event: React.MouseEvent, + element: TimelineElementType, + ) => void; + onElementClick: ( + event: React.MouseEvent, element: TimelineElementType, ) => void; - onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void; dragState: ElementDragState; + isDropTarget?: boolean; } export function TimelineElement({ @@ -83,17 +193,18 @@ export function TimelineElement({ onElementMouseDown, onElementClick, dragState, + isDropTarget = false, }: TimelineElementProps) { const editor = useEditor(); const { selectedElements } = useElementSelection(); const { requestRevealMedia } = useAssetsPanelStore(); - const mediaAssets = editor.media.getAssets(); let mediaAsset: MediaAsset | null = null; if (hasMediaId(element)) { mediaAsset = - mediaAssets.find((asset) => asset.id === element.mediaId) ?? null; + editor.media.getAssets().find((asset) => asset.id === element.mediaId) ?? + null; } const hasAudio = mediaSupportsAudio({ media: mediaAsset }); @@ -123,10 +234,32 @@ export function TimelineElement({ : element.startTime; const displayedStartTime = isResizing ? currentStartTime : elementStartTime; const displayedDuration = isResizing ? currentDuration : element.duration; - const elementWidth = - displayedDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; - const elementLeft = displayedStartTime * 50 * zoomLevel; + const elementWidth = timelineTimeToPixels({ + time: displayedDuration, + zoomLevel, + }); + const elementLeft = timelineTimeToSnappedPixels({ + time: displayedStartTime, + zoomLevel, + }); + const keyframeIndicators = isSelected + ? getKeyframeIndicators({ + keyframes: getElementKeyframes({ animations: element.animations }), + trackId: track.id, + elementId: element.id, + displayedStartTime, + zoomLevel, + elementLeft, + elementWidth, + }) + : []; + const { + keyframeDragState, + handleKeyframeMouseDown, + handleKeyframeClick, + getVisualOffsetPx, + } = useKeyframeDrag({ zoomLevel, element, displayedStartTime }); const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => { event.stopPropagation(); if (hasMediaId(element)) { @@ -140,7 +273,7 @@ export function TimelineElement({ <ContextMenu> <ContextMenuTrigger asChild> <div - className={`absolute top-0 h-full select-none`} + className="absolute top-0 h-full select-none" style={{ left: `${elementLeft}px`, width: `${elementWidth}px`, @@ -154,13 +287,24 @@ export function TimelineElement({ element={element} track={track} isSelected={isSelected} - hasAudio={hasAudio} - isMuted={isMuted} - mediaAssets={mediaAssets} onElementClick={onElementClick} onElementMouseDown={onElementMouseDown} handleResizeStart={handleResizeStart} + isDropTarget={isDropTarget} /> + {isSelected && ( + <div className="pointer-events-none absolute inset-0 overflow-hidden"> + <KeyframeIndicators + indicators={keyframeIndicators} + dragState={keyframeDragState} + displayedStartTime={displayedStartTime} + elementLeft={elementLeft} + onKeyframeMouseDown={handleKeyframeMouseDown} + onKeyframeClick={handleKeyframeClick} + getVisualOffsetPx={getVisualOffsetPx} + /> + </div> + )} </div> </ContextMenuTrigger> <ContextMenuContent className="w-64"> @@ -197,7 +341,9 @@ export function TimelineElement({ <> <ContextMenuItem icon={<HugeiconsIcon icon={Search01Icon} />} - onClick={(event) => handleRevealInMedia({ event })} + onClick={(event: React.MouseEvent) => + handleRevealInMedia({ event }) + } > Reveal media </ContextMenuItem> @@ -225,22 +371,20 @@ function ElementInner({ element, track, isSelected, - hasAudio, - isMuted, - mediaAssets, onElementClick, onElementMouseDown, handleResizeStart, + isDropTarget = false, }: { element: TimelineElementType; track: TimelineTrack; isSelected: boolean; - hasAudio: boolean; - isMuted: boolean; - mediaAssets: MediaAsset[]; - onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void; + onElementClick: ( + event: React.MouseEvent, + element: TimelineElementType, + ) => void; onElementMouseDown: ( - e: React.MouseEvent, + event: React.MouseEvent, element: TimelineElementType, ) => void; handleResizeStart: (params: { @@ -248,48 +392,62 @@ function ElementInner({ elementId: string; side: "left" | "right"; }) => void; + isDropTarget?: boolean; }) { + const opacityClass = + (canElementBeHidden(element) && element.hidden) || isDropTarget + ? "opacity-50" + : ""; + const closeClipEffects = usePropertiesStore( + (state) => state.closeClipEffects, + ); + return ( <div - className={`relative h-full cursor-pointer overflow-hidden rounded-[0.5rem] ${getTrackClasses( - { - type: track.type, - }, - )} ${canElementBeHidden(element) && element.hidden ? "opacity-50" : ""}`} + className="relative h-full cursor-pointer" + style={{ marginInline: ELEMENT_RING_WIDTH_PX }} > - <button - type="button" - className="absolute inset-0 size-full cursor-pointer" - onClick={(e) => onElementClick(e, element)} - onMouseDown={(e) => onElementMouseDown(e, element)} + <div + className={cn( + "absolute inset-0 overflow-hidden rounded-sm", + getTrackClasses({ type: track.type }), + opacityClass, + )} + style={ + isSelected + ? { + boxShadow: `0 0 0 ${ELEMENT_RING_WIDTH_PX}px var(--foreground)`, + } + : undefined + } > - <div className="absolute inset-0 flex h-full items-center"> - <ElementContent - element={element} - track={track} - isSelected={isSelected} - mediaAssets={mediaAssets} + <button + type="button" + className="absolute inset-0 size-full cursor-pointer flex flex-col" + onClick={(event) => { + closeClipEffects(); + onElementClick(event, element); + }} + onMouseDown={(event) => onElementMouseDown(event, element)} + > + <div className="flex flex-1 min-h-0 items-center overflow-hidden"> + <ElementContent + element={element} + track={track} + isSelected={isSelected} + /> + </div> + </button> + </div> + + {element.type !== "audio" && element.type !== "effect" && ( + <div className="sticky left-1 mt-1 ml-1 w-fit"> + <EffectsButton + element={element as VisualElement} + trackId={track.id} /> </div> - - {(hasAudio - ? isMuted - : canElementBeHidden(element) && element.hidden) && ( - <div className="bg-opacity-50 pointer-events-none absolute inset-0 flex items-center justify-center bg-black"> - {hasAudio ? ( - <HugeiconsIcon - icon={VolumeHighIcon} - className="size-6 text-white" - /> - ) : ( - <HugeiconsIcon - icon={VolumeOffIcon} - className="size-6 text-white" - /> - )} - </div> - )} - </button> + )} {isSelected && ( <> @@ -326,61 +484,242 @@ function ResizeHandle({ return ( <button type="button" - className={`bg-primary absolute top-0 bottom-0 flex w-[0.6rem] items-center justify-center ${isLeft ? "left-0 cursor-w-resize" : "right-0 cursor-e-resize"}`} + className={cn( + "absolute top-0 bottom-0 w-2", + isLeft ? "-left-1 cursor-w-resize" : "-right-1 cursor-e-resize", + )} onMouseDown={(event) => handleResizeStart({ event, elementId, side })} + onClick={(event) => event.stopPropagation()} aria-label={`${isLeft ? "Left" : "Right"} resize handle`} - > - <div className="bg-foreground h-[1.5rem] w-[0.2rem] rounded-full" /> - </button> + ></button> ); } -function ElementContent({ - element, - track, - isSelected, - mediaAssets, +function KeyframeIndicators({ + indicators, + dragState, + displayedStartTime, + elementLeft, + onKeyframeMouseDown, + onKeyframeClick, + getVisualOffsetPx, }: { + indicators: KeyframeIndicator[]; + dragState: KeyframeDragState; + displayedStartTime: number; + elementLeft: number; + onKeyframeMouseDown: (params: { + event: React.MouseEvent; + keyframes: SelectedKeyframeRef[]; + }) => void; + onKeyframeClick: (params: { + event: React.MouseEvent; + keyframes: SelectedKeyframeRef[]; + orderedKeyframes: SelectedKeyframeRef[]; + indicatorTime: number; + }) => void; + getVisualOffsetPx: (params: { + indicatorTime: number; + indicatorOffsetPx: number; + isBeingDragged: boolean; + displayedStartTime: number; + elementLeft: number; + }) => number; +}) { + const { isKeyframeSelected } = useKeyframeSelection(); + const orderedKeyframes = indicators.flatMap( + (indicator) => indicator.keyframes, + ); + + return indicators.map((indicator) => { + const isIndicatorSelected = indicator.keyframes.some((keyframe) => + isKeyframeSelected({ keyframe }), + ); + const isBeingDragged = indicator.keyframes.some((kf) => + dragState.draggingKeyframeIds.has(kf.keyframeId), + ); + const visualOffsetPx = getVisualOffsetPx({ + indicatorTime: indicator.time, + indicatorOffsetPx: indicator.offsetPx, + isBeingDragged, + displayedStartTime, + elementLeft, + }); + + return ( + <button + key={indicator.time} + type="button" + className="pointer-events-auto absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-grab" + style={{ left: visualOffsetPx }} + onMouseDown={(event) => + onKeyframeMouseDown({ event, keyframes: indicator.keyframes }) + } + onClick={(event) => + onKeyframeClick({ + event, + keyframes: indicator.keyframes, + orderedKeyframes, + indicatorTime: indicator.time, + }) + } + aria-label="Select keyframe" + > + <HugeiconsIcon + icon={KeyframeIcon} + className={cn( + "size-3.5 mt-1.5 text-black", + isIndicatorSelected ? "fill-primary" : "fill-white", + )} + strokeWidth={1.5} + /> + </button> + ); + }); +} + +interface ElementContentProps { element: TimelineElementType; track: TimelineTrack; isSelected: boolean; +} + +interface ElementContentRendererProps extends ElementContentProps { mediaAssets: MediaAsset[]; +} + +type ElementContentRenderer = (props: ElementContentRendererProps) => ReactNode; + +export function renderTiledMedia({ + element, + imageUrl, + track, +}: { + element: VisualElement; + imageUrl: string | undefined; + track: ElementContentProps["track"]; +}): ReactNode { + if (!imageUrl) { + return ( + <span className="text-foreground/80 truncate text-xs"> + {element.name} + </span> + ); + } + + const trackHeight = getTrackHeight({ type: track.type }); + const tileWidth = trackHeight * (16 / 9); + + return ( + <div + className="absolute inset-0" + style={{ + backgroundImage: `url(${imageUrl})`, + backgroundRepeat: "repeat-x", + backgroundSize: `${tileWidth}px ${trackHeight}px`, + backgroundPosition: "left center", + pointerEvents: "none", + }} + /> + ); +} + +function EffectsButton({ + element, + trackId, + className, +}: { + element: VisualElement; + trackId: string; + className?: string; }) { - if (element.type === "text") { + const openClipEffects = usePropertiesStore((state) => state.openClipEffects); + const { selectElement } = useElementSelection(); + + if (!element.effects?.length) { + return null; + } + + const handleClick = (event: React.MouseEvent) => { + event.stopPropagation(); + selectElement({ elementId: element.id, trackId }); + openClipEffects({ elementId: element.id, trackId }); + }; + + return ( + <Button + variant="text" + size="icon" + className={cn("rounded-sm !size-5 bg-background/50", className)} + onClick={handleClick} + onMouseDown={(event) => event.stopPropagation()} + > + <HugeiconsIcon icon={MagicWand05Icon} /> + </Button> + ); +} + +const ELEMENT_CONTENT_RENDERERS: Record< + TimelineElementType["type"], + ElementContentRenderer +> = { + text: ({ element }) => { + const textElement = element as Extract< + TimelineElementType, + { type: "text" } + >; return ( <div className="flex size-full items-center justify-start pl-2"> - <span className="truncate text-xs text-white">{element.content}</span> + <span className="truncate text-xs text-white"> + {textElement.content} + </span> </div> ); - } - - if (element.type === "sticker") { + }, + effect: ({ element }) => ( + <div className="flex size-full items-center justify-start gap-1 pl-2"> + <HugeiconsIcon + icon={MagicWand05Icon} + className="size-4 shrink-0 text-white" + /> + <span className="truncate text-xs text-white ml-1">{element.name}</span> + </div> + ), + sticker: ({ element }) => { + const stickerElement = element as Extract< + TimelineElementType, + { type: "sticker" } + >; return ( <div className="flex size-full items-center gap-2 pl-2"> <Image src={resolveStickerId({ - stickerId: element.stickerId, + stickerId: stickerElement.stickerId, options: { width: 20, height: 20 }, })} - alt={element.name} + alt={stickerElement.name} className="size-5 shrink-0" width={20} height={20} unoptimized /> - <span className="truncate text-xs text-white">{element.name}</span> + <span className="truncate text-xs text-white"> + {stickerElement.name} + </span> </div> ); - } - - if (element.type === "audio") { + }, + audio: ({ element, mediaAssets }) => { + const audioElement = element as Extract< + TimelineElementType, + { type: "audio" } + >; const audioBuffer = - element.sourceType === "library" ? element.buffer : undefined; - + audioElement.sourceType === "library" ? audioElement.buffer : undefined; const audioUrl = - element.sourceType === "library" - ? element.sourceUrl - : mediaAssets.find((asset) => asset.id === element.mediaId)?.url; + audioElement.sourceType === "library" + ? audioElement.sourceUrl + : mediaAssets.find((asset) => asset.id === audioElement.mediaId)?.url; if (audioBuffer || audioUrl) { return ( @@ -399,53 +738,52 @@ function ElementContent({ return ( <span className="text-foreground/80 truncate text-xs"> - {element.name} + {audioElement.name} </span> ); - } - - const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId); - if (!mediaAsset) { - return ( - <span className="text-foreground/80 truncate text-xs"> - {element.name} - </span> + }, + video: ({ element, track, mediaAssets }) => { + const videoElement = element as Extract< + TimelineElementType, + { type: "video" } + >; + const mediaAsset = mediaAssets.find( + (asset) => asset.id === videoElement.mediaId, ); - } - - if ( - mediaAsset.type === "image" || - (mediaAsset.type === "video" && mediaAsset.thumbnailUrl) - ) { - const trackHeight = getTrackHeight({ type: track.type }); - const tileWidth = trackHeight * (16 / 9); - const imageUrl = - mediaAsset.type === "image" ? mediaAsset.url : mediaAsset.thumbnailUrl; - - return ( - <div className="flex size-full items-center justify-center"> - <div - className={`relative size-full ${isSelected ? "bg-primary" : "bg-transparent"}`} - > - <div - className="absolute right-0 left-0" - style={{ - backgroundImage: imageUrl ? `url(${imageUrl})` : "none", - backgroundRepeat: "repeat-x", - backgroundSize: `${tileWidth}px ${trackHeight}px`, - backgroundPosition: "left center", - pointerEvents: "none", - top: isSelected ? "0.25rem" : "0rem", - bottom: isSelected ? "0.25rem" : "0rem", - }} - /> - </div> - </div> + return renderTiledMedia({ + element: videoElement, + imageUrl: mediaAsset?.thumbnailUrl, + track, + }); + }, + image: ({ element, track, mediaAssets }) => { + const imageElement = element as Extract< + TimelineElementType, + { type: "image" } + >; + const mediaAsset = mediaAssets.find( + (asset) => asset.id === imageElement.mediaId, ); - } + return renderTiledMedia({ + element: imageElement, + imageUrl: mediaAsset?.url, + track, + }); + }, +}; +function ElementContent({ element, track, isSelected }: ElementContentProps) { + const editor = useEditor(); + const renderer = ELEMENT_CONTENT_RENDERERS[element.type]; return ( - <span className="text-foreground/80 truncate text-xs">{element.name}</span> + <> + {renderer({ + element, + track, + isSelected, + mediaAssets: editor.media.getAssets(), + })} + </> ); } @@ -474,9 +812,9 @@ function MuteMenuItem({ return <HugeiconsIcon icon={VolumeMute02Icon} />; } return isMuted ? ( - <HugeiconsIcon icon={VolumeHighIcon} /> - ) : ( <HugeiconsIcon icon={VolumeOffIcon} /> + ) : ( + <HugeiconsIcon icon={VolumeHighIcon} /> ); }; @@ -549,14 +887,15 @@ function ActionMenuItem({ ...props }: Omit<ComponentProps<typeof ContextMenuItem>, "onClick" | "textRight"> & { action: TAction; + children: ReactNode; }) { return ( <ContextMenuItem - onClick={(event) => { + onClick={(event: React.MouseEvent) => { event.stopPropagation(); invokeAction(action); }} - textRight={getDisplayShortcut(action)} + textRight={getDisplayShortcut({ action })} {...props} > {children} diff --git a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx index ebb2a37e..e290259e 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx @@ -1,7 +1,11 @@ "use client"; import { useRef } from "react"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { + getCenteredLineLeft, + TIMELINE_INDICATOR_LINE_WIDTH_PX, + timelineTimeToSnappedPixels, +} from "@/lib/timeline"; import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead"; import { useEditor } from "@/hooks/use-editor"; @@ -43,9 +47,11 @@ export function TimelinePlayhead({ 400; const totalHeight = Math.max(0, timelineContainerHeight - 4); - const timelinePosition = - playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; - const leftPosition = timelinePosition; + const centerPosition = timelineTimeToSnappedPixels({ + time: playheadPosition, + zoomLevel, + }); + const leftPosition = getCenteredLineLeft({ centerPixel: centerPosition }); const handlePlayheadKeyDown = ( event: React.KeyboardEvent<HTMLDivElement>, @@ -77,7 +83,7 @@ export function TimelinePlayhead({ left: `${leftPosition}px`, top: 0, height: `${totalHeight}px`, - width: "2px", + width: `${TIMELINE_INDICATOR_LINE_WIDTH_PX}px`, }} onKeyDown={handlePlayheadKeyDown} > diff --git a/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx b/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx index 9402d25e..22cd52cf 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx @@ -1,6 +1,6 @@ "use client"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { timelineTimeToSnappedPixels } from "@/lib/timeline"; import { formatRulerLabel } from "@/lib/timeline/ruler-utils"; interface TimelineTickProps { @@ -16,7 +16,7 @@ export function TimelineTick({ fps, showLabel, }: TimelineTickProps) { - const leftPosition = time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const leftPosition = timelineTimeToSnappedPixels({ time, zoomLevel }); if (showLabel) { const label = formatRulerLabel({ timeInSeconds: time, fps }); diff --git a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx index 03ce88eb..533ea63d 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx @@ -78,7 +78,7 @@ export function TimelineToolbar({ function ToolbarLeftSection() { const editor = useEditor(); const currentTime = editor.playback.getCurrentTime(); - const currentBookmarked = editor.scenes.isBookmarked({ time: currentTime }); + const isCurrentlyBookmarked = editor.scenes.isBookmarked({ time: currentTime }); const handleAction = ({ action, @@ -116,7 +116,7 @@ function ToolbarLeftSection() { <ToolbarButton icon={<SplitSquareHorizontal />} - tooltip="Coming soon" /* separate audio */ + tooltip="Separate audio (coming soon)" disabled={true} onClick={({ event: _event }) => {}} /> @@ -131,7 +131,7 @@ function ToolbarLeftSection() { <ToolbarButton icon={<HugeiconsIcon icon={SnowIcon} />} - tooltip="Coming soon" /* freeze frame */ + tooltip="Freeze frame (coming soon)" disabled={true} onClick={({ event: _event }) => {}} /> @@ -149,8 +149,8 @@ function ToolbarLeftSection() { <Tooltip> <ToolbarButton icon={<HugeiconsIcon icon={Bookmark02Icon} />} - isActive={currentBookmarked} - tooltip={currentBookmarked ? "Remove bookmark" : "Add bookmark"} + isActive={isCurrentlyBookmarked} + tooltip={isCurrentlyBookmarked ? "Remove bookmark" : "Add bookmark"} onClick={({ event }) => handleAction({ action: "toggle-bookmark", event }) } @@ -191,12 +191,10 @@ function ToolbarRightSection({ onZoomChange: (zoom: number) => void; onZoom: (options: { direction: "in" | "out" }) => void; }) { - const { - snappingEnabled, - rippleEditingEnabled, - toggleSnapping, - toggleRippleEditing, - } = useTimelineStore(); + const snappingEnabled = useTimelineStore((s) => s.snappingEnabled); + const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled); + const toggleSnapping = useTimelineStore((s) => s.toggleSnapping); + const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing); return ( <div className="flex items-center gap-1"> diff --git a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx index 42faadaf..81cda7a6 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx @@ -32,6 +32,7 @@ interface TimelineTrackContentProps { onTrackMouseDown?: (event: React.MouseEvent) => void; onTrackClick?: (event: React.MouseEvent) => void; shouldIgnoreClick?: () => boolean; + targetElementId?: string | null; } export function TimelineTrackContent({ @@ -48,9 +49,10 @@ export function TimelineTrackContent({ onTrackMouseDown, onTrackClick, shouldIgnoreClick, + targetElementId = null, }: TimelineTrackContentProps) { const editor = useEditor(); - const { isElementSelected, clearElementSelection } = useElementSelection(); + const { isElementSelected } = useElementSelection(); const duration = editor.timeline.getTotalDuration(); @@ -67,7 +69,6 @@ export function TimelineTrackContent({ className="size-full" onClick={(event) => { if (shouldIgnoreClick?.()) return; - clearElementSelection(); onTrackClick?.(event); }} onMouseDown={(event) => { @@ -102,6 +103,7 @@ export function TimelineTrackContent({ onElementClick({ event, element, track }) } dragState={dragState} + isDropTarget={element.id === targetElementId} /> ); }) diff --git a/apps/web/src/components/gitHub-contribute-section.tsx b/apps/web/src/components/gitHub-contribute-section.tsx index 153a662a..fc65b7aa 100644 --- a/apps/web/src/components/gitHub-contribute-section.tsx +++ b/apps/web/src/components/gitHub-contribute-section.tsx @@ -23,7 +23,7 @@ export function GitHubContributeSection({ target="_blank" rel="noopener noreferrer" > - <Button className="w-full" size="lg"> + <Button variant="foreground" className="w-full" size="lg"> <HugeiconsIcon icon={GithubIcon} /> Start contributing </Button> diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx index 95f08304..7fe0046f 100644 --- a/apps/web/src/components/header.tsx +++ b/apps/web/src/components/header.tsx @@ -18,6 +18,10 @@ export function Header() { const links = [ { + label: "Roadmap", + href: "/roadmap", + }, + { label: "Contributors", href: "/contributors", }, diff --git a/apps/web/src/components/ui/context-menu.tsx b/apps/web/src/components/ui/context-menu.tsx index a49d8a88..d8791bdf 100644 --- a/apps/web/src/components/ui/context-menu.tsx +++ b/apps/web/src/components/ui/context-menu.tsx @@ -31,7 +31,7 @@ const contextMenuItemVariants = cva( default: "focus:bg-accent focus:text-accent-foreground [&_svg]:text-muted-foreground", destructive: - "text-destructive focus:bg-destructive/5 focus:text-destructive [&_svg]:text-destructive", + "text-destructive focus:bg-destructive/10 focus:text-destructive [&_svg]:text-destructive", }, }, defaultVariants: { diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx index 311f6edb..1b57ea4d 100644 --- a/apps/web/src/components/ui/select.tsx +++ b/apps/web/src/components/ui/select.tsx @@ -40,7 +40,7 @@ const selectTriggerVariants = cva( }, size: { default: "", - sm: "", + sm: "rounded-sm", }, }, defaultVariants: { diff --git a/apps/web/src/components/ui/slider.tsx b/apps/web/src/components/ui/slider.tsx index 57ddbcf0..700be644 100644 --- a/apps/web/src/components/ui/slider.tsx +++ b/apps/web/src/components/ui/slider.tsx @@ -7,7 +7,9 @@ import { cn } from "@/utils/ui"; const Slider = React.forwardRef< React.ElementRef<typeof SliderPrimitive.Root>, - React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> + React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> & { + className?: string; + } >(({ className, ...props }, ref) => ( <SliderPrimitive.Root ref={ref} diff --git a/apps/web/src/constants/animation-constants.ts b/apps/web/src/constants/animation-constants.ts new file mode 100644 index 00000000..44776d34 --- /dev/null +++ b/apps/web/src/constants/animation-constants.ts @@ -0,0 +1,2 @@ +export const TIME_EPSILON_SECONDS = 1 / 1000; +export const MIN_TRANSFORM_SCALE = 0.01; diff --git a/apps/web/src/constants/text-constants.ts b/apps/web/src/constants/text-constants.ts index 6b688ed1..6779670b 100644 --- a/apps/web/src/constants/text-constants.ts +++ b/apps/web/src/constants/text-constants.ts @@ -17,7 +17,11 @@ export const FONT_SIZE_SCALE_REFERENCE = 90; export const DEFAULT_LETTER_SPACING = 0; export const DEFAULT_LINE_HEIGHT = 1.2; +export const CORNER_RADIUS_MIN = 0; +export const CORNER_RADIUS_MAX = 100; + export const DEFAULT_TEXT_BACKGROUND = { + enabled: false, color: "#000000", cornerRadius: 0, paddingX: 30, diff --git a/apps/web/src/constants/timeline-constants.tsx b/apps/web/src/constants/timeline-constants.tsx index 5ac0913a..5bc938bb 100644 --- a/apps/web/src/constants/timeline-constants.tsx +++ b/apps/web/src/constants/timeline-constants.tsx @@ -3,6 +3,7 @@ import type { BlendMode } from "@/types/rendering"; import type { TrackType, Transform } from "@/types/timeline"; import { Happy01Icon, + MagicWand05Icon, MusicNote03Icon, TextIcon, } from "@hugeicons/core-free-icons"; @@ -19,26 +20,65 @@ export const DEFAULT_OPACITY = 1; export const DEFAULT_BLEND_MODE: BlendMode = "normal"; export const DEFAULT_BOOKMARK_COLOR = "#009dff"; -export const TRACK_COLORS: Record<TrackType, { background: string }> = { +export const TRACK_CONFIG: Record< + TrackType, + { + background: string; + height: number; + defaultName: string; + icon: React.ReactNode; + } +> = { video: { background: "transparent", + height: 60, + defaultName: "Video track", + icon: <OcVideoIcon className="text-muted-foreground size-4 shrink-0" />, }, text: { background: "bg-[#5DBAA0]", + height: 25, + defaultName: "Text track", + icon: ( + <HugeiconsIcon + icon={TextIcon} + className="text-muted-foreground size-4 shrink-0" + /> + ), }, audio: { - background: "bg-[#915DBE]", + background: "bg-[#8F5DBA]", + height: 50, + defaultName: "Audio track", + icon: ( + <HugeiconsIcon + icon={MusicNote03Icon} + className="text-muted-foreground size-4 shrink-0" + /> + ), }, sticker: { - background: "bg-amber-500", + background: "bg-[#BA5D7A]", + height: 50, + defaultName: "Sticker track", + icon: ( + <HugeiconsIcon + icon={Happy01Icon} + className="text-muted-foreground size-4 shrink-0" + /> + ), + }, + effect: { + background: "bg-[#5d93ba]", + height: 25, + defaultName: "Effect track", + icon: ( + <HugeiconsIcon + icon={MagicWand05Icon} + className="text-muted-foreground size-4 shrink-0" + /> + ), }, -} as const; - -export const TRACK_HEIGHTS: Record<TrackType, number> = { - video: 60, - text: 25, - audio: 50, - sticker: 50, } as const; export const TRACK_GAP = 4; @@ -60,25 +100,3 @@ export const DEFAULT_TIMELINE_VIEW_STATE: TTimelineViewState = { scrollLeft: 0, playheadTime: 0, }; - -export const TRACK_ICONS: Record<TrackType, React.ReactNode> = { - video: <OcVideoIcon className="text-muted-foreground size-4 shrink-0" />, - text: ( - <HugeiconsIcon - icon={TextIcon} - className="text-muted-foreground size-4 shrink-0" - /> - ), - audio: ( - <HugeiconsIcon - icon={MusicNote03Icon} - className="text-muted-foreground size-4 shrink-0" - /> - ), - sticker: ( - <HugeiconsIcon - icon={Happy01Icon} - className="text-muted-foreground size-4 shrink-0" - /> - ), -} as const; diff --git a/apps/web/src/core/index.ts b/apps/web/src/core/index.ts index 4a8813f8..42ab3b87 100644 --- a/apps/web/src/core/index.ts +++ b/apps/web/src/core/index.ts @@ -8,6 +8,7 @@ import { CommandManager } from "./managers/commands"; import { SaveManager } from "./managers/save-manager"; import { AudioManager } from "./managers/audio-manager"; import { SelectionManager } from "./managers/selection-manager"; +import { registerDefaultEffects } from "@/lib/effects"; export class EditorCore { private static instance: EditorCore | null = null; @@ -24,6 +25,7 @@ export class EditorCore { public readonly selection: SelectionManager; private constructor() { + registerDefaultEffects(); this.command = new CommandManager(); this.playback = new PlaybackManager(this); this.timeline = new TimelineManager(this); diff --git a/apps/web/src/core/managers/audio-manager.ts b/apps/web/src/core/managers/audio-manager.ts index 457e49d6..20b124b6 100644 --- a/apps/web/src/core/managers/audio-manager.ts +++ b/apps/web/src/core/managers/audio-manager.ts @@ -29,6 +29,7 @@ export class AudioManager { private playbackSessionId = 0; private lastIsPlaying = false; private lastVolume = 1; + private playbackLatencyCompensationSeconds = 0; private unsubscribers: Array<() => void> = []; constructor(private editor: EditorCore) { @@ -136,6 +137,7 @@ export class AudioManager { this.stopPlayback(); this.playbackSessionId++; + this.playbackLatencyCompensationSeconds = 0; const tracks = this.editor.timeline.getTracks(); const mediaAssets = this.editor.media.getAssets(); @@ -224,13 +226,21 @@ export class AudioManager { const clipStart = clip.startTime; const clipEnd = clip.startTime + clip.duration; - - const iteratorStartTime = Math.max(startTime, clipStart); + const playbackTimeAfterSinkReady = this.getPlaybackTime(); + const iteratorStartTime = Math.max( + startTime, + clipStart, + playbackTimeAfterSinkReady, + ); + if (iteratorStartTime >= clipEnd) { + return; + } const sourceStartTime = clip.trimStart + (iteratorStartTime - clip.startTime); const iterator = sink.buffers(sourceStartTime); this.clipIterators.set(clip.id, iterator); + let consecutiveDroppedBufferCount = 0; for await (const { buffer, timestamp } of iterator) { if (!this.editor.playback.getIsPlaying()) return; @@ -244,15 +254,41 @@ export class AudioManager { node.connect(this.masterGain ?? audioContext.destination); const startTimestamp = - this.playbackStartContextTime + (timelineTime - this.playbackStartTime); + this.playbackStartContextTime + + this.playbackLatencyCompensationSeconds + + (timelineTime - this.playbackStartTime); if (startTimestamp >= audioContext.currentTime) { node.start(startTimestamp); + consecutiveDroppedBufferCount = 0; } else { const offset = audioContext.currentTime - startTimestamp; if (offset < buffer.duration) { node.start(audioContext.currentTime, offset); + consecutiveDroppedBufferCount = 0; } else { + consecutiveDroppedBufferCount += 1; + if (consecutiveDroppedBufferCount >= 5) { + const nextCompensationSeconds = Math.max( + this.playbackLatencyCompensationSeconds, + Math.min(0.25, offset + 0.01), + ); + if ( + nextCompensationSeconds > + this.playbackLatencyCompensationSeconds + 0.001 + ) { + this.playbackLatencyCompensationSeconds = + nextCompensationSeconds; + } + const resyncStartTime = this.getPlaybackTime(); + this.clipIterators.delete(clip.id); + void this.runClipIterator({ + clip, + startTime: resyncStartTime, + sessionId, + }); + return; + } continue; } } diff --git a/apps/web/src/core/managers/project-manager.ts b/apps/web/src/core/managers/project-manager.ts index 2608b8c0..05889a94 100644 --- a/apps/web/src/core/managers/project-manager.ts +++ b/apps/web/src/core/managers/project-manager.ts @@ -7,7 +7,7 @@ import type { TProjectSettings, TTimelineViewState, } from "@/types/project"; -import type { ExportOptions, ExportResult } from "@/types/export"; +import type { ExportOptions, ExportResult, ExportState } from "@/types/export"; import { storageService } from "@/services/storage/service"; import { toast } from "sonner"; import { generateUUID } from "@/utils/id"; @@ -51,6 +51,12 @@ export class ProjectManager { toVersion: null, projectName: null, }; + private exportState: ExportState = { + isExporting: false, + progress: 0, + result: null, + }; + private exportCancelRequested = false; constructor(private editor: EditorCore) {} @@ -191,7 +197,40 @@ export class ProjectManager { } async export({ options }: { options: ExportOptions }): Promise<ExportResult> { - return this.editor.renderer.exportProject({ options }); + this.exportCancelRequested = false; + this.exportState = { isExporting: true, progress: 0, result: null }; + this.notify(); + + const result = await this.editor.renderer.exportProject({ + options, + onProgress: ({ progress }) => { + this.exportState = { ...this.exportState, progress }; + this.notify(); + }, + onCancel: () => this.exportCancelRequested, + }); + + this.exportState = { + isExporting: false, + progress: this.exportState.progress, + result, + }; + this.notify(); + + return result; + } + + cancelExport(): void { + this.exportCancelRequested = true; + } + + clearExportState(): void { + this.exportState = { isExporting: false, progress: 0, result: null }; + this.notify(); + } + + getExportState(): ExportState { + return this.exportState; } async loadAllProjects(): Promise<void> { diff --git a/apps/web/src/core/managers/renderer-manager.ts b/apps/web/src/core/managers/renderer-manager.ts index 03463b2c..601a007e 100644 --- a/apps/web/src/core/managers/renderer-manager.ts +++ b/apps/web/src/core/managers/renderer-manager.ts @@ -88,11 +88,14 @@ export class RendererManager { async exportProject({ options, + onProgress, + onCancel, }: { options: ExportOptions; + onProgress?: ({ progress }: { progress: number }) => void; + onCancel?: () => boolean; }): Promise<ExportResult> { - const { format, quality, fps, includeAudio, onProgress, onCancel } = - options; + const { format, quality, fps, includeAudio } = options; try { const tracks = this.editor.timeline.getTracks(); diff --git a/apps/web/src/core/managers/selection-manager.ts b/apps/web/src/core/managers/selection-manager.ts index fa6a02bf..e4295c11 100644 --- a/apps/web/src/core/managers/selection-manager.ts +++ b/apps/web/src/core/managers/selection-manager.ts @@ -1,9 +1,12 @@ import type { EditorCore } from "@/core"; +import type { SelectedKeyframeRef } from "@/types/animation"; type ElementRef = { trackId: string; elementId: string }; export class SelectionManager { private selectedElements: ElementRef[] = []; + private selectedKeyframes: SelectedKeyframeRef[] = []; + private keyframeSelectionAnchor: SelectedKeyframeRef | null = null; private listeners = new Set<() => void>(); constructor(editor: EditorCore) { @@ -14,13 +17,47 @@ export class SelectionManager { return this.selectedElements; } + getSelectedKeyframes(): SelectedKeyframeRef[] { + return this.selectedKeyframes; + } + + getKeyframeSelectionAnchor(): SelectedKeyframeRef | null { + return this.keyframeSelectionAnchor; + } + setSelectedElements({ elements }: { elements: ElementRef[] }): void { this.selectedElements = elements; + this.selectedKeyframes = []; + this.keyframeSelectionAnchor = null; + this.notify(); + } + + setSelectedKeyframes({ + keyframes, + anchorKeyframe, + }: { + keyframes: SelectedKeyframeRef[]; + anchorKeyframe?: SelectedKeyframeRef | null; + }): void { + this.selectedKeyframes = keyframes; + if (anchorKeyframe !== undefined) { + this.keyframeSelectionAnchor = anchorKeyframe; + } else if (keyframes.length === 0) { + this.keyframeSelectionAnchor = null; + } this.notify(); } clearSelection(): void { this.selectedElements = []; + this.selectedKeyframes = []; + this.keyframeSelectionAnchor = null; + this.notify(); + } + + clearKeyframeSelection(): void { + this.selectedKeyframes = []; + this.keyframeSelectionAnchor = null; this.notify(); } diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts index c8ac5a79..173f0744 100644 --- a/apps/web/src/core/managers/timeline-manager.ts +++ b/apps/web/src/core/managers/timeline-manager.ts @@ -1,10 +1,16 @@ import type { EditorCore } from "@/core"; +import type { EffectParamValues } from "@/types/effects"; import type { TrackType, TimelineTrack, TimelineElement, ClipboardItem, } from "@/types/timeline"; +import type { + AnimationInterpolation, + AnimationPropertyPath, + AnimationValue, +} from "@/types/animation"; import { calculateTotalDuration } from "@/lib/timeline"; import { AddTrackCommand, @@ -24,6 +30,16 @@ import { UpdateElementStartTimeCommand, MoveElementCommand, TracksSnapshotCommand, + UpsertKeyframeCommand, + RemoveKeyframeCommand, + RetimeKeyframeCommand, + AddClipEffectCommand, + RemoveClipEffectCommand, + UpdateClipEffectParamsCommand, + ToggleClipEffectCommand, + ReorderClipEffectsCommand, + UpsertEffectParamKeyframeCommand, + RemoveEffectParamKeyframeCommand, } from "@/lib/commands/timeline"; import { BatchCommand, PreviewTracker } from "@/lib/commands"; import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element"; @@ -54,14 +70,27 @@ export class TimelineManager { elementId, trimStart, trimEnd, + startTime, + duration, pushHistory = true, + rippleEnabled = false, }: { elementId: string; trimStart: number; trimEnd: number; + startTime?: number; + duration?: number; pushHistory?: boolean; + rippleEnabled?: boolean; }): void { - const command = new UpdateElementTrimCommand(elementId, trimStart, trimEnd); + const command = new UpdateElementTrimCommand({ + elementId, + trimStart, + trimEnd, + startTime, + duration, + rippleEnabled, + }); if (pushHistory) { this.editor.command.execute({ command }); } else { @@ -80,11 +109,11 @@ export class TimelineManager { duration: number; pushHistory?: boolean; }): void { - const command = new UpdateElementDurationCommand( + const command = new UpdateElementDurationCommand({ trackId, elementId, duration, - ); + }); if (pushHistory) { this.editor.command.execute({ command }); } else { @@ -99,7 +128,10 @@ export class TimelineManager { elements: { trackId: string; elementId: string }[]; startTime: number; }): void { - const command = new UpdateElementStartTimeCommand(elements, startTime); + const command = new UpdateElementStartTimeCommand({ + elements, + startTime, + }); this.editor.command.execute({ command }); } @@ -109,20 +141,23 @@ export class TimelineManager { elementId, newStartTime, createTrack, + rippleEnabled = false, }: { sourceTrackId: string; targetTrackId: string; elementId: string; newStartTime: number; createTrack?: { type: TrackType; index: number }; + rippleEnabled?: boolean; }): void { - const command = new MoveElementCommand( + const command = new MoveElementCommand({ sourceTrackId, targetTrackId, elementId, newStartTime, createTrack, - ); + rippleEnabled, + }); this.editor.command.execute({ command }); } @@ -140,12 +175,19 @@ export class TimelineManager { elements, splitTime, retainSide = "both", + rippleEnabled = false, }: { elements: { trackId: string; elementId: string }[]; splitTime: number; retainSide?: "both" | "left" | "right"; + rippleEnabled?: boolean; }): { trackId: string; elementId: string }[] { - const command = new SplitElementsCommand(elements, splitTime, retainSide); + const command = new SplitElementsCommand({ + elements, + splitTime, + retainSide, + rippleEnabled, + }); this.editor.command.execute({ command }); return command.getRightSideElements(); } @@ -194,10 +236,12 @@ export class TimelineManager { deleteElements({ elements, + rippleEnabled = false, }: { elements: { trackId: string; elementId: string }[]; + rippleEnabled?: boolean; }): void { - const command = new DeleteElementsCommand(elements); + const command = new DeleteElementsCommand({ elements, rippleEnabled }); this.editor.command.execute({ command }); } @@ -208,13 +252,17 @@ export class TimelineManager { updates: Array<{ trackId: string; elementId: string; - updates: Partial<Record<string, unknown>>; + updates: Partial<TimelineElement>; }>; pushHistory?: boolean; }): void { const commands = updates.map( ({ trackId, elementId, updates: elementUpdates }) => - new UpdateElementCommand(trackId, elementId, elementUpdates), + new UpdateElementCommand({ + trackId, + elementId, + updates: elementUpdates, + }), ); const command = commands.length === 1 ? commands[0] : new BatchCommand(commands); @@ -225,6 +273,252 @@ export class TimelineManager { } } + addClipEffect({ + trackId, + elementId, + effectType, + }: { + trackId: string; + elementId: string; + effectType: string; + }): string { + const command = new AddClipEffectCommand({ + trackId, + elementId, + effectType, + }); + this.editor.command.execute({ command }); + return command.getEffectId() ?? ""; + } + + removeClipEffect({ + trackId, + elementId, + effectId, + }: { + trackId: string; + elementId: string; + effectId: string; + }): void { + const command = new RemoveClipEffectCommand({ + trackId, + elementId, + effectId, + }); + this.editor.command.execute({ command }); + } + + updateClipEffectParams({ + trackId, + elementId, + effectId, + params, + pushHistory = true, + }: { + trackId: string; + elementId: string; + effectId: string; + params: Partial<EffectParamValues>; + pushHistory?: boolean; + }): void { + const command = new UpdateClipEffectParamsCommand({ + trackId, + elementId, + effectId, + params, + }); + if (pushHistory) { + this.editor.command.execute({ command }); + } else { + command.execute(); + } + } + + toggleClipEffect({ + trackId, + elementId, + effectId, + }: { + trackId: string; + elementId: string; + effectId: string; + }): void { + const command = new ToggleClipEffectCommand({ + trackId, + elementId, + effectId, + }); + this.editor.command.execute({ command }); + } + + reorderClipEffects({ + trackId, + elementId, + fromIndex, + toIndex, + }: { + trackId: string; + elementId: string; + fromIndex: number; + toIndex: number; + }): void { + const command = new ReorderClipEffectsCommand({ + trackId, + elementId, + fromIndex, + toIndex, + }); + this.editor.command.execute({ command }); + } + + upsertKeyframes({ + keyframes, + }: { + keyframes: Array<{ + trackId: string; + elementId: string; + propertyPath: AnimationPropertyPath; + time: number; + value: AnimationValue; + interpolation?: AnimationInterpolation; + keyframeId?: string; + }>; + }): void { + if (keyframes.length === 0) { + return; + } + + const commands = keyframes.map( + ({ + trackId, + elementId, + propertyPath, + time, + value, + interpolation, + keyframeId, + }) => + new UpsertKeyframeCommand({ + trackId, + elementId, + propertyPath, + time, + value, + interpolation, + keyframeId, + }), + ); + const command = + commands.length === 1 ? commands[0] : new BatchCommand(commands); + this.editor.command.execute({ command }); + } + + removeKeyframes({ + keyframes, + }: { + keyframes: Array<{ + trackId: string; + elementId: string; + propertyPath: AnimationPropertyPath; + keyframeId: string; + }>; + }): void { + if (keyframes.length === 0) { + return; + } + + const commands = keyframes.map( + ({ trackId, elementId, propertyPath, keyframeId }) => + new RemoveKeyframeCommand({ + trackId, + elementId, + propertyPath, + keyframeId, + }), + ); + const command = + commands.length === 1 ? commands[0] : new BatchCommand(commands); + this.editor.command.execute({ command }); + } + + retimeKeyframe({ + trackId, + elementId, + propertyPath, + keyframeId, + time, + }: { + trackId: string; + elementId: string; + propertyPath: AnimationPropertyPath; + keyframeId: string; + time: number; + }): void { + const command = new RetimeKeyframeCommand({ + trackId, + elementId, + propertyPath, + keyframeId, + nextTime: time, + }); + this.editor.command.execute({ command }); + } + + upsertEffectParamKeyframe({ + trackId, + elementId, + effectId, + paramKey, + time, + value, + interpolation, + keyframeId, + }: { + trackId: string; + elementId: string; + effectId: string; + paramKey: string; + time: number; + value: number; + interpolation?: "linear" | "hold"; + keyframeId?: string; + }): void { + const command = new UpsertEffectParamKeyframeCommand({ + trackId, + elementId, + effectId, + paramKey, + time, + value, + interpolation, + keyframeId, + }); + this.editor.command.execute({ command }); + } + + removeEffectParamKeyframe({ + trackId, + elementId, + effectId, + paramKey, + keyframeId, + }: { + trackId: string; + elementId: string; + effectId: string; + paramKey: string; + keyframeId: string; + }): void { + const command = new RemoveEffectParamKeyframeCommand({ + trackId, + elementId, + effectId, + paramKey, + keyframeId, + }); + this.editor.command.execute({ command }); + } + isPreviewActive(): boolean { return this.previewTracker.isActive(); } @@ -235,7 +529,7 @@ export class TimelineManager { updates: Array<{ trackId: string; elementId: string; - updates: Partial<Record<string, unknown>>; + updates: Partial<TimelineElement>; }>; }): void { const tracks = this.getTracks(); diff --git a/apps/web/src/hooks/actions/use-editor-actions.ts b/apps/web/src/hooks/actions/use-editor-actions.ts index 1ce890cb..e4f378d6 100644 --- a/apps/web/src/hooks/actions/use-editor-actions.ts +++ b/apps/web/src/hooks/actions/use-editor-actions.ts @@ -4,13 +4,19 @@ import { useTimelineStore } from "@/stores/timeline-store"; import { useActionHandler } from "@/hooks/actions/use-action-handler"; import { useEditor } from "../use-editor"; import { useElementSelection } from "../timeline/element/use-element-selection"; +import { useKeyframeSelection } from "../timeline/element/use-keyframe-selection"; import { getElementsAtTime } from "@/lib/timeline"; export function useEditorActions() { const editor = useEditor(); const activeProject = editor.project.getActive(); const { selectedElements, setElementSelection } = useElementSelection(); - const { clipboard, setClipboard, toggleSnapping } = useTimelineStore(); + const { selectedKeyframes, clearKeyframeSelection } = useKeyframeSelection(); + const clipboard = useTimelineStore((s) => s.clipboard); + const setClipboard = useTimelineStore((s) => s.setClipboard); + const toggleSnapping = useTimelineStore((s) => s.toggleSnapping); + const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled); + const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing); useActionHandler( "toggle-play", @@ -158,11 +164,21 @@ export function useEditorActions() { if (elementsToSplit.length === 0) return; - editor.timeline.splitElements({ + const rightSideElements = editor.timeline.splitElements({ elements: elementsToSplit, splitTime: currentTime, retainSide: "right", + rippleEnabled: rippleEditingEnabled, }); + + if (rippleEditingEnabled && rightSideElements.length > 0) { + const firstRightElement = editor.timeline.getElementsWithTracks({ + elements: [rightSideElements[0]], + })[0]; + if (firstRightElement) { + editor.playback.seek({ time: firstRightElement.element.startTime }); + } + } }, undefined, ); @@ -193,11 +209,17 @@ export function useEditorActions() { useActionHandler( "delete-selected", () => { + if (selectedKeyframes.length > 0) { + editor.timeline.removeKeyframes({ keyframes: selectedKeyframes }); + clearKeyframeSelection(); + return; + } if (selectedElements.length === 0) { return; } editor.timeline.deleteElements({ elements: selectedElements, + rippleEnabled: rippleEditingEnabled, }); editor.selection.clearSelection(); }, @@ -219,6 +241,19 @@ export function useEditorActions() { ); useActionHandler( + "deselect-all", + () => { + setElementSelection({ elements: [] }); + clearKeyframeSelection(); + const activeElement = document.activeElement; + if (activeElement instanceof HTMLButtonElement) { + activeElement.blur(); + } + }, + undefined, + ); + + useActionHandler( "duplicate-selected", () => { editor.timeline.duplicateElements({ @@ -261,7 +296,7 @@ export function useEditorActions() { elements: selectedElements, }); const items = results.map(({ track, element }) => { - const { ...elementWithoutId } = element; + const { id: _, ...elementWithoutId } = element; return { trackId: track.id, trackType: track.type, @@ -296,6 +331,14 @@ export function useEditorActions() { ); useActionHandler( + "toggle-ripple-editing", + () => { + toggleRippleEditing(); + }, + undefined, + ); + + useActionHandler( "undo", () => { editor.command.undo(); diff --git a/apps/web/src/hooks/timeline/element/use-element-interaction.ts b/apps/web/src/hooks/timeline/element/use-element-interaction.ts index e7b93813..ea01c718 100644 --- a/apps/web/src/hooks/timeline/element/use-element-interaction.ts +++ b/apps/web/src/hooks/timeline/element/use-element-interaction.ts @@ -8,6 +8,7 @@ import { } from "react"; import { useEditor } from "@/hooks/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; +import { useTimelineStore } from "@/stores/timeline-store"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; import { DRAG_THRESHOLD_PX, @@ -38,6 +39,8 @@ interface UseElementInteractionProps { onSnapPointChange?: (snapPoint: SnapPoint | null) => void; } +const MOUSE_BUTTON_RIGHT = 2; + const initialDragState: ElementDragState = { isDragging: false, elementId: null, @@ -162,6 +165,7 @@ export function useElementInteraction({ onSnapPointChange, }: UseElementInteractionProps) { const editor = useEditor(); + const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled); const isShiftHeldRef = useShiftKey(); const tracks = editor.timeline.getTracks(); const { @@ -441,27 +445,33 @@ export function useElementInteraction({ return; } - if (dropTarget.isNewTrack) { - const newTrackId = generateUUID(); + if (dropTarget.isNewTrack) { + const newTrackId = generateUUID(); + editor.timeline.moveElement({ + sourceTrackId: dragState.trackId, + targetTrackId: newTrackId, + elementId: dragState.elementId, + newStartTime: snappedTime, + createTrack: { type: sourceTrack.type, index: dropTarget.trackIndex }, + rippleEnabled: rippleEditingEnabled, + }); + selectElement({ trackId: newTrackId, elementId: dragState.elementId }); + } else { + const targetTrack = tracks[dropTarget.trackIndex]; + if (targetTrack) { editor.timeline.moveElement({ sourceTrackId: dragState.trackId, - targetTrackId: newTrackId, + targetTrackId: targetTrack.id, elementId: dragState.elementId, newStartTime: snappedTime, - createTrack: { type: sourceTrack.type, index: dropTarget.trackIndex }, + rippleEnabled: rippleEditingEnabled, }); - } else { - const targetTrack = tracks[dropTarget.trackIndex]; - if (targetTrack) { - editor.timeline.moveElement({ - sourceTrackId: dragState.trackId, - targetTrackId: targetTrack.id, - elementId: dragState.elementId, - newStartTime: snappedTime, - }); + if (targetTrack.id !== dragState.trackId) { + selectElement({ trackId: targetTrack.id, elementId: dragState.elementId }); } } + } endDrag(); onSnapPointChange?.(null); @@ -483,6 +493,8 @@ export function useElementInteraction({ tracksContainerRef, tracksScrollRef, headerRef, + rippleEditingEnabled, + selectElement, ]); useEffect(() => { @@ -508,10 +520,10 @@ export function useElementInteraction({ element: TimelineElement; track: TimelineTrack; }) => { - const isRightClick = event.button === 2; + const isRightClick = event.button === MOUSE_BUTTON_RIGHT; - // right-click: don't stop propagation so ContextMenu can open - if (isRightClick) { + // right-click: don't stop propagation so ContextMenu can open + if (isRightClick) { const alreadySelected = isElementSelected({ trackId: track.id, elementId: element.id, @@ -526,14 +538,12 @@ export function useElementInteraction({ return; } - // left-click: stop propagation for drag operations - event.stopPropagation(); - mouseDownLocationRef.current = { x: event.clientX, y: event.clientY }; + event.stopPropagation(); + mouseDownLocationRef.current = { x: event.clientX, y: event.clientY }; - const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey; + const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey; - // multi-select: toggle selection - if (isMultiSelect) { + if (isMultiSelect) { handleSelectionClick({ trackId: track.id, elementId: element.id, @@ -541,8 +551,7 @@ export function useElementInteraction({ }); } - // start drag - const clickOffsetTime = getClickOffsetTime({ + const clickOffsetTime = getClickOffsetTime({ clientX: event.clientX, elementRect: event.currentTarget.getBoundingClientRect(), zoomLevel, @@ -570,10 +579,9 @@ export function useElementInteraction({ element: TimelineElement; track: TimelineTrack; }) => { - event.stopPropagation(); + event.stopPropagation(); - // was it a drag or a click? - if (mouseDownLocationRef.current) { + if (mouseDownLocationRef.current) { const deltaX = Math.abs(event.clientX - mouseDownLocationRef.current.x); const deltaY = Math.abs(event.clientY - mouseDownLocationRef.current.y); if (deltaX > DRAG_THRESHOLD_PX || deltaY > DRAG_THRESHOLD_PX) { @@ -585,16 +593,18 @@ export function useElementInteraction({ // modifier keys already handled in mousedown if (event.metaKey || event.ctrlKey || event.shiftKey) return; - // single click: select if not selected - const alreadySelected = isElementSelected({ + const alreadySelected = isElementSelected({ trackId: track.id, elementId: element.id, }); if (!alreadySelected) { selectElement({ trackId: track.id, elementId: element.id }); + return; } + + editor.selection.clearKeyframeSelection(); }, - [isElementSelected, selectElement], + [editor.selection, isElementSelected, selectElement], ); return { diff --git a/apps/web/src/hooks/timeline/element/use-element-resize.ts b/apps/web/src/hooks/timeline/element/use-element-resize.ts index 2b303ad9..3f49e798 100644 --- a/apps/web/src/hooks/timeline/element/use-element-resize.ts +++ b/apps/web/src/hooks/timeline/element/use-element-resize.ts @@ -40,7 +40,9 @@ export function useTimelineElementResize({ const activeProject = editor.project.getActive(); const isShiftHeldRef = useShiftKey(); const snappingEnabled = useTimelineStore((state) => state.snappingEnabled); - + const rippleEditingEnabled = useTimelineStore( + (state) => state.rippleEditingEnabled, + ); const [resizing, setResizing] = useState<ResizeState | null>(null); const [currentTrimStart, setCurrentTrimStart] = useState(element.trimStart); @@ -86,12 +88,8 @@ export function useTimelineElementResize({ }; const canExtendElementDuration = useCallback(() => { - if (element.type === "text" || element.type === "image") { - return true; - } - - return false; - }, [element.type]); + return element.sourceDuration == null; + }, [element.sourceDuration]); const updateTrimFromMouseMove = useCallback( ({ clientX }: { clientX: number }) => { @@ -141,18 +139,52 @@ export function useTimelineElementResize({ } onSnapPointChange?.(resizeSnapPoint); + const otherElements = track.elements.filter(({ id }) => id !== element.id); + const initialEndTime = resizing.initialStartTime + resizing.initialDuration; + + const rightNeighborBound = + resizing.side === "right" + ? otherElements + .filter(({ startTime }) => startTime >= initialEndTime) + .reduce((min, { startTime }) => Math.min(min, startTime), Infinity) + : Infinity; + + const leftNeighborBound = + resizing.side === "left" + ? otherElements + .filter( + ({ startTime, duration }) => + startTime + duration <= resizing.initialStartTime, + ) + .reduce( + (max, { startTime, duration }) => + Math.max(max, startTime + duration), + -Infinity, + ) + : -Infinity; + if (resizing.side === "left") { const sourceDuration = resizing.initialTrimStart + resizing.initialDuration + resizing.initialTrimEnd; + const minTrimStartForNeighbor = Number.isFinite(leftNeighborBound) + ? Math.max( + 0, + resizing.initialTrimStart + + (leftNeighborBound - resizing.initialStartTime), + ) + : 0; const maxAllowed = sourceDuration - resizing.initialTrimEnd - minDurationSeconds; const calculated = resizing.initialTrimStart + deltaTime; if (calculated >= 0 && calculated <= maxAllowed) { const newTrimStart = snapTimeToFrame({ - time: Math.min(maxAllowed, calculated), + time: Math.min( + maxAllowed, + Math.max(minTrimStartForNeighbor, calculated), + ), fps: projectFps, }); const trimDelta = newTrimStart - resizing.initialTrimStart; @@ -175,7 +207,16 @@ export function useTimelineElementResize({ if (canExtendElementDuration()) { const extensionAmount = Math.abs(calculated); const maxExtension = resizing.initialStartTime; - const actualExtension = Math.min(extensionAmount, maxExtension); + const actualExtension = Math.max( + 0, + Number.isFinite(leftNeighborBound) + ? Math.min( + extensionAmount, + maxExtension, + resizing.initialStartTime - leftNeighborBound, + ) + : Math.min(extensionAmount, maxExtension), + ); const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime - actualExtension, fps: projectFps, @@ -192,7 +233,8 @@ export function useTimelineElementResize({ currentStartTimeRef.current = newStartTime; currentDurationRef.current = newDuration; } else { - const trimDelta = 0 - resizing.initialTrimStart; + const trimDelta = + minTrimStartForNeighbor - resizing.initialTrimStart; const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime + trimDelta, fps: projectFps, @@ -202,10 +244,10 @@ export function useTimelineElementResize({ fps: projectFps, }); - setCurrentTrimStart(0); + setCurrentTrimStart(minTrimStartForNeighbor); setCurrentStartTime(newStartTime); setCurrentDuration(newDuration); - currentTrimStartRef.current = 0; + currentTrimStartRef.current = minTrimStartForNeighbor; currentStartTimeRef.current = newStartTime; currentDurationRef.current = newDuration; } @@ -216,6 +258,9 @@ export function useTimelineElementResize({ resizing.initialDuration + resizing.initialTrimEnd; const newTrimEnd = resizing.initialTrimEnd - deltaTime; + const maxAllowedDuration = Number.isFinite(rightNeighborBound) + ? rightNeighborBound - resizing.initialStartTime + : Infinity; if (newTrimEnd < 0) { if (canExtendElementDuration()) { @@ -223,7 +268,7 @@ export function useTimelineElementResize({ const baseDuration = resizing.initialDuration + resizing.initialTrimEnd; const newDuration = snapTimeToFrame({ - time: baseDuration + extensionNeeded, + time: Math.min(baseDuration + extensionNeeded, maxAllowedDuration), fps: projectFps, }); @@ -234,7 +279,10 @@ export function useTimelineElementResize({ } else { const extensionToLimit = resizing.initialTrimEnd; const newDuration = snapTimeToFrame({ - time: resizing.initialDuration + extensionToLimit, + time: Math.min( + resizing.initialDuration + extensionToLimit, + maxAllowedDuration, + ), fps: projectFps, }); @@ -244,9 +292,20 @@ export function useTimelineElementResize({ currentTrimEndRef.current = 0; } } else { + const minTrimEndForNeighbor = Number.isFinite(maxAllowedDuration) + ? Math.max( + 0, + resizing.initialDuration + + resizing.initialTrimEnd - + maxAllowedDuration, + ) + : 0; const maxTrimEnd = sourceDuration - resizing.initialTrimStart - minDurationSeconds; - const clampedTrimEnd = Math.min(maxTrimEnd, Math.max(0, newTrimEnd)); + const clampedTrimEnd = Math.min( + maxTrimEnd, + Math.max(minTrimEndForNeighbor, newTrimEnd), + ); const finalTrimEnd = snapTimeToFrame({ time: clampedTrimEnd, fps: projectFps, @@ -271,6 +330,7 @@ export function useTimelineElementResize({ snappingEnabled, editor, element.id, + track.elements, onSnapPointChange, canExtendElementDuration, isShiftHeldRef, @@ -289,26 +349,14 @@ export function useTimelineElementResize({ const startTimeChanged = finalStartTime !== resizing.initialStartTime; const durationChanged = finalDuration !== resizing.initialDuration; - if (trimStartChanged || trimEndChanged) { + if (trimStartChanged || trimEndChanged || startTimeChanged || durationChanged) { editor.timeline.updateElementTrim({ elementId: element.id, trimStart: finalTrimStart, trimEnd: finalTrimEnd, - }); - } - - if (startTimeChanged) { - editor.timeline.updateElementStartTime({ - elements: [{ trackId: track.id, elementId: element.id }], - startTime: finalStartTime, - }); - } - - if (durationChanged) { - editor.timeline.updateElementDuration({ - trackId: track.id, - elementId: element.id, - duration: finalDuration, + startTime: startTimeChanged ? finalStartTime : undefined, + duration: durationChanged ? finalDuration : undefined, + rippleEnabled: rippleEditingEnabled, }); } @@ -319,9 +367,9 @@ export function useTimelineElementResize({ resizing, editor.timeline, element.id, - track.id, onResizeStateChange, onSnapPointChange, + rippleEditingEnabled, ]); useEffect(() => { diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts b/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts new file mode 100644 index 00000000..b18db6eb --- /dev/null +++ b/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts @@ -0,0 +1,302 @@ +import { + useState, + useCallback, + useEffect, + useRef, + type MouseEvent as ReactMouseEvent, +} from "react"; +import { useEditor } from "@/hooks/use-editor"; +import { useKeyframeSelection } from "./use-keyframe-selection"; +import { snapTimeToFrame, getSnappedSeekTime } from "@/lib/time"; +import { timelineTimeToSnappedPixels } from "@/lib/timeline"; +import { + DRAG_THRESHOLD_PX, + TIMELINE_CONSTANTS, +} from "@/constants/timeline-constants"; +import { RetimeKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/retime-keyframe"; +import { BatchCommand } from "@/lib/commands"; +import type { SelectedKeyframeRef } from "@/types/animation"; +import type { TimelineElement } from "@/types/timeline"; +import type { Command } from "@/lib/commands/base-command"; +export interface KeyframeDragState { + isDragging: boolean; + draggingKeyframeIds: Set<string>; + deltaTime: number; +} + +const initialDragState: KeyframeDragState = { + isDragging: false, + draggingKeyframeIds: new Set(), + deltaTime: 0, +}; + +interface PendingKeyframeDrag { + keyframeRefs: SelectedKeyframeRef[]; + startMouseX: number; +} + +export function useKeyframeDrag({ + zoomLevel, + element, + displayedStartTime, +}: { + zoomLevel: number; + element: TimelineElement; + displayedStartTime: number; +}) { + const editor = useEditor(); + const { + selectedKeyframes, + isKeyframeSelected, + setKeyframeSelection, + toggleKeyframeSelection, + selectKeyframeRange, + } = useKeyframeSelection(); + + const [dragState, setDragState] = + useState<KeyframeDragState>(initialDragState); + const [isPendingDrag, setIsPendingDrag] = useState(false); + + const pendingDragRef = useRef<PendingKeyframeDrag | null>(null); + const mouseDownXRef = useRef<number | null>(null); + + const activeProject = editor.project.getActive(); + const fps = activeProject.settings.fps; + + const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + + const endDrag = useCallback(() => { + setDragState(initialDragState); + }, []); + + const commitDrag = useCallback( + ({ + keyframeRefs, + deltaTime, + }: { + keyframeRefs: SelectedKeyframeRef[]; + deltaTime: number; + }) => { + const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => { + const channel = element.animations?.channels[keyframeRef.propertyPath]; + const keyframe = channel?.keyframes.find( + (keyframe) => keyframe.id === keyframeRef.keyframeId, + ); + if (!keyframe) return []; + const nextTime = Math.max( + 0, + Math.min(element.duration, keyframe.time + deltaTime), + ); + return [ + new RetimeKeyframeCommand({ + trackId: keyframeRef.trackId, + elementId: keyframeRef.elementId, + propertyPath: keyframeRef.propertyPath, + keyframeId: keyframeRef.keyframeId, + nextTime, + }), + ]; + }); + + if (commands.length === 1) { + editor.command.execute({ command: commands[0] }); + } else if (commands.length > 1) { + editor.command.execute({ command: new BatchCommand(commands) }); + } + }, + [editor.command, element], + ); + + useEffect(() => { + if (!dragState.isDragging && !isPendingDrag) return; + + const handleMouseMove = ({ clientX }: MouseEvent) => { + if (isPendingDrag && pendingDragRef.current) { + const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX); + if (deltaX <= DRAG_THRESHOLD_PX) return; + + const pending = pendingDragRef.current; + pendingDragRef.current = null; + setIsPendingDrag(false); + setDragState({ + isDragging: true, + draggingKeyframeIds: new Set( + pending.keyframeRefs.map((keyframe) => keyframe.keyframeId), + ), + deltaTime: 0, + }); + return; + } + + if (!dragState.isDragging) return; + + const startX = mouseDownXRef.current ?? clientX; + const rawDelta = (clientX - startX) / pixelsPerSecond; + const snappedDelta = snapTimeToFrame({ time: rawDelta, fps }); + + setDragState((previous) => ({ ...previous, deltaTime: snappedDelta })); + }; + + document.addEventListener("mousemove", handleMouseMove); + return () => document.removeEventListener("mousemove", handleMouseMove); + }, [dragState.isDragging, isPendingDrag, pixelsPerSecond, fps]); + + useEffect(() => { + if (!dragState.isDragging) return; + + const handleMouseUp = () => { + const draggingRefs = selectedKeyframes.filter( + (keyframe) => + keyframe.elementId === element.id && + dragState.draggingKeyframeIds.has(keyframe.keyframeId), + ); + + if (draggingRefs.length > 0 && dragState.deltaTime !== 0) { + commitDrag({ + keyframeRefs: draggingRefs, + deltaTime: dragState.deltaTime, + }); + } + + endDrag(); + }; + + document.addEventListener("mouseup", handleMouseUp); + return () => document.removeEventListener("mouseup", handleMouseUp); + }, [ + dragState.isDragging, + dragState.draggingKeyframeIds, + dragState.deltaTime, + selectedKeyframes, + element.id, + commitDrag, + endDrag, + ]); + + useEffect(() => { + if (!isPendingDrag) return; + + const handleMouseUp = () => { + pendingDragRef.current = null; + setIsPendingDrag(false); + }; + + document.addEventListener("mouseup", handleMouseUp); + return () => document.removeEventListener("mouseup", handleMouseUp); + }, [isPendingDrag]); + + const handleKeyframeMouseDown = useCallback( + ({ + event, + keyframes, + }: { + event: ReactMouseEvent; + keyframes: SelectedKeyframeRef[]; + }) => { + event.preventDefault(); + event.stopPropagation(); + + mouseDownXRef.current = event.clientX; + + const anySelected = keyframes.some((keyframe) => + isKeyframeSelected({ keyframe }), + ); + + const isModifierKey = event.shiftKey || event.metaKey || event.ctrlKey; + if (!anySelected && !isModifierKey) { + setKeyframeSelection({ keyframes }); + } + + const keyframeRefsToTrack = anySelected ? selectedKeyframes : keyframes; + + pendingDragRef.current = { + keyframeRefs: keyframeRefsToTrack, + startMouseX: event.clientX, + }; + setIsPendingDrag(true); + }, + [isKeyframeSelected, selectedKeyframes, setKeyframeSelection], +); + + const handleKeyframeClick = useCallback( + ({ + event, + keyframes, + orderedKeyframes, + indicatorTime, + }: { + event: ReactMouseEvent; + keyframes: SelectedKeyframeRef[]; + orderedKeyframes: SelectedKeyframeRef[]; + indicatorTime: number; + }) => { + event.stopPropagation(); + + const wasDrag = + mouseDownXRef.current !== null && + Math.abs(event.clientX - mouseDownXRef.current) > DRAG_THRESHOLD_PX; + mouseDownXRef.current = null; + + if (wasDrag) return; + + const duration = editor.timeline.getTotalDuration(); + const seekTime = getSnappedSeekTime({ + rawTime: displayedStartTime + indicatorTime, + duration, + fps, + }); + editor.playback.seek({ time: seekTime }); + + if (event.shiftKey) { + selectKeyframeRange({ + orderedKeyframes, + targetKeyframes: keyframes, + isAdditive: event.metaKey || event.ctrlKey, + }); + return; + } + + toggleKeyframeSelection({ + keyframes, + isMultiKey: event.metaKey || event.ctrlKey, + }); + }, + [toggleKeyframeSelection, selectKeyframeRange, editor, displayedStartTime, fps], + ); + + const getVisualOffsetPx = useCallback( + ({ + indicatorTime, + indicatorOffsetPx, + isBeingDragged, + displayedStartTime, + elementLeft, + }: { + indicatorTime: number; + indicatorOffsetPx: number; + isBeingDragged: boolean; + displayedStartTime: number; + elementLeft: number; + }): number => { + if (!isBeingDragged) return indicatorOffsetPx; + const clampedTime = Math.max( + 0, + Math.min(element.duration, indicatorTime + dragState.deltaTime), + ); + return ( + timelineTimeToSnappedPixels({ + time: displayedStartTime + clampedTime, + zoomLevel, + }) - elementLeft + ); + }, + [dragState.deltaTime, element.duration, zoomLevel], + ); + + return { + keyframeDragState: dragState, + handleKeyframeMouseDown, + handleKeyframeClick, + getVisualOffsetPx, + }; +} diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts b/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts new file mode 100644 index 00000000..0ab05fe3 --- /dev/null +++ b/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts @@ -0,0 +1,229 @@ +import { useCallback, useSyncExternalStore } from "react"; +import { useEditor } from "@/hooks/use-editor"; +import type { SelectedKeyframeRef } from "@/types/animation"; + +function getSelectedKeyframeId({ + keyframe, +}: { + keyframe: SelectedKeyframeRef; +}): string { + return `${keyframe.trackId}:${keyframe.elementId}:${keyframe.propertyPath}:${keyframe.keyframeId}`; +} + +function mergeUniqueKeyframes({ + keyframes, +}: { + keyframes: SelectedKeyframeRef[]; +}): SelectedKeyframeRef[] { + const keyframesById = new Map<string, SelectedKeyframeRef>(); + for (const keyframe of keyframes) { + keyframesById.set(getSelectedKeyframeId({ keyframe }), keyframe); + } + return [...keyframesById.values()]; +} + +export function useKeyframeSelection() { + const editor = useEditor(); + const selectedKeyframes = useSyncExternalStore( + (listener) => editor.selection.subscribe(listener), + () => editor.selection.getSelectedKeyframes(), + ); + const keyframeSelectionAnchor = useSyncExternalStore( + (listener) => editor.selection.subscribe(listener), + () => editor.selection.getKeyframeSelectionAnchor(), + ); + + const isKeyframeSelected = useCallback( + ({ keyframe }: { keyframe: SelectedKeyframeRef }) => { + const keyframeId = getSelectedKeyframeId({ keyframe }); + return selectedKeyframes.some( + (selectedKeyframe) => + getSelectedKeyframeId({ keyframe: selectedKeyframe }) === keyframeId, + ); + }, + [selectedKeyframes], + ); + + const setKeyframeSelection = useCallback( + ({ + keyframes, + anchorKeyframe, + }: { + keyframes: SelectedKeyframeRef[]; + anchorKeyframe?: SelectedKeyframeRef; + }) => { + const uniqueKeyframes = mergeUniqueKeyframes({ keyframes }); + editor.selection.setSelectedKeyframes({ + keyframes: uniqueKeyframes, + anchorKeyframe: + anchorKeyframe ?? uniqueKeyframes[uniqueKeyframes.length - 1] ?? null, + }); + }, + [editor], + ); + + const addKeyframesToSelection = useCallback( + ({ + keyframes, + anchorKeyframe, + }: { + keyframes: SelectedKeyframeRef[]; + anchorKeyframe?: SelectedKeyframeRef; + }) => { + const mergedKeyframes = mergeUniqueKeyframes({ + keyframes: [...selectedKeyframes, ...keyframes], + }); + editor.selection.setSelectedKeyframes({ + keyframes: mergedKeyframes, + anchorKeyframe: + anchorKeyframe ?? mergedKeyframes[mergedKeyframes.length - 1] ?? null, + }); + }, + [selectedKeyframes, editor], + ); + + const removeKeyframesFromSelection = useCallback( + ({ + keyframes, + anchorKeyframe, + }: { + keyframes: SelectedKeyframeRef[]; + anchorKeyframe?: SelectedKeyframeRef; + }) => { + const keyframeIdsToRemove = new Set( + keyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })), + ); + const nextKeyframes = selectedKeyframes.filter( + (selectedKeyframe) => + !keyframeIdsToRemove.has( + getSelectedKeyframeId({ keyframe: selectedKeyframe }), + ), + ); + editor.selection.setSelectedKeyframes({ + keyframes: nextKeyframes, + anchorKeyframe: + anchorKeyframe ?? nextKeyframes[nextKeyframes.length - 1] ?? null, + }); + }, + [selectedKeyframes, editor], + ); + + const clearKeyframeSelection = useCallback(() => { + editor.selection.clearKeyframeSelection(); + }, [editor]); + + const toggleKeyframeSelection = useCallback( + ({ + keyframes, + isMultiKey, + }: { + keyframes: SelectedKeyframeRef[]; + isMultiKey: boolean; + }) => { + const anchorKeyframe = keyframes[0]; + const areAllKeyframesSelected = keyframes.every((keyframe) => + isKeyframeSelected({ keyframe }), + ); + if (!isMultiKey) { + setKeyframeSelection({ keyframes, anchorKeyframe }); + return; + } + + if (areAllKeyframesSelected) { + removeKeyframesFromSelection({ keyframes, anchorKeyframe }); + return; + } + + addKeyframesToSelection({ keyframes, anchorKeyframe }); + }, + [ + setKeyframeSelection, + isKeyframeSelected, + removeKeyframesFromSelection, + addKeyframesToSelection, + ], + ); + + const selectKeyframeRange = useCallback( + ({ + orderedKeyframes, + targetKeyframes, + isAdditive, + }: { + orderedKeyframes: SelectedKeyframeRef[]; + targetKeyframes: SelectedKeyframeRef[]; + isAdditive: boolean; + }) => { + if (orderedKeyframes.length === 0 || targetKeyframes.length === 0) { + return; + } + + const anchorKeyframe = + keyframeSelectionAnchor ?? + selectedKeyframes[selectedKeyframes.length - 1] ?? + targetKeyframes[0]; + if (!anchorKeyframe) { + return; + } + + const targetKeyframeIds = new Set( + targetKeyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })), + ); + const anchorId = getSelectedKeyframeId({ keyframe: anchorKeyframe }); + const anchorIndex = orderedKeyframes.findIndex( + (keyframe) => getSelectedKeyframeId({ keyframe }) === anchorId, + ); + if (anchorIndex === -1) { + if (isAdditive) { + addKeyframesToSelection({ + keyframes: targetKeyframes, + anchorKeyframe, + }); + return; + } + setKeyframeSelection({ keyframes: targetKeyframes, anchorKeyframe }); + return; + } + + const targetIndexes = orderedKeyframes + .map((keyframe, index) => ({ + keyframeId: getSelectedKeyframeId({ keyframe }), + index, + })) + .filter(({ keyframeId }) => targetKeyframeIds.has(keyframeId)) + .map(({ index }) => index); + if (targetIndexes.length === 0) { + return; + } + + const rangeStart = Math.min(anchorIndex, ...targetIndexes); + const rangeEnd = Math.max(anchorIndex, ...targetIndexes); + const rangeKeyframes = orderedKeyframes.slice(rangeStart, rangeEnd + 1); + + if (isAdditive) { + addKeyframesToSelection({ keyframes: rangeKeyframes, anchorKeyframe }); + return; + } + + setKeyframeSelection({ keyframes: rangeKeyframes, anchorKeyframe }); + }, + [ + keyframeSelectionAnchor, + selectedKeyframes, + addKeyframesToSelection, + setKeyframeSelection, + ], + ); + + return { + selectedKeyframes, + keyframeSelectionAnchor, + isKeyframeSelected, + setKeyframeSelection, + addKeyframesToSelection, + removeKeyframesFromSelection, + clearKeyframeSelection, + toggleKeyframeSelection, + selectKeyframeRange, + }; +} diff --git a/apps/web/src/hooks/timeline/use-selection-box.ts b/apps/web/src/hooks/timeline/use-selection-box.ts index 12c6bf8a..c4f8d6ce 100644 --- a/apps/web/src/hooks/timeline/use-selection-box.ts +++ b/apps/web/src/hooks/timeline/use-selection-box.ts @@ -5,6 +5,7 @@ import { useEditor } from "../use-editor"; interface UseSelectionBoxProps { containerRef: React.RefObject<HTMLElement | null>; + headerRef: React.RefObject<HTMLElement | null>; onSelectionComplete: ( elements: { trackId: string; elementId: string }[], ) => void; @@ -88,6 +89,7 @@ function isRectangleIntersecting({ export function useSelectionBox({ containerRef, + headerRef, onSelectionComplete, isEnabled = true, tracksScrollRef, @@ -131,6 +133,8 @@ export function useSelectionBox({ endPos, }); const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const timelineHeaderHeight = + headerRef.current?.getBoundingClientRect().height ?? 0; const selectedElements: { trackId: string; elementId: string }[] = []; for (const [trackIndex, track] of tracks.entries()) { @@ -139,8 +143,9 @@ export function useSelectionBox({ trackIndex, }); const trackHeight = getTrackHeight({ type: track.type }); - const elementTop = trackTop; - const elementBottom = trackTop + trackHeight; + const elementTop = + timelineHeaderHeight + TIMELINE_CONSTANTS.PADDING_TOP_PX + trackTop; + const elementBottom = elementTop + trackHeight; for (const element of track.elements) { const elementLeft = element.startTime * pixelsPerSecond; @@ -168,7 +173,14 @@ export function useSelectionBox({ } onSelectionComplete(selectedElements); }, - [containerRef, onSelectionComplete, tracks, tracksScrollRef, zoomLevel], + [ + containerRef, + headerRef, + onSelectionComplete, + tracks, + tracksScrollRef, + zoomLevel, + ], ); useEffect(() => { diff --git a/apps/web/src/hooks/timeline/use-snap-indicator-position.ts b/apps/web/src/hooks/timeline/use-snap-indicator-position.ts index d78eb04d..f3e63894 100644 --- a/apps/web/src/hooks/timeline/use-snap-indicator-position.ts +++ b/apps/web/src/hooks/timeline/use-snap-indicator-position.ts @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { timelineTimeToSnappedPixels } from "@/lib/timeline"; import type { TimelineTrack } from "@/types/timeline"; interface UseSnapIndicatorPositionParams { @@ -50,8 +50,10 @@ export function useSnapIndicatorPosition({ ? trackLabelsRef.current.offsetWidth : 0; - const timelinePosition = - (snapPoint?.time || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const timelinePosition = timelineTimeToSnappedPixels({ + time: snapPoint?.time ?? 0, + zoomLevel, + }); const leftPosition = trackLabelsWidth + timelinePosition - scrollLeft; return { diff --git a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts index 632d2c78..8a30785e 100644 --- a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts +++ b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts @@ -8,6 +8,7 @@ import { buildTextElement, buildStickerElement, buildElementFromMedia, + buildEffectElement, } from "@/lib/timeline/element-utils"; import type { Command } from "@/lib/commands/base-command"; import { AddMediaAssetCommand } from "@/lib/commands/media"; @@ -16,17 +17,23 @@ import { BatchCommand } from "@/lib/commands"; import { computeDropTarget } from "@/lib/timeline/drop-utils"; import { getDragData, hasDragData } from "@/lib/drag-data"; import type { TrackType, DropTarget, ElementType } from "@/types/timeline"; -import type { MediaDragData, StickerDragData } from "@/types/drag"; +import type { + MediaDragData, + StickerDragData, + EffectDragData, +} from "@/types/drag"; interface UseTimelineDragDropProps { containerRef: RefObject<HTMLDivElement | null>; headerRef?: RefObject<HTMLElement | null>; + tracksScrollRef?: RefObject<HTMLDivElement | null>; zoomLevel: number; } export function useTimelineDragDrop({ containerRef, headerRef, + tracksScrollRef, zoomLevel, }: UseTimelineDragDropProps) { const editor = useEditor(); @@ -54,6 +61,7 @@ export function useTimelineDragDrop({ if (dragData.type === "text") return "text"; if (dragData.type === "sticker") return "sticker"; + if (dragData.type === "effect") return "effect"; if (dragData.type === "media") { return dragData.mediaType; } @@ -70,7 +78,11 @@ export function useTimelineDragDrop({ elementType: ElementType; mediaId?: string; }): number => { - if (elementType === "text" || elementType === "sticker") { + if ( + elementType === "text" || + elementType === "sticker" || + elementType === "effect" + ) { return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; } if (mediaId) { @@ -94,11 +106,16 @@ export function useTimelineDragDrop({ (e: React.DragEvent) => { e.preventDefault(); - const rect = containerRef.current?.getBoundingClientRect(); - if (!rect) return; + const scrollContainer = tracksScrollRef?.current; + const referenceRect = + scrollContainer?.getBoundingClientRect() ?? + containerRef.current?.getBoundingClientRect(); + if (!referenceRect) return; const headerHeight = headerRef?.current?.getBoundingClientRect().height ?? 0; + const scrollLeft = scrollContainer?.scrollLeft ?? 0; + const scrollTop = scrollContainer?.scrollTop ?? 0; const hasFiles = e.dataTransfer.types.includes("Files"); const isExternal = hasFiles && !hasDragData({ dataTransfer: e.dataTransfer }); @@ -121,8 +138,15 @@ export function useTimelineDragDrop({ mediaId: dragData?.type === "media" ? dragData.id : undefined, }); - const mouseX = e.clientX - rect.left; - const mouseY = Math.max(0, e.clientY - rect.top - headerHeight); + const mouseX = e.clientX - referenceRect.left + scrollLeft; + const mouseY = e.clientY - referenceRect.top + scrollTop - headerHeight; + + const targetElementTypes = + dragData?.type === "effect" + ? (dragData as EffectDragData).targetElementTypes + : dragData?.type === "media" + ? (dragData as MediaDragData).targetElementTypes + : undefined; const target = computeDropTarget({ elementType, @@ -134,6 +158,7 @@ export function useTimelineDragDrop({ elementDuration: duration, pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND, zoomLevel, + targetElementTypes, }); target.xPosition = getSnappedTime({ time: target.xPosition }); @@ -144,6 +169,7 @@ export function useTimelineDragDrop({ [ containerRef, headerRef, + tracksScrollRef, tracks, currentTime, zoomLevel, @@ -182,19 +208,6 @@ export function useTimelineDragDrop({ target: DropTarget; dragData: { name?: string; content?: string }; }) => { - let trackId: string; - - if (target.isNewTrack) { - trackId = editor.timeline.addTrack({ - type: "text", - index: target.trackIndex, - }); - } else { - const track = tracks[target.trackIndex]; - if (!track) return; - trackId = track.id; - } - const element = buildTextElement({ raw: { name: dragData.name ?? "", @@ -203,12 +216,26 @@ export function useTimelineDragDrop({ startTime: target.xPosition, }); + if (target.isNewTrack) { + const addTrackCmd = new AddTrackCommand("text", target.trackIndex); + const insertCmd = new InsertElementCommand({ + element, + placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, + }); + editor.command.execute({ + command: new BatchCommand([addTrackCmd, insertCmd]), + }); + return; + } + + const track = tracks[target.trackIndex]; + if (!track) return; editor.timeline.insertElement({ - placement: { mode: "explicit", trackId }, + placement: { mode: "explicit", trackId: track.id }, element, }); }, - [editor.timeline, tracks], + [editor.command, editor.timeline, tracks], ); const executeStickerDrop = useCallback( @@ -219,52 +246,46 @@ export function useTimelineDragDrop({ target: DropTarget; dragData: StickerDragData; }) => { - let trackId: string; - - if (target.isNewTrack) { - trackId = editor.timeline.addTrack({ - type: "sticker", - index: target.trackIndex, - }); - } else { - const track = tracks[target.trackIndex]; - if (!track) return; - trackId = track.id; - } - const element = buildStickerElement({ stickerId: dragData.stickerId, name: dragData.name, startTime: target.xPosition, }); + if (target.isNewTrack) { + const addTrackCmd = new AddTrackCommand("sticker", target.trackIndex); + const insertCmd = new InsertElementCommand({ + element, + placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, + }); + editor.command.execute({ + command: new BatchCommand([addTrackCmd, insertCmd]), + }); + return; + } + + const track = tracks[target.trackIndex]; + if (!track) return; editor.timeline.insertElement({ - placement: { mode: "explicit", trackId }, + placement: { mode: "explicit", trackId: track.id }, element, }); }, - [editor.timeline, tracks], + [editor.command, editor.timeline, tracks], ); const executeMediaDrop = useCallback( ({ target, dragData }: { target: DropTarget; dragData: MediaDragData }) => { + if (target.targetElement) { + toast.info("Replace media source is coming soon!"); + return; + } + const mediaAsset = mediaAssets.find((m) => m.id === dragData.id); if (!mediaAsset) return; const trackType: TrackType = dragData.mediaType === "audio" ? "audio" : "video"; - let trackId: string; - - if (target.isNewTrack) { - trackId = editor.timeline.addTrack({ - type: trackType, - index: target.trackIndex, - }); - } else { - const track = tracks[target.trackIndex]; - if (!track) return; - trackId = track.id; - } const duration = mediaAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; @@ -276,12 +297,80 @@ export function useTimelineDragDrop({ startTime: target.xPosition, }); + if (target.isNewTrack) { + const addTrackCmd = new AddTrackCommand(trackType, target.trackIndex); + const insertCmd = new InsertElementCommand({ + element, + placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, + }); + editor.command.execute({ + command: new BatchCommand([addTrackCmd, insertCmd]), + }); + return; + } + + const track = tracks[target.trackIndex]; + if (!track) return; + editor.timeline.insertElement({ + placement: { mode: "explicit", trackId: track.id }, + element, + }); + }, + [editor.command, editor.timeline, mediaAssets, tracks], + ); + + const executeEffectDrop = useCallback( + ({ + target, + dragData, + }: { + target: DropTarget; + dragData: EffectDragData; + }) => { + if (target.targetElement) { + editor.timeline.addClipEffect({ + trackId: target.targetElement.trackId, + elementId: target.targetElement.elementId, + effectType: dragData.effectType, + }); + return; + } + + const effectTrack = tracks.find((t) => t.type === "effect"); + let trackId: string; + + if (effectTrack) { + trackId = effectTrack.id; + } else if (target.isNewTrack) { + const addTrackCmd = new AddTrackCommand("effect", target.trackIndex); + const insertCmd = new InsertElementCommand({ + element: buildEffectElement({ + effectType: dragData.effectType, + startTime: target.xPosition, + }), + placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, + }); + editor.command.execute({ + command: new BatchCommand([addTrackCmd, insertCmd]), + }); + return; + } else { + const track = tracks[target.trackIndex]; + if (!track || track.type !== "effect") return; + trackId = track.id; + } + + const element = buildEffectElement({ + effectType: dragData.effectType, + startTime: target.xPosition, + }); + editor.timeline.insertElement({ placement: { mode: "explicit", trackId }, element, }); }, - [editor.timeline, mediaAssets, tracks], + [editor.command, editor.timeline, tracks], ); const executeFileDrop = useCallback( @@ -384,16 +473,27 @@ export function useTimelineDragDrop({ executeTextDrop({ target: currentTarget, dragData }); } else if (dragData.type === "sticker") { executeStickerDrop({ target: currentTarget, dragData }); + } else if (dragData.type === "effect") { + executeEffectDrop({ + target: currentTarget, + dragData: dragData as EffectDragData, + }); } else { executeMediaDrop({ target: currentTarget, dragData }); } } else if (hasFiles) { - const rect = containerRef.current?.getBoundingClientRect(); - if (!rect) return; - const mouseX = e.clientX - rect.left; + const scrollContainer = tracksScrollRef?.current; + const referenceRect = + scrollContainer?.getBoundingClientRect() ?? + containerRef.current?.getBoundingClientRect(); + if (!referenceRect) return; + const scrollLeft = scrollContainer?.scrollLeft ?? 0; + const scrollTop = scrollContainer?.scrollTop ?? 0; + const mouseX = e.clientX - referenceRect.left + scrollLeft; const headerHeight = headerRef?.current?.getBoundingClientRect().height ?? 0; - const mouseY = Math.max(0, e.clientY - rect.top - headerHeight); + const mouseY = + e.clientY - referenceRect.top + scrollTop - headerHeight; await executeFileDrop({ files: Array.from(e.dataTransfer.files), mouseX, @@ -410,9 +510,11 @@ export function useTimelineDragDrop({ executeTextDrop, executeStickerDrop, executeMediaDrop, + executeEffectDrop, executeFileDrop, containerRef, headerRef, + tracksScrollRef, ], ); diff --git a/apps/web/src/hooks/use-effect-preview.ts b/apps/web/src/hooks/use-effect-preview.ts new file mode 100644 index 00000000..a84781e0 --- /dev/null +++ b/apps/web/src/hooks/use-effect-preview.ts @@ -0,0 +1,47 @@ +import { useEffect, useRef } from "react"; +import { effectPreviewService } from "@/services/renderer/effect-preview"; +import type { EffectParamValues } from "@/types/effects"; + +export function useEffectPreview({ + effectType, + params, + canvasRef, + isActive, +}: { + effectType: string; + params: EffectParamValues; + canvasRef: React.RefObject<HTMLCanvasElement | null>; + isActive: boolean; +}): void { + const requestRef = useRef<number>(0); + + useEffect(() => { + if (!isActive) { + if (requestRef.current) { + cancelAnimationFrame(requestRef.current); + requestRef.current = 0; + } + return; + } + + const loop = (): void => { + const canvas = canvasRef.current; + if (canvas) { + effectPreviewService.renderPreview({ + effectType, + params, + targetCanvas: canvas, + }); + } + requestRef.current = requestAnimationFrame(loop); + }; + + requestRef.current = requestAnimationFrame(loop); + + return () => { + if (requestRef.current) { + cancelAnimationFrame(requestRef.current); + } + }; + }, [effectType, params, canvasRef, isActive]); +} diff --git a/apps/web/src/hooks/use-transform-handles.ts b/apps/web/src/hooks/use-transform-handles.ts index 8be6420c..fb561396 100644 --- a/apps/web/src/hooks/use-transform-handles.ts +++ b/apps/web/src/hooks/use-transform-handles.ts @@ -1,7 +1,6 @@ -import { useCallback, useRef, useState } from "react"; +import { useCallback, useRef, useState, useSyncExternalStore } from "react"; import { useEditor } from "@/hooks/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; -import { useSyncExternalStore } from "react"; import { getVisibleElementsWithBounds, type ElementWithBounds, @@ -18,7 +17,13 @@ import { type SnapLine, } from "@/lib/preview/preview-snap"; import { isVisualElement } from "@/lib/timeline/element-utils"; +import { + getElementLocalTime, + resolveTransformAtTime, + setChannel, +} from "@/lib/animation"; import type { Transform } from "@/types/timeline"; +import type { ElementAnimations } from "@/types/animation"; type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; type HandleType = Corner | "rotation"; @@ -32,6 +37,8 @@ interface ScaleState { initialBoundsCy: number; baseWidth: number; baseHeight: number; + shouldClearScaleAnimation: boolean; + animationsWithoutScale: ElementAnimations | undefined; } interface RotationState { @@ -78,16 +85,16 @@ function getCornerDistance({ }; corner: Corner; }): number { - const halfW = bounds.width / 2; - const halfH = bounds.height / 2; + const halfWidth = bounds.width / 2; + const halfHeight = bounds.height / 2; const angleRad = (bounds.rotation * Math.PI) / 180; const cos = Math.cos(angleRad); const sin = Math.sin(angleRad); const localX = - corner === "top-left" || corner === "bottom-left" ? -halfW : halfW; + corner === "top-left" || corner === "bottom-left" ? -halfWidth : halfWidth; const localY = - corner === "top-left" || corner === "top-right" ? -halfH : halfH; + corner === "top-left" || corner === "top-right" ? -halfHeight : halfHeight; const rotatedX = localX * cos - localY * sin; const rotatedY = localX * sin + localY * cos; @@ -114,6 +121,8 @@ export function useTransformHandles({ const tracks = editor.timeline.getTracks(); const currentTime = editor.playback.getCurrentTime(); + const currentTimeRef = useRef(currentTime); + currentTimeRef.current = currentTime; const mediaAssets = editor.media.getAssets(); const canvasSize = editor.project.getActive().settings.canvasSize; @@ -144,19 +153,41 @@ export function useTransformHandles({ const { bounds, trackId, elementId, element } = selectedWithBounds; if (!isVisualElement(element)) return; + const localTime = getElementLocalTime({ + timelineTime: currentTimeRef.current, + elementStartTime: element.startTime, + elementDuration: element.duration, + }); + const resolvedTransform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); + const initialDistance = getCornerDistance({ bounds, corner }); - const baseWidth = bounds.width / element.transform.scale; - const baseHeight = bounds.height / element.transform.scale; + const baseWidth = bounds.width / resolvedTransform.scale; + const baseHeight = bounds.height / resolvedTransform.scale; + const shouldClearScaleAnimation = + !!element.animations?.channels["transform.scale"]; + const animationsWithoutScale = shouldClearScaleAnimation + ? setChannel({ + animations: element.animations, + propertyPath: "transform.scale", + channel: undefined, + }) + : element.animations; scaleStateRef.current = { trackId, elementId, - initialTransform: element.transform, + initialTransform: resolvedTransform, initialDistance, initialBoundsCx: bounds.cx, initialBoundsCy: bounds.cy, baseWidth, baseHeight, + shouldClearScaleAnimation, + animationsWithoutScale, }; setActiveHandle(corner); (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); @@ -172,19 +203,30 @@ export function useTransformHandles({ const { bounds, trackId, elementId, element } = selectedWithBounds; if (!isVisualElement(element)) return; + const localTime = getElementLocalTime({ + timelineTime: currentTimeRef.current, + elementStartTime: element.startTime, + elementDuration: element.duration, + }); + const resolvedTransform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); + const position = screenToCanvas({ clientX: event.clientX, clientY: event.clientY, canvas: canvasRef.current, }); - const dx = position.x - bounds.cx; - const dy = position.y - bounds.cy; - const initialAngle = (Math.atan2(dy, dx) * 180) / Math.PI; + const deltaX = position.x - bounds.cx; + const deltaY = position.y - bounds.cy; + const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; rotationStateRef.current = { trackId, elementId, - initialTransform: element.transform, + initialTransform: resolvedTransform, initialAngle, initialBoundsCx: bounds.cx, initialBoundsCy: bounds.cy, @@ -220,11 +262,13 @@ export function useTransformHandles({ initialBoundsCy, baseWidth, baseHeight, + shouldClearScaleAnimation, + animationsWithoutScale, } = scaleStateRef.current; - const dx = position.x - initialBoundsCx; - const dy = position.y - initialBoundsCy; - const currentDistance = Math.sqrt(dx * dx + dy * dy) || 1; + const deltaX = position.x - initialBoundsCx; + const deltaY = position.y - initialBoundsCy; + const currentDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1; const scaleFactor = currentDistance / initialDistance; const proposedScale = Math.max( MIN_SCALE, @@ -258,14 +302,22 @@ export function useTransformHandles({ setSnapLines(activeLines); } + const updates: { + transform: Transform; + animations?: ElementAnimations; + } = { + transform: { ...initialTransform, scale: snappedScale }, + }; + if (shouldClearScaleAnimation) { + updates.animations = animationsWithoutScale; + } + editor.timeline.previewElements({ updates: [ { trackId, elementId, - updates: { - transform: { ...initialTransform, scale: snappedScale }, - }, + updates, }, ], }); @@ -282,9 +334,9 @@ export function useTransformHandles({ initialBoundsCy, } = rotationStateRef.current; - const dx = position.x - initialBoundsCx; - const dy = position.y - initialBoundsCy; - const currentAngle = (Math.atan2(dy, dx) * 180) / Math.PI; + const deltaX = position.x - initialBoundsCx; + const deltaY = position.y - initialBoundsCy; + const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; let deltaAngle = currentAngle - initialAngle; if (deltaAngle > 180) deltaAngle -= 360; if (deltaAngle < -180) deltaAngle += 360; diff --git a/apps/web/src/lib/actions/definitions.ts b/apps/web/src/lib/actions/definitions.ts index 83144a25..82319216 100644 --- a/apps/web/src/lib/actions/definitions.ts +++ b/apps/web/src/lib/actions/definitions.ts @@ -105,11 +105,20 @@ export const ACTIONS = { category: "editing", defaultShortcuts: ["n"], }, + "toggle-ripple-editing": { + description: "Toggle ripple editing", + category: "editing", + }, "select-all": { description: "Select all elements", category: "selection", defaultShortcuts: ["ctrl+a"], }, + "deselect-all": { + description: "Deselect all elements", + category: "selection", + defaultShortcuts: ["escape"], + }, "duplicate-selected": { description: "Duplicate selected element", category: "selection", @@ -141,7 +150,7 @@ export const ACTIONS = { export type TAction = keyof typeof ACTIONS; -export function getActionDefinition(action: TAction): TActionDefinition { +export function getActionDefinition({ action }: { action: TAction }): TActionDefinition { return ACTIONS[action]; } diff --git a/apps/web/src/lib/animation/__tests__/transform-keyframes.test.ts b/apps/web/src/lib/animation/__tests__/transform-keyframes.test.ts new file mode 100644 index 00000000..629bd0d6 --- /dev/null +++ b/apps/web/src/lib/animation/__tests__/transform-keyframes.test.ts @@ -0,0 +1,313 @@ +import { describe, expect, test } from "bun:test"; +import type { ElementAnimations } from "@/types/animation"; +import { + clampAnimationsToDuration, + getElementKeyframes, + getKeyframeAtTime, + hasKeyframesForPath, + getChannelValueAtTime, + getElementLocalTime, + resolveTransformAtTime, + splitAnimationsAtTime, +} from "@/lib/animation"; + +describe("transform keyframe evaluation", () => { + test("uses fallback value when channel is missing", () => { + const value = getChannelValueAtTime({ + channel: undefined, + time: 1, + fallbackValue: 42, + }); + expect(value).toBe(42); + }); + + test("returns boundary value when time is within epsilon of first/last keyframe", () => { + const channel = { + valueKind: "number" as const, + keyframes: [ + { id: "a", time: 0, value: 10, interpolation: "linear" as const }, + { id: "b", time: 2, value: 30, interpolation: "linear" as const }, + ], + }; + expect( + getChannelValueAtTime({ + channel, + time: 0.0008, + fallbackValue: 0, + }), + ).toBe(10); + expect( + getChannelValueAtTime({ + channel, + time: 1.9992, + fallbackValue: 0, + }), + ).toBe(30); + }); + + test("interpolates linear channels", () => { + const value = getChannelValueAtTime({ + channel: { + valueKind: "number", + keyframes: [ + { id: "a", time: 0, value: 10, interpolation: "linear" }, + { id: "b", time: 2, value: 30, interpolation: "linear" }, + ], + }, + time: 1, + fallbackValue: 0, + }); + expect(value).toBe(20); + }); + + test("clamps local time to [0, duration]", () => { + expect( + getElementLocalTime({ + timelineTime: 2, + elementStartTime: 5, + elementDuration: 4, + }), + ).toBe(0); + expect( + getElementLocalTime({ + timelineTime: 12, + elementStartTime: 5, + elementDuration: 4, + }), + ).toBe(4); + expect( + getElementLocalTime({ + timelineTime: 7, + elementStartTime: 5, + elementDuration: 4, + }), + ).toBe(2); + }); + + test("uses hold interpolation from the left keyframe", () => { + const value = getChannelValueAtTime({ + channel: { + valueKind: "number", + keyframes: [ + { id: "a", time: 0, value: 10, interpolation: "hold" }, + { id: "b", time: 2, value: 30, interpolation: "linear" }, + ], + }, + time: 1, + fallbackValue: 0, + }); + expect(value).toBe(10); + }); + + test("resolves transform by mixing animated and fallback properties", () => { + const animations: ElementAnimations = { + channels: { + "transform.position.x": { + valueKind: "number", + keyframes: [ + { id: "a", time: 0, value: 0, interpolation: "linear" }, + { id: "b", time: 4, value: 80, interpolation: "linear" }, + ], + }, + "transform.scale": { + valueKind: "number", + keyframes: [{ id: "c", time: 0, value: 2, interpolation: "hold" }], + }, + }, + }; + const resolvedTransform = resolveTransformAtTime({ + baseTransform: { + position: { x: 10, y: 20 }, + scale: 1, + rotate: 15, + }, + animations, + localTime: 2, + }); + expect(resolvedTransform).toEqual({ + position: { x: 40, y: 20 }, + scale: 2, + rotate: 15, + }); + }); +}); + +describe("transform keyframe mutation utilities", () => { + test("splits channels and rebases right side times", () => { + const animations: ElementAnimations = { + channels: { + "transform.scale": { + valueKind: "number", + keyframes: [ + { id: "a", time: 0, value: 1, interpolation: "linear" }, + { id: "b", time: 2, value: 2, interpolation: "linear" }, + { id: "c", time: 6, value: 4, interpolation: "linear" }, + ], + }, + }, + }; + const { leftAnimations, rightAnimations } = splitAnimationsAtTime({ + animations, + splitTime: 4, + }); + + expect( + leftAnimations?.channels["transform.scale"]?.keyframes.map( + (keyframe) => keyframe.time, + ), + ).toEqual([0, 2, 4]); + expect( + rightAnimations?.channels["transform.scale"]?.keyframes.map( + (keyframe) => keyframe.time, + ), + ).toEqual([0, 2]); + expect( + rightAnimations?.channels["transform.scale"]?.keyframes[0]?.value, + ).toBe(3); + }); + + test("clamps channels to updated element duration", () => { + const animations: ElementAnimations = { + channels: { + "transform.rotate": { + valueKind: "number", + keyframes: [ + { id: "a", time: 0, value: 0, interpolation: "linear" }, + { id: "b", time: 2, value: 20, interpolation: "linear" }, + { id: "c", time: 5, value: 50, interpolation: "linear" }, + ], + }, + }, + }; + const clampedAnimations = clampAnimationsToDuration({ + animations, + duration: 2, + }); + expect( + clampedAnimations?.channels["transform.rotate"]?.keyframes.map( + (keyframe) => keyframe.time, + ), + ).toEqual([0, 2]); + }); +}); + +describe("typed channel interpolation", () => { + test("interpolates color channels from hex keyframes", () => { + const value = getChannelValueAtTime({ + channel: { + valueKind: "color", + keyframes: [ + { id: "a", time: 0, value: "#000000", interpolation: "linear" }, + { id: "b", time: 1, value: "#ffffff", interpolation: "linear" }, + ], + }, + time: 0.5, + fallbackValue: "#000000", + }); + expect(typeof value).toBe("string"); + expect(value).toContain("rgba("); + }); + + test("uses hold behavior for discrete channels", () => { + const value = getChannelValueAtTime({ + channel: { + valueKind: "discrete", + keyframes: [ + { id: "a", time: 0, value: "normal", interpolation: "hold" }, + { id: "b", time: 2, value: "multiply", interpolation: "hold" }, + ], + }, + time: 1.2, + fallbackValue: "normal", + }); + expect(value).toBe("normal"); + }); +}); + +describe("keyframe query helpers", () => { + test("getElementKeyframes returns flat list of all keyframes across channels", () => { + const animations: ElementAnimations = { + channels: { + "transform.position.x": { + valueKind: "number", + keyframes: [{ id: "x-1", time: 1, value: 64, interpolation: "linear" }], + }, + opacity: { + valueKind: "number", + keyframes: [{ id: "o-1", time: 0, value: 1, interpolation: "linear" }], + }, + }, + }; + + const keyframes = getElementKeyframes({ animations }); + expect(keyframes).toHaveLength(2); + expect(keyframes.map((keyframe) => keyframe.propertyPath).sort()).toEqual([ + "opacity", + "transform.position.x", + ]); + }); + + test("getElementKeyframes returns empty array when animations are missing or channels are empty", () => { + expect(getElementKeyframes({ animations: undefined })).toEqual([]); + expect( + getElementKeyframes({ + animations: { + channels: { opacity: { valueKind: "number", keyframes: [] } }, + }, + }), + ).toEqual([]); + }); + + test("hasKeyframesForPath returns true only for paths with keyframes", () => { + const animations: ElementAnimations = { + channels: { + "transform.position.x": { + valueKind: "number", + keyframes: [{ id: "x-1", time: 1, value: 64, interpolation: "linear" }], + }, + "transform.position.y": { + valueKind: "number", + keyframes: [], + }, + }, + }; + + expect( + hasKeyframesForPath({ animations, propertyPath: "transform.position.x" }), + ).toBe(true); + expect( + hasKeyframesForPath({ animations, propertyPath: "transform.position.y" }), + ).toBe(false); + }); + + test("getKeyframeAtTime finds keyframe within epsilon and returns full object", () => { + const animations: ElementAnimations = { + channels: { + "transform.rotate": { + valueKind: "number", + keyframes: [ + { id: "r-1", time: 1, value: 15, interpolation: "linear" }, + { id: "r-2", time: 2, value: 30, interpolation: "linear" }, + ], + }, + }, + }; + + const found = getKeyframeAtTime({ + animations, + propertyPath: "transform.rotate", + time: 1.0008, + }); + expect(found?.id).toBe("r-1"); + expect(found?.value).toBe(15); + expect(found?.propertyPath).toBe("transform.rotate"); + + expect( + getKeyframeAtTime({ + animations, + propertyPath: "transform.rotate", + time: 1.01, + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/animation/color-channel.ts b/apps/web/src/lib/animation/color-channel.ts new file mode 100644 index 00000000..2a340f94 --- /dev/null +++ b/apps/web/src/lib/animation/color-channel.ts @@ -0,0 +1,19 @@ +import type { + AnimationPropertyPath, + ColorAnimationChannel, + ElementAnimations, +} from "@/types/animation"; + +export function getColorChannelForPath({ + animations, + propertyPath, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; +}): ColorAnimationChannel | undefined { + const channel = animations?.channels[propertyPath]; + if (!channel || channel.valueKind !== "color") { + return undefined; + } + return channel; +} diff --git a/apps/web/src/lib/animation/effect-param-channel.ts b/apps/web/src/lib/animation/effect-param-channel.ts new file mode 100644 index 00000000..07efe4d8 --- /dev/null +++ b/apps/web/src/lib/animation/effect-param-channel.ts @@ -0,0 +1,117 @@ +import type { Effect, EffectParamValues } from "@/types/effects"; +import type { + ElementAnimations, + NumberAnimationChannel, +} from "@/types/animation"; +import { + getChannel, + removeKeyframe, + setChannel, + upsertKeyframe, +} from "./keyframes"; +import { getChannelValueAtTime } from "./interpolation"; + +const EFFECT_PARAM_PATH_PREFIX = "effects."; +const EFFECT_PARAM_PATH_SUFFIX = ".params."; + +function buildEffectParamPath({ + effectId, + paramKey, +}: { + effectId: string; + paramKey: string; +}): string { + return `${EFFECT_PARAM_PATH_PREFIX}${effectId}${EFFECT_PARAM_PATH_SUFFIX}${paramKey}`; +} + +export function resolveEffectParamsAtTime({ + effect, + animations, + localTime, +}: { + effect: Effect; + animations: ElementAnimations | undefined; + localTime: number; +}): EffectParamValues { + const resolved: EffectParamValues = {}; + + for (const [paramKey, staticValue] of Object.entries(effect.params)) { + const path = buildEffectParamPath({ effectId: effect.id, paramKey }); + const channel = getChannel({ animations, propertyPath: path }); + if (channel && channel.keyframes.length > 0) { + resolved[paramKey] = getChannelValueAtTime({ + channel, + time: localTime, + fallbackValue: staticValue, + }) as number | string | boolean; + } else { + resolved[paramKey] = staticValue; + } + } + + return resolved; +} + +const EMPTY_NUMBER_CHANNEL: NumberAnimationChannel = { + valueKind: "number", + keyframes: [], +}; + +export function upsertEffectParamKeyframe({ + animations, + effectId, + paramKey, + time, + value, + interpolation, + keyframeId, +}: { + animations: ElementAnimations | undefined; + effectId: string; + paramKey: string; + time: number; + value: number; + interpolation?: "linear" | "hold"; + keyframeId?: string; +}): ElementAnimations | undefined { + const path = buildEffectParamPath({ effectId, paramKey }); + const channel = getChannel({ animations, propertyPath: path }); + const targetChannel = + channel && channel.valueKind === "number" ? channel : EMPTY_NUMBER_CHANNEL; + const updatedChannel = upsertKeyframe({ + channel: targetChannel, + time, + value, + interpolation: interpolation ?? "linear", + keyframeId, + }); + + return ( + setChannel({ + animations, + propertyPath: path, + channel: updatedChannel, + }) ?? { channels: {} } + ); +} + +export function removeEffectParamKeyframe({ + animations, + effectId, + paramKey, + keyframeId, +}: { + animations: ElementAnimations | undefined; + effectId: string; + paramKey: string; + keyframeId: string; +}): ElementAnimations | undefined { + const path = buildEffectParamPath({ effectId, paramKey }); + const channel = getChannel({ animations, propertyPath: path }); + const updatedChannel = removeKeyframe({ channel, keyframeId }); + return setChannel({ + animations, + propertyPath: path, + channel: updatedChannel, + }); +} diff --git a/apps/web/src/lib/animation/index.ts b/apps/web/src/lib/animation/index.ts new file mode 100644 index 00000000..c4396bb7 --- /dev/null +++ b/apps/web/src/lib/animation/index.ts @@ -0,0 +1,41 @@ +export { + getChannelValueAtTime, + getNumberChannelValueAtTime, + normalizeChannel, +} from "./interpolation"; + +export { + clampAnimationsToDuration, + cloneAnimations, + getChannel, + removeElementKeyframe, + retimeElementKeyframe, + setChannel, + splitAnimationsAtTime, + upsertElementKeyframe, +} from "./keyframes"; + +export { + getElementLocalTime, + resolveColorAtTime, + resolveNumberAtTime, + resolveOpacityAtTime, + resolveTransformAtTime, + resolveVolumeAtTime, +} from "./resolve"; + +export { + coerceAnimationValueForProperty, + getAnimationPropertyDefinition, + getDefaultInterpolationForProperty, + getElementBaseValueForProperty, + isAnimationPropertyPath, + supportsAnimationProperty, + withElementBaseValueForProperty, +} from "./property-registry"; + +export { + getElementKeyframes, + getKeyframeAtTime, + hasKeyframesForPath, +} from "./keyframe-query"; diff --git a/apps/web/src/lib/animation/interpolation.ts b/apps/web/src/lib/animation/interpolation.ts new file mode 100644 index 00000000..7a6fc6cf --- /dev/null +++ b/apps/web/src/lib/animation/interpolation.ts @@ -0,0 +1,370 @@ +import type { + AnimationChannel, + AnimationValue, + ColorAnimationChannel, + DiscreteValue, + DiscreteAnimationChannel, + NumberAnimationChannel, +} from "@/types/animation"; +import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; + +function byTimeAscending({ + leftTime, + rightTime, +}: { + leftTime: number; + rightTime: number; +}): number { + return leftTime - rightTime; +} + +function isWithinTimePair({ + time, + leftTime, + rightTime, +}: { + time: number; + leftTime: number; + rightTime: number; +}): boolean { + return ( + time >= leftTime - TIME_EPSILON_SECONDS && + time <= rightTime + TIME_EPSILON_SECONDS + ); +} + +function clamp01({ value }: { value: number }): number { + return Math.max(0, Math.min(1, value)); +} + +function parseHexChannel({ hex }: { hex: string }): number | null { + const value = Number.parseInt(hex, 16); + return Number.isNaN(value) ? null : value; +} + +function parseHexColor({ + color, +}: { + color: string; +}): { red: number; green: number; blue: number; alpha: number } | null { + const trimmed = color.trim(); + if (!trimmed.startsWith("#")) { + return null; + } + + const rawHex = trimmed.slice(1); + if (rawHex.length === 3 || rawHex.length === 4) { + const [redHex, greenHex, blueHex, alphaHex = "f"] = rawHex.split(""); + const red = parseHexChannel({ hex: `${redHex}${redHex}` }); + const green = parseHexChannel({ hex: `${greenHex}${greenHex}` }); + const blue = parseHexChannel({ hex: `${blueHex}${blueHex}` }); + const alpha = parseHexChannel({ hex: `${alphaHex}${alphaHex}` }); + if ( + red === null || + green === null || + blue === null || + alpha === null + ) { + return null; + } + + return { red, green, blue, alpha: alpha / 255 }; + } + + if (rawHex.length === 6 || rawHex.length === 8) { + const red = parseHexChannel({ hex: rawHex.slice(0, 2) }); + const green = parseHexChannel({ hex: rawHex.slice(2, 4) }); + const blue = parseHexChannel({ hex: rawHex.slice(4, 6) }); + const alphaHex = rawHex.length === 8 ? rawHex.slice(6, 8) : "ff"; + const alpha = parseHexChannel({ hex: alphaHex }); + if ( + red === null || + green === null || + blue === null || + alpha === null + ) { + return null; + } + + return { red, green, blue, alpha: alpha / 255 }; + } + + return null; +} + +function formatRgbaColor({ + red, + green, + blue, + alpha, +}: { + red: number; + green: number; + blue: number; + alpha: number; +}): string { + const roundedRed = Math.round(red); + const roundedGreen = Math.round(green); + const roundedBlue = Math.round(blue); + const roundedAlpha = Math.round(clamp01({ value: alpha }) * 1000) / 1000; + return `rgba(${roundedRed}, ${roundedGreen}, ${roundedBlue}, ${roundedAlpha})`; +} + +function lerpNumber({ + leftValue, + rightValue, + progress, +}: { + leftValue: number; + rightValue: number; + progress: number; +}): number { + return leftValue + (rightValue - leftValue) * progress; +} + +function interpolateColor({ + leftColor, + rightColor, + progress, +}: { + leftColor: string; + rightColor: string; + progress: number; +}): string { + const leftParsed = parseHexColor({ color: leftColor }); + const rightParsed = parseHexColor({ color: rightColor }); + if (!leftParsed || !rightParsed) { + return progress >= 1 ? rightColor : leftColor; + } + + return formatRgbaColor({ + red: lerpNumber({ + leftValue: leftParsed.red, + rightValue: rightParsed.red, + progress, + }), + green: lerpNumber({ + leftValue: leftParsed.green, + rightValue: rightParsed.green, + progress, + }), + blue: lerpNumber({ + leftValue: leftParsed.blue, + rightValue: rightParsed.blue, + progress, + }), + alpha: lerpNumber({ + leftValue: leftParsed.alpha, + rightValue: rightParsed.alpha, + progress, + }), + }); +} + +export function normalizeChannel<TChannel extends AnimationChannel>({ + channel, +}: { + channel: TChannel; +}): TChannel { + return { + ...channel, + keyframes: [...channel.keyframes].sort((leftKeyframe, rightKeyframe) => + byTimeAscending({ + leftTime: leftKeyframe.time, + rightTime: rightKeyframe.time, + }), + ), + } as TChannel; +} + +function evaluateChannelValueAtTime<TKeyframe extends { time: number; value: TValue }, TValue>({ + keyframes, + time, + fallbackValue, + getInterpolatedValue, +}: { + keyframes: TKeyframe[] | undefined; + time: number; + fallbackValue: TValue; + getInterpolatedValue: ({ + leftKeyframe, + rightKeyframe, + progress, + }: { + leftKeyframe: TKeyframe; + rightKeyframe: TKeyframe; + progress: number; + }) => TValue; +}): TValue { + if (!keyframes || keyframes.length === 0) { + return fallbackValue; + } + + const firstKeyframe = keyframes[0]; + const lastKeyframe = keyframes[keyframes.length - 1]; + if (!firstKeyframe || !lastKeyframe) { + return fallbackValue; + } + + if (time <= firstKeyframe.time + TIME_EPSILON_SECONDS) { + return firstKeyframe.value; + } + + if (time >= lastKeyframe.time - TIME_EPSILON_SECONDS) { + return lastKeyframe.value; + } + + for (let keyframeIndex = 0; keyframeIndex < keyframes.length - 1; keyframeIndex++) { + const leftKeyframe = keyframes[keyframeIndex]; + const rightKeyframe = keyframes[keyframeIndex + 1]; + + if (Math.abs(time - rightKeyframe.time) <= TIME_EPSILON_SECONDS) { + return rightKeyframe.value; + } + + const isBetweenPair = isWithinTimePair({ + time, + leftTime: leftKeyframe.time, + rightTime: rightKeyframe.time, + }); + if (!isBetweenPair) { + continue; + } + + const span = rightKeyframe.time - leftKeyframe.time; + if (Math.abs(span) <= TIME_EPSILON_SECONDS) { + return rightKeyframe.value; + } + + const progress = clamp01({ + value: (time - leftKeyframe.time) / span, + }); + + return getInterpolatedValue({ + leftKeyframe, + rightKeyframe, + progress, + }); + } + + return lastKeyframe.value; +} + +export function getNumberChannelValueAtTime({ + channel, + time, + fallbackValue, +}: { + channel: NumberAnimationChannel | undefined; + time: number; + fallbackValue: number; +}): number { + return evaluateChannelValueAtTime({ + keyframes: channel?.keyframes, + time, + fallbackValue, + getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => { + if (leftKeyframe.interpolation === "hold") { + return leftKeyframe.value; + } + + return lerpNumber({ + leftValue: leftKeyframe.value, + rightValue: rightKeyframe.value, + progress, + }); + }, + }); +} + +export function getColorValueAtTime({ + channel, + time, + fallbackValue, +}: { + channel: ColorAnimationChannel | undefined; + time: number; + fallbackValue: string; +}): string { + return evaluateChannelValueAtTime({ + keyframes: channel?.keyframes, + time, + fallbackValue, + getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => { + if (leftKeyframe.interpolation === "hold") { + return leftKeyframe.value; + } + + return interpolateColor({ + leftColor: leftKeyframe.value, + rightColor: rightKeyframe.value, + progress, + }); + }, + }); +} + +function getDiscreteValueAtTime({ + channel, + time, + fallbackValue, +}: { + channel: DiscreteAnimationChannel | undefined; + time: number; + fallbackValue: DiscreteValue; +}): DiscreteValue { + return evaluateChannelValueAtTime({ + keyframes: channel?.keyframes, + time, + fallbackValue, + getInterpolatedValue: ({ leftKeyframe }) => leftKeyframe.value, + }); +} + +export function getChannelValueAtTime({ + channel, + time, + fallbackValue, +}: { + channel: AnimationChannel | undefined; + time: number; + fallbackValue: AnimationValue; +}): AnimationValue { + if (!channel || channel.keyframes.length === 0) { + return fallbackValue; + } + + if (channel.valueKind === "number") { + if (typeof fallbackValue !== "number") { + return fallbackValue; + } + + return getNumberChannelValueAtTime({ + channel, + time, + fallbackValue, + }); + } + + if (channel.valueKind === "color") { + if (typeof fallbackValue !== "string") { + return fallbackValue; + } + + return getColorValueAtTime({ + channel, + time, + fallbackValue, + }); + } + + if (typeof fallbackValue !== "string" && typeof fallbackValue !== "boolean") { + return fallbackValue; + } + + return getDiscreteValueAtTime({ + channel, + time, + fallbackValue, + }); +} diff --git a/apps/web/src/lib/animation/keyframe-query.ts b/apps/web/src/lib/animation/keyframe-query.ts new file mode 100644 index 00000000..6626e3e8 --- /dev/null +++ b/apps/web/src/lib/animation/keyframe-query.ts @@ -0,0 +1,67 @@ +import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; +import type { + AnimationPropertyPath, + ElementAnimations, + ElementKeyframe, +} from "@/types/animation"; + +export function getElementKeyframes({ + animations, +}: { + animations: ElementAnimations | undefined; +}): ElementKeyframe[] { + if (!animations) { + return []; + } + + return Object.entries(animations.channels).flatMap( + ([propertyPath, channel]) => { + if (!channel || channel.keyframes.length === 0) { + return []; + } + + return channel.keyframes.map((keyframe) => ({ + propertyPath: propertyPath as AnimationPropertyPath, + id: keyframe.id, + time: keyframe.time, + value: keyframe.value, + interpolation: keyframe.interpolation, + })); + }, + ); +} + +export function hasKeyframesForPath({ + animations, + propertyPath, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; +}): boolean { + const channel = animations?.channels[propertyPath]; + return Boolean(channel && channel.keyframes.length > 0); +} + +export function getKeyframeAtTime({ + animations, + propertyPath, + time, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; + time: number; +}): ElementKeyframe | null { + const channel = animations?.channels[propertyPath]; + if (!channel || channel.keyframes.length === 0) return null; + const keyframe = channel.keyframes.find( + (keyframe) => Math.abs(keyframe.time - time) <= TIME_EPSILON_SECONDS, + ); + if (!keyframe) return null; + return { + propertyPath, + id: keyframe.id, + time: keyframe.time, + value: keyframe.value, + interpolation: keyframe.interpolation, + }; +} diff --git a/apps/web/src/lib/animation/keyframes.ts b/apps/web/src/lib/animation/keyframes.ts new file mode 100644 index 00000000..3e3613f2 --- /dev/null +++ b/apps/web/src/lib/animation/keyframes.ts @@ -0,0 +1,611 @@ +import type { + AnimationChannel, + AnimationInterpolation, + AnimationKeyframe, + AnimationPropertyPath, + AnimationValue, + AnimationValueKind, + ColorAnimationChannel, + DiscreteAnimationChannel, + ElementAnimations, + NumberAnimationChannel, +} from "@/types/animation"; +import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; +import { generateUUID } from "@/utils/id"; +import { getChannelValueAtTime, normalizeChannel } from "./interpolation"; +import { + coerceAnimationValueForProperty, + getDefaultInterpolationForProperty, + getAnimationPropertyDefinition, + isAnimationPropertyPath, +} from "./property-registry"; + +function isNearlySameTime({ + leftTime, + rightTime, +}: { + leftTime: number; + rightTime: number; +}): boolean { + return Math.abs(leftTime - rightTime) <= TIME_EPSILON_SECONDS; +} + +function toAnimation({ + channelEntries, +}: { + channelEntries: Array<[string, AnimationChannel]>; +}): ElementAnimations | undefined { + if (channelEntries.length === 0) { + return undefined; + } + + return { + channels: Object.fromEntries(channelEntries), + }; +} + +function toChannel({ + keyframes, + valueKind, +}: { + keyframes: AnimationKeyframe[]; + valueKind: AnimationValueKind; +}): AnimationChannel { + return normalizeChannel({ + channel: { + valueKind, + keyframes, + } as AnimationChannel, + }); +} + +export function getChannel({ + animations, + propertyPath, +}: { + animations: ElementAnimations | undefined; + propertyPath: string; +}): AnimationChannel | undefined { + return animations?.channels[propertyPath]; +} + +function getInterpolationForChannel({ + channel, + interpolation, +}: { + channel: AnimationChannel; + interpolation: AnimationInterpolation | undefined; +}): AnimationInterpolation { + if (channel.valueKind === "discrete") { + return "hold"; + } + + if (interpolation === "linear" || interpolation === "hold") { + return interpolation; + } + + return "linear"; +} + +function buildKeyframe({ + channel, + id, + time, + value, + interpolation, +}: { + channel: AnimationChannel; + id: string; + time: number; + value: AnimationValue; + interpolation: AnimationInterpolation; +}): AnimationKeyframe { + if (channel.valueKind === "number") { + if (typeof value !== "number") { + throw new Error("Number channel keyframes require numeric values"); + } + + return { + id, + time, + value, + interpolation: interpolation === "hold" ? "hold" : "linear", + }; + } + + if (channel.valueKind === "color") { + if (typeof value !== "string") { + throw new Error("Color channel keyframes require string values"); + } + + return { + id, + time, + value, + interpolation: interpolation === "hold" ? "hold" : "linear", + }; + } + + if (typeof value !== "string" && typeof value !== "boolean") { + throw new Error( + "Discrete channel keyframes require boolean or string values", + ); + } + + return { + id, + time, + value, + interpolation: "hold", + }; +} + +function createEmptyChannel({ + propertyPath, +}: { + propertyPath: AnimationPropertyPath; +}): AnimationChannel { + const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); + if (propertyDefinition.valueKind === "number") { + return { + valueKind: "number", + keyframes: [], + } satisfies NumberAnimationChannel; + } + + if (propertyDefinition.valueKind === "color") { + return { + valueKind: "color", + keyframes: [], + } satisfies ColorAnimationChannel; + } + + return { + valueKind: "discrete", + keyframes: [], + } satisfies DiscreteAnimationChannel; +} + +export function upsertKeyframe({ + channel, + time, + value, + interpolation, + keyframeId, +}: { + channel: AnimationChannel | undefined; + time: number; + value: AnimationValue; + interpolation?: AnimationInterpolation; + keyframeId?: string; +}): AnimationChannel | undefined { + if (!channel) { + return undefined; + } + + const currentKeyframes = channel.keyframes; + const nextKeyframes = [...currentKeyframes]; + const nextInterpolation = getInterpolationForChannel({ + channel, + interpolation, + }); + if (keyframeId) { + const keyframeByIdIndex = nextKeyframes.findIndex( + (keyframe) => keyframe.id === keyframeId, + ); + if (keyframeByIdIndex >= 0) { + nextKeyframes[keyframeByIdIndex] = buildKeyframe({ + channel, + id: nextKeyframes[keyframeByIdIndex].id, + time, + value, + interpolation: nextInterpolation, + }); + return toChannel({ + keyframes: nextKeyframes, + valueKind: channel.valueKind, + }); + } + } + + const keyframeAtTimeIndex = nextKeyframes.findIndex((keyframe) => + isNearlySameTime({ leftTime: keyframe.time, rightTime: time }), + ); + if (keyframeAtTimeIndex >= 0) { + nextKeyframes[keyframeAtTimeIndex] = buildKeyframe({ + channel, + id: nextKeyframes[keyframeAtTimeIndex].id, + time: nextKeyframes[keyframeAtTimeIndex].time, + value, + interpolation: nextInterpolation, + }); + return toChannel({ + keyframes: nextKeyframes, + valueKind: channel.valueKind, + }); + } + + nextKeyframes.push( + buildKeyframe({ + channel, + id: keyframeId ?? generateUUID(), + time, + value, + interpolation: nextInterpolation, + }), + ); + + return toChannel({ + keyframes: nextKeyframes, + valueKind: channel.valueKind, + }); +} + +export function removeKeyframe({ + channel, + keyframeId, +}: { + channel: AnimationChannel | undefined; + keyframeId: string; +}): AnimationChannel | undefined { + if (!channel) { + return undefined; + } + + const nextKeyframes = channel.keyframes.filter( + (keyframe) => keyframe.id !== keyframeId, + ); + if (nextKeyframes.length === 0) { + return undefined; + } + + return toChannel({ + keyframes: nextKeyframes, + valueKind: channel.valueKind, + }); +} + +export function retimeKeyframe({ + channel, + keyframeId, + time, +}: { + channel: AnimationChannel | undefined; + keyframeId: string; + time: number; +}): AnimationChannel | undefined { + if (!channel) { + return undefined; + } + + const keyframeByIdIndex = channel.keyframes.findIndex( + (keyframe) => keyframe.id === keyframeId, + ); + if (keyframeByIdIndex < 0) { + return channel; + } + + const nextKeyframes = [...channel.keyframes]; + nextKeyframes[keyframeByIdIndex] = { + ...nextKeyframes[keyframeByIdIndex], + time, + }; + + return toChannel({ + keyframes: nextKeyframes, + valueKind: channel.valueKind, + }); +} + +export function setChannel({ + animations, + propertyPath, + channel, +}: { + animations: ElementAnimations | undefined; + propertyPath: string; + channel: AnimationChannel | undefined; +}): ElementAnimations | undefined { + const currentChannels = animations?.channels ?? {}; + + const nextChannelEntries = Object.entries(currentChannels) + .filter(([path]) => path !== propertyPath) + .filter(([, ch]) => ch && ch.keyframes.length > 0) + .map(([path, ch]) => [path, ch] as [string, AnimationChannel]); + + if (channel && channel.keyframes.length > 0) { + nextChannelEntries.push([propertyPath, channel]); + } + + return toAnimation({ + channelEntries: nextChannelEntries, + }); +} + +export function cloneAnimations({ + animations, + shouldRegenerateKeyframeIds = false, +}: { + animations: ElementAnimations | undefined; + shouldRegenerateKeyframeIds?: boolean; +}): ElementAnimations | undefined { + if (!animations) { + return undefined; + } + + const clonedEntries = Object.entries(animations.channels).flatMap( + ([propertyPath, channel]) => { + if (!channel || channel.keyframes.length === 0) { + return []; + } + + const clonedKeyframes = channel.keyframes.map((keyframe) => ({ + ...keyframe, + id: shouldRegenerateKeyframeIds ? generateUUID() : keyframe.id, + })); + + return [ + [ + propertyPath, + toChannel({ + keyframes: clonedKeyframes, + valueKind: channel.valueKind, + }), + ] as [string, AnimationChannel], + ]; + }, + ); + + return toAnimation({ + channelEntries: clonedEntries, + }); +} + +export function clampAnimationsToDuration({ + animations, + duration, +}: { + animations: ElementAnimations | undefined; + duration: number; +}): ElementAnimations | undefined { + if (!animations) { + return undefined; + } + + const clampedEntries = Object.entries(animations.channels).flatMap( + ([propertyPath, channel]) => { + if (!channel) { + return []; + } + + const nextKeyframes = channel.keyframes.filter( + (keyframe) => keyframe.time >= 0 && keyframe.time <= duration, + ); + if (nextKeyframes.length === 0) { + return []; + } + + return [ + [ + propertyPath, + toChannel({ + keyframes: nextKeyframes, + valueKind: channel.valueKind, + }), + ] as [string, AnimationChannel], + ]; + }, + ); + + return toAnimation({ + channelEntries: clampedEntries, + }); +} + +export function splitAnimationsAtTime({ + animations, + splitTime, + shouldIncludeSplitBoundary = true, +}: { + animations: ElementAnimations | undefined; + splitTime: number; + shouldIncludeSplitBoundary?: boolean; +}): { + leftAnimations: ElementAnimations | undefined; + rightAnimations: ElementAnimations | undefined; +} { + if (!animations) { + return { leftAnimations: undefined, rightAnimations: undefined }; + } + + const leftChannels: Array<[string, AnimationChannel]> = []; + const rightChannels: Array<[string, AnimationChannel]> = []; + + for (const [propertyPath, channel] of Object.entries(animations.channels)) { + if (!channel || channel.keyframes.length === 0) { + continue; + } + + const normalizedChannel = normalizeChannel({ channel }); + let leftKeyframes = normalizedChannel.keyframes.filter( + (keyframe) => keyframe.time <= splitTime, + ); + let rightKeyframes = normalizedChannel.keyframes + .filter((keyframe) => keyframe.time >= splitTime) + .map((keyframe) => ({ + ...keyframe, + time: keyframe.time - splitTime, + })); + + const hasBoundaryOnLeft = leftKeyframes.some((keyframe) => + isNearlySameTime({ leftTime: keyframe.time, rightTime: splitTime }), + ); + const hasBoundaryOnRight = rightKeyframes.some((keyframe) => + isNearlySameTime({ leftTime: keyframe.time, rightTime: 0 }), + ); + if ( + shouldIncludeSplitBoundary && + (!hasBoundaryOnLeft || !hasBoundaryOnRight) + ) { + const boundaryValue = getChannelValueAtTime({ + channel: normalizedChannel, + time: splitTime, + fallbackValue: normalizedChannel.keyframes[0].value, + }); + const knownPropertyPath = isAnimationPropertyPath({ propertyPath }) + ? (propertyPath as AnimationPropertyPath) + : null; + const boundaryInterpolation = knownPropertyPath + ? getDefaultInterpolationForProperty({ + propertyPath: knownPropertyPath, + }) + : normalizedChannel.valueKind === "discrete" + ? "hold" + : "linear"; + + if (!hasBoundaryOnLeft) { + leftKeyframes = [ + ...leftKeyframes, + buildKeyframe({ + channel: normalizedChannel, + id: generateUUID(), + time: splitTime, + value: boundaryValue, + interpolation: boundaryInterpolation, + }), + ]; + } + + if (!hasBoundaryOnRight) { + rightKeyframes = [ + buildKeyframe({ + channel: normalizedChannel, + id: generateUUID(), + time: 0, + value: boundaryValue, + interpolation: boundaryInterpolation, + }), + ...rightKeyframes, + ]; + } + } + + const leftChannel = leftKeyframes.length + ? toChannel({ + keyframes: leftKeyframes, + valueKind: normalizedChannel.valueKind, + }) + : undefined; + const rightChannel = rightKeyframes.length + ? toChannel({ + keyframes: rightKeyframes, + valueKind: normalizedChannel.valueKind, + }) + : undefined; + if (leftChannel) { + leftChannels.push([propertyPath, leftChannel]); + } + if (rightChannel) { + rightChannels.push([propertyPath, rightChannel]); + } + } + + return { + leftAnimations: toAnimation({ channelEntries: leftChannels }), + rightAnimations: toAnimation({ channelEntries: rightChannels }), + }; +} + +export function upsertElementKeyframe({ + animations, + propertyPath, + time, + value, + interpolation, + keyframeId, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; + time: number; + value: AnimationValue; + interpolation?: AnimationInterpolation; + keyframeId?: string; +}): ElementAnimations | undefined { + const coercedValue = coerceAnimationValueForProperty({ + propertyPath, + value, + }); + if (coercedValue === null) { + return animations; + } + + const defaultInterpolation = getDefaultInterpolationForProperty({ + propertyPath, + }); + const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); + const channel = getChannel({ animations, propertyPath }); + const targetChannel = + channel && channel.valueKind === propertyDefinition.valueKind + ? channel + : createEmptyChannel({ propertyPath }); + const updatedChannel = upsertKeyframe({ + channel: targetChannel, + time, + value: coercedValue, + interpolation: interpolation ?? defaultInterpolation, + keyframeId, + }); + + return ( + setChannel({ + animations, + propertyPath, + channel: updatedChannel, + }) ?? { channels: {} } + ); +} + +export function removeElementKeyframe({ + animations, + propertyPath, + keyframeId, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; + keyframeId: string; +}): ElementAnimations | undefined { + const channel = getChannel({ animations, propertyPath }); + const updatedChannel = removeKeyframe({ + channel, + keyframeId, + }); + return setChannel({ + animations, + propertyPath, + channel: updatedChannel, + }); +} + +export function retimeElementKeyframe({ + animations, + propertyPath, + keyframeId, + time, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; + keyframeId: string; + time: number; +}): ElementAnimations | undefined { + const channel = getChannel({ animations, propertyPath }); + const updatedChannel = retimeKeyframe({ + channel, + keyframeId, + time, + }); + return setChannel({ + animations, + propertyPath, + channel: updatedChannel, + }); +} diff --git a/apps/web/src/lib/animation/number-channel.ts b/apps/web/src/lib/animation/number-channel.ts new file mode 100644 index 00000000..67166156 --- /dev/null +++ b/apps/web/src/lib/animation/number-channel.ts @@ -0,0 +1,20 @@ +import type { + AnimationPropertyPath, + ElementAnimations, + NumberAnimationChannel, +} from "@/types/animation"; + +export function getNumberChannelForPath({ + animations, + propertyPath, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; +}): NumberAnimationChannel | undefined { + const channel = animations?.channels[propertyPath]; + if (!channel || channel.valueKind !== "number") { + return undefined; + } + + return channel; +} diff --git a/apps/web/src/lib/animation/property-registry.ts b/apps/web/src/lib/animation/property-registry.ts new file mode 100644 index 00000000..361ae3ba --- /dev/null +++ b/apps/web/src/lib/animation/property-registry.ts @@ -0,0 +1,352 @@ +import type { + AnimationInterpolation, + AnimationPropertyPath, + AnimationValue, + AnimationValueKind, + DiscreteValue, +} from "@/types/animation"; +import type { TimelineElement } from "@/types/timeline"; +import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants"; +import { + CORNER_RADIUS_MAX, + CORNER_RADIUS_MIN, + DEFAULT_TEXT_BACKGROUND, +} from "@/constants/text-constants"; +import { isVisualElement } from "@/lib/timeline/element-utils"; + +interface NumericRange { + min?: number; + max?: number; +} + +interface AnimationPropertyDefinition { + valueKind: AnimationValueKind; + defaultInterpolation: AnimationInterpolation; + numericRange?: NumericRange; + supportsElement: ({ element }: { element: TimelineElement }) => boolean; + getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null; + setValue: ({ + element, + value, + }: { + element: TimelineElement; + value: AnimationValue; + }) => TimelineElement; +} + +const ANIMATION_PROPERTY_REGISTRY: Record< + AnimationPropertyPath, + AnimationPropertyDefinition +> = { + "transform.position.x": { + valueKind: "number", + defaultInterpolation: "linear", + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.transform.position.x : null, + setValue: ({ element, value }) => + isVisualElement(element) + ? { + ...element, + transform: { + ...element.transform, + position: { ...element.transform.position, x: value as number }, + }, + } + : element, + }, + "transform.position.y": { + valueKind: "number", + defaultInterpolation: "linear", + 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.scale": { + valueKind: "number", + defaultInterpolation: "linear", + numericRange: { min: MIN_TRANSFORM_SCALE }, + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.transform.scale : null, + setValue: ({ element, value }) => + isVisualElement(element) + ? { + ...element, + transform: { ...element.transform, scale: value as number }, + } + : element, + }, + "transform.rotate": { + valueKind: "number", + defaultInterpolation: "linear", + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.transform.rotate : null, + setValue: ({ element, value }) => + isVisualElement(element) + ? { + ...element, + transform: { ...element.transform, rotate: value as number }, + } + : element, + }, + opacity: { + valueKind: "number", + defaultInterpolation: "linear", + numericRange: { min: 0, max: 1 }, + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.opacity : null, + setValue: ({ element, value }) => + isVisualElement(element) + ? { ...element, opacity: value as number } + : element, + }, + volume: { + valueKind: "number", + defaultInterpolation: "linear", + numericRange: { min: 0, max: 1 }, + supportsElement: ({ element }) => element.type === "audio", + getValue: ({ element }) => + element.type === "audio" ? element.volume : null, + setValue: ({ element, value }) => + element.type === "audio" + ? { ...element, volume: value as number } + : element, + }, + color: { + valueKind: "color", + defaultInterpolation: "linear", + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => (element.type === "text" ? element.color : null), + setValue: ({ element, value }) => + element.type === "text" + ? { ...element, color: value as string } + : element, + }, + "background.color": { + valueKind: "color", + defaultInterpolation: "linear", + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" ? element.background.color : null, + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, color: value as string }, + } + : element, + }, + "background.paddingX": { + valueKind: "number", + defaultInterpolation: "linear", + numericRange: { min: 0 }, + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" + ? (element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX) + : null, + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, paddingX: value as number }, + } + : element, + }, + "background.paddingY": { + valueKind: "number", + defaultInterpolation: "linear", + numericRange: { min: 0 }, + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" + ? (element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY) + : null, + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, paddingY: value as number }, + } + : element, + }, + "background.offsetX": { + valueKind: "number", + defaultInterpolation: "linear", + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" + ? (element.background.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX) + : null, + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, offsetX: value as number }, + } + : element, + }, + "background.offsetY": { + valueKind: "number", + defaultInterpolation: "linear", + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" + ? (element.background.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY) + : null, + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, offsetY: value as number }, + } + : element, + }, + "background.cornerRadius": { + valueKind: "number", + defaultInterpolation: "linear", + numericRange: { min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX }, + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" + ? (element.background.cornerRadius ?? CORNER_RADIUS_MIN) + : null, + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, cornerRadius: value as number }, + } + : element, + }, +}; + +export function isAnimationPropertyPath({ + propertyPath, +}: { + propertyPath: string; +}): boolean { + return propertyPath in ANIMATION_PROPERTY_REGISTRY; +} + +export function getAnimationPropertyDefinition({ + propertyPath, +}: { + propertyPath: AnimationPropertyPath; +}): AnimationPropertyDefinition { + return ANIMATION_PROPERTY_REGISTRY[propertyPath]; +} + +export function supportsAnimationProperty({ + element, + propertyPath, +}: { + element: TimelineElement; + propertyPath: AnimationPropertyPath; +}): boolean { + const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); + return propertyDefinition.supportsElement({ element }); +} + +export function getElementBaseValueForProperty({ + element, + propertyPath, +}: { + element: TimelineElement; + propertyPath: AnimationPropertyPath; +}): AnimationValue | null { + const definition = getAnimationPropertyDefinition({ propertyPath }); + if (!definition.supportsElement({ element })) { + return null; + } + return definition.getValue({ element }); +} + +export function withElementBaseValueForProperty({ + element, + propertyPath, + value, +}: { + element: TimelineElement; + propertyPath: AnimationPropertyPath; + value: AnimationValue; +}): TimelineElement { + const coercedValue = coerceAnimationValueForProperty({ propertyPath, value }); + if (coercedValue === null) { + return element; + } + const definition = getAnimationPropertyDefinition({ propertyPath }); + if (!definition.supportsElement({ element })) { + return element; + } + return definition.setValue({ element, value: coercedValue }); +} + +export function getDefaultInterpolationForProperty({ + propertyPath, +}: { + propertyPath: AnimationPropertyPath; +}): AnimationInterpolation { + const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); + return propertyDefinition.defaultInterpolation; +} + +function clampNumericRange({ + value, + numericRange, +}: { + value: number; + numericRange: NumericRange | undefined; +}): number { + if (!numericRange) { + return value; + } + + const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY; + const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY; + return Math.min(maxValue, Math.max(minValue, value)); +} + +export function coerceAnimationValueForProperty({ + propertyPath, + value, +}: { + propertyPath: AnimationPropertyPath; + value: AnimationValue; +}): AnimationValue | null { + const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); + + if (propertyDefinition.valueKind === "number") { + if (typeof value !== "number" || Number.isNaN(value)) { + return null; + } + + return clampNumericRange({ + value, + numericRange: propertyDefinition.numericRange, + }); + } + + if (propertyDefinition.valueKind === "color") { + return typeof value === "string" ? value : null; + } + + if (typeof value === "string" || typeof value === "boolean") { + return value as DiscreteValue; + } + + return null; +} diff --git a/apps/web/src/lib/animation/resolve.ts b/apps/web/src/lib/animation/resolve.ts new file mode 100644 index 00000000..147be1b3 --- /dev/null +++ b/apps/web/src/lib/animation/resolve.ts @@ -0,0 +1,148 @@ +import type { AnimationPropertyPath, ElementAnimations } from "@/types/animation"; +import type { Transform } from "@/types/timeline"; +import { getColorValueAtTime, getNumberChannelValueAtTime } from "./interpolation"; +import { getColorChannelForPath } from "./color-channel"; +import { getNumberChannelForPath } from "./number-channel"; + +export function getElementLocalTime({ + timelineTime, + elementStartTime, + elementDuration, +}: { + timelineTime: number; + elementStartTime: number; + elementDuration: number; +}): number { + const localTime = timelineTime - elementStartTime; + if (localTime <= 0) { + return 0; + } + + if (localTime >= elementDuration) { + return elementDuration; + } + + return localTime; +} + +export function resolveTransformAtTime({ + baseTransform, + animations, + localTime, +}: { + baseTransform: Transform; + animations: ElementAnimations | undefined; + localTime: number; +}): Transform { + const safeLocalTime = Math.max(0, localTime); + return { + position: { + x: getNumberChannelValueAtTime({ + channel: getNumberChannelForPath({ + animations, + propertyPath: "transform.position.x", + }), + time: safeLocalTime, + fallbackValue: baseTransform.position.x, + }), + y: getNumberChannelValueAtTime({ + channel: getNumberChannelForPath({ + animations, + propertyPath: "transform.position.y", + }), + time: safeLocalTime, + fallbackValue: baseTransform.position.y, + }), + }, + scale: getNumberChannelValueAtTime({ + channel: getNumberChannelForPath({ + animations, + propertyPath: "transform.scale", + }), + time: safeLocalTime, + fallbackValue: baseTransform.scale, + }), + rotate: getNumberChannelValueAtTime({ + channel: getNumberChannelForPath({ + animations, + propertyPath: "transform.rotate", + }), + time: safeLocalTime, + fallbackValue: baseTransform.rotate, + }), + }; +} + +export function resolveOpacityAtTime({ + baseOpacity, + animations, + localTime, +}: { + baseOpacity: number; + animations: ElementAnimations | undefined; + localTime: number; +}): number { + return getNumberChannelValueAtTime({ + channel: getNumberChannelForPath({ + animations, + propertyPath: "opacity", + }), + time: Math.max(0, localTime), + fallbackValue: baseOpacity, + }); +} + +export function resolveNumberAtTime({ + baseValue, + animations, + propertyPath, + localTime, +}: { + baseValue: number; + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; + localTime: number; +}): number { + return getNumberChannelValueAtTime({ + channel: getNumberChannelForPath({ animations, propertyPath }), + time: Math.max(0, localTime), + fallbackValue: baseValue, + }); +} + +export function resolveColorAtTime({ + baseColor, + animations, + propertyPath, + localTime, +}: { + baseColor: string; + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; + localTime: number; +}): string { + return getColorValueAtTime({ + channel: getColorChannelForPath({ animations, propertyPath }), + time: Math.max(0, localTime), + fallbackValue: baseColor, + }); +} + +export function resolveVolumeAtTime({ + baseVolume, + animations, + localTime, +}: { + baseVolume: number; + animations: ElementAnimations | undefined; + localTime: number; +}): number { + return getNumberChannelValueAtTime({ + channel: getNumberChannelForPath({ + animations, + propertyPath: "volume", + }), + time: Math.max(0, localTime), + fallbackValue: baseVolume, + }); +} diff --git a/apps/web/src/lib/commands/__tests__/keyframe-aware-commands.test.ts b/apps/web/src/lib/commands/__tests__/keyframe-aware-commands.test.ts new file mode 100644 index 00000000..d2356a39 --- /dev/null +++ b/apps/web/src/lib/commands/__tests__/keyframe-aware-commands.test.ts @@ -0,0 +1,433 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { EditorCore } from "@/core"; +import type { TimelineTrack, VideoElement } from "@/types/timeline"; +import { DEFAULT_TRANSFORM } from "@/constants/timeline-constants"; +import { UpdateElementDurationCommand } from "@/lib/commands/timeline/element/update-element-duration"; +import { UpdateElementTrimCommand } from "@/lib/commands/timeline/element/update-element-trim"; +import { SplitElementsCommand } from "@/lib/commands/timeline/element/split-elements"; +import { DuplicateElementsCommand } from "@/lib/commands/timeline/element/duplicate-elements"; +import { UpsertKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/upsert-keyframe"; +import { RemoveKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/remove-keyframe"; +import { RetimeKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/retime-keyframe"; + +type MockEditor = { + timeline: { + getTracks: () => TimelineTrack[]; + updateTracks: (tracks: TimelineTrack[]) => void; + }; + selection: { + getSelectedElements: () => { trackId: string; elementId: string }[]; + setSelectedElements: ({ + elements, + }: { + elements: { trackId: string; elementId: string }[]; + }) => void; + }; +}; + +const originalGetInstance = EditorCore.getInstance; + +function mockEditorCore({ editor }: { editor: MockEditor }): void { + ( + EditorCore as unknown as { + getInstance: () => EditorCore; + } + ).getInstance = () => editor as unknown as EditorCore; +} + +function restoreEditorCore(): void { + ( + EditorCore as unknown as { + getInstance: typeof EditorCore.getInstance; + } + ).getInstance = originalGetInstance; +} + +function buildVideoElement(): VideoElement { + return { + id: "element-1", + name: "Clip", + type: "video", + mediaId: "media-1", + duration: 8, + startTime: 1, + trimStart: 0, + trimEnd: 0, + transform: DEFAULT_TRANSFORM, + opacity: 1, + animations: { + channels: { + "transform.scale": { + valueKind: "number", + keyframes: [ + { id: "kf-a", time: 0, value: 1, interpolation: "linear" }, + { id: "kf-b", time: 3, value: 1.5, interpolation: "linear" }, + { id: "kf-c", time: 6, value: 2, interpolation: "linear" }, + ], + }, + }, + }, + }; +} + +function buildTracks({ element }: { element: VideoElement }): TimelineTrack[] { + return [ + { + id: "track-1", + name: "Main", + type: "video", + elements: [element], + isMain: true, + muted: false, + hidden: false, + }, + ]; +} + +afterEach(() => { + restoreEditorCore(); +}); + +describe("keyframe-aware timeline commands", () => { + test("duration updates clamp keyframes beyond the new duration", () => { + const tracks = buildTracks({ element: buildVideoElement() }); + let updatedTracks: TimelineTrack[] = tracks; + mockEditorCore({ + editor: { + timeline: { + getTracks: () => tracks, + updateTracks: (nextTracks) => { + updatedTracks = nextTracks; + }, + }, + selection: { + getSelectedElements: () => [], + setSelectedElements: () => {}, + }, + }, + }); + + new UpdateElementDurationCommand({ + trackId: "track-1", + elementId: "element-1", + duration: 3, + }).execute(); + + const updatedElement = (updatedTracks[0].elements[0] as VideoElement).animations; + expect( + updatedElement?.channels["transform.scale"]?.keyframes.map( + (keyframe) => keyframe.time, + ), + ).toEqual([0, 3]); + }); + + test("trim updates clamp keyframes when duration is changed", () => { + const tracks = buildTracks({ element: buildVideoElement() }); + let updatedTracks: TimelineTrack[] = tracks; + mockEditorCore({ + editor: { + timeline: { + getTracks: () => tracks, + updateTracks: (nextTracks) => { + updatedTracks = nextTracks; + }, + }, + selection: { + getSelectedElements: () => [], + setSelectedElements: () => {}, + }, + }, + }); + + new UpdateElementTrimCommand({ + elementId: "element-1", + trimStart: 0, + trimEnd: 0, + startTime: 1, + duration: 2, + }).execute(); + + const updatedElement = updatedTracks[0].elements[0] as VideoElement; + expect(updatedElement.duration).toBe(2); + expect( + updatedElement.animations?.channels["transform.scale"]?.keyframes.map( + (keyframe) => keyframe.time, + ), + ).toEqual([0]); + }); + + test("split rebases right-side keyframes and keeps continuity at split time", () => { + const tracks = buildTracks({ element: buildVideoElement() }); + let updatedTracks: TimelineTrack[] = tracks; + mockEditorCore({ + editor: { + timeline: { + getTracks: () => tracks, + updateTracks: (nextTracks) => { + updatedTracks = nextTracks; + }, + }, + selection: { + getSelectedElements: () => [], + setSelectedElements: () => {}, + }, + }, + }); + + new SplitElementsCommand({ + elements: [{ trackId: "track-1", elementId: "element-1" }], + splitTime: 5, + }).execute(); + + const leftElement = updatedTracks[0].elements.find( + (element) => element.id === "element-1", + ) as VideoElement; + const rightElement = updatedTracks[0].elements.find( + (element) => element.id !== "element-1", + ) as VideoElement; + + expect( + leftElement.animations?.channels["transform.scale"]?.keyframes.map( + (keyframe) => keyframe.time, + ), + ).toEqual([0, 3, 4]); + expect( + rightElement.animations?.channels["transform.scale"]?.keyframes.map( + (keyframe) => keyframe.time, + ), + ).toEqual([0, 2]); + expect( + rightElement.animations?.channels["transform.scale"]?.keyframes[0]?.value, + ).toBeCloseTo(5 / 3, 4); + }); + + test("duplicate creates independent keyframe ids for copied element", () => { + const tracks = buildTracks({ element: buildVideoElement() }); + let updatedTracks: TimelineTrack[] = tracks; + mockEditorCore({ + editor: { + timeline: { + getTracks: () => tracks, + updateTracks: (nextTracks) => { + updatedTracks = nextTracks; + }, + }, + selection: { + getSelectedElements: () => [{ trackId: "track-1", elementId: "element-1" }], + setSelectedElements: () => {}, + }, + }, + }); + + new DuplicateElementsCommand({ + elements: [{ trackId: "track-1", elementId: "element-1" }], + }).execute(); + + const originalElement = updatedTracks.find( + (track) => track.id === "track-1", + )?.elements[0] as VideoElement; + const duplicatedTrack = updatedTracks.find((track) => track.id !== "track-1"); + const duplicatedElement = duplicatedTrack?.elements[0] as VideoElement; + + expect(duplicatedElement).toBeDefined(); + expect( + duplicatedElement.animations?.channels["transform.scale"]?.keyframes.map( + (keyframe) => keyframe.time, + ), + ).toEqual([0, 3, 6]); + expect( + duplicatedElement.animations?.channels["transform.scale"]?.keyframes[0]?.id, + ).not.toBe( + originalElement.animations?.channels["transform.scale"]?.keyframes[0]?.id, + ); + }); +}); + +describe("generic keyframe commands", () => { + test("upsert adds or updates keyframe at target time", () => { + const element = buildVideoElement(); + const tracks = buildTracks({ element }); + let updatedTracks: TimelineTrack[] = tracks; + mockEditorCore({ + editor: { + timeline: { + getTracks: () => tracks, + updateTracks: (nextTracks) => { + updatedTracks = nextTracks; + }, + }, + selection: { + getSelectedElements: () => [], + setSelectedElements: () => {}, + }, + }, + }); + + new UpsertKeyframeCommand({ + trackId: "track-1", + elementId: "element-1", + propertyPath: "transform.scale", + time: 2, + value: 2.5, + }).execute(); + + const updatedElement = updatedTracks[0].elements[0] as VideoElement; + const keyframes = + updatedElement.animations?.channels["transform.scale"]?.keyframes ?? []; + const atTwo = keyframes.find((keyframe) => Math.abs(keyframe.time - 2) < 0.001); + expect(atTwo?.value).toBe(2.5); + }); + + test("remove deletes keyframe by id", () => { + const element = buildVideoElement(); + const tracks = buildTracks({ element }); + let updatedTracks: TimelineTrack[] = tracks; + mockEditorCore({ + editor: { + timeline: { + getTracks: () => tracks, + updateTracks: (nextTracks) => { + updatedTracks = nextTracks; + }, + }, + selection: { + getSelectedElements: () => [], + setSelectedElements: () => {}, + }, + }, + }); + + new RemoveKeyframeCommand({ + trackId: "track-1", + elementId: "element-1", + propertyPath: "transform.scale", + keyframeId: "kf-b", + }).execute(); + + const updatedElement = updatedTracks[0].elements[0] as VideoElement; + const keyframes = + updatedElement.animations?.channels["transform.scale"]?.keyframes ?? []; + expect(keyframes).toHaveLength(2); + expect(keyframes.find((keyframe) => keyframe.id === "kf-b")).toBeUndefined(); + expect(updatedElement.transform.scale).toBe(1); + }); + + test("remove persists value to base property when channel becomes empty", () => { + const element: VideoElement = { + ...buildVideoElement(), + transform: { + ...DEFAULT_TRANSFORM, + scale: 1, + }, + animations: { + channels: { + "transform.scale": { + valueKind: "number", + keyframes: [ + { + id: "only-scale", + time: 2, + value: 1.43, + interpolation: "linear", + }, + ], + }, + }, + }, + }; + const tracks = buildTracks({ element }); + let updatedTracks: TimelineTrack[] = tracks; + mockEditorCore({ + editor: { + timeline: { + getTracks: () => tracks, + updateTracks: (nextTracks) => { + updatedTracks = nextTracks; + }, + }, + selection: { + getSelectedElements: () => [], + setSelectedElements: () => {}, + }, + }, + }); + + new RemoveKeyframeCommand({ + trackId: "track-1", + elementId: "element-1", + propertyPath: "transform.scale", + keyframeId: "only-scale", + }).execute(); + + const updatedElement = updatedTracks[0].elements[0] as VideoElement; + expect(updatedElement.transform.scale).toBe(1.43); + expect(updatedElement.animations?.channels["transform.scale"]).toBeUndefined(); + }); + + test("upsert supports non-transform paths like opacity", () => { + const element = buildVideoElement(); + const tracks = buildTracks({ element }); + let updatedTracks: TimelineTrack[] = tracks; + mockEditorCore({ + editor: { + timeline: { + getTracks: () => tracks, + updateTracks: (nextTracks) => { + updatedTracks = nextTracks; + }, + }, + selection: { + getSelectedElements: () => [], + setSelectedElements: () => {}, + }, + }, + }); + + new UpsertKeyframeCommand({ + trackId: "track-1", + elementId: "element-1", + propertyPath: "opacity", + time: 1, + value: 0.35, + }).execute(); + + const updatedElement = updatedTracks[0].elements[0] as VideoElement; + const opacityChannel = updatedElement.animations?.channels.opacity; + expect(opacityChannel?.valueKind).toBe("number"); + expect(opacityChannel?.keyframes[0]?.value).toBe(0.35); + }); + + test("retime moves keyframe to new time", () => { + const element = buildVideoElement(); + const tracks = buildTracks({ element }); + let updatedTracks: TimelineTrack[] = tracks; + mockEditorCore({ + editor: { + timeline: { + getTracks: () => tracks, + updateTracks: (nextTracks) => { + updatedTracks = nextTracks; + }, + }, + selection: { + getSelectedElements: () => [], + setSelectedElements: () => {}, + }, + }, + }); + + new RetimeKeyframeCommand({ + trackId: "track-1", + elementId: "element-1", + propertyPath: "transform.scale", + keyframeId: "kf-b", + nextTime: 4, + }).execute(); + + const updatedElement = updatedTracks[0].elements[0] as VideoElement; + const keyframe = updatedElement.animations?.channels["transform.scale"]?.keyframes.find( + (existingKeyframe) => existingKeyframe.id === "kf-b", + ); + expect(keyframe?.time).toBe(4); + }); +}); diff --git a/apps/web/src/lib/commands/timeline/clipboard/paste.ts b/apps/web/src/lib/commands/timeline/clipboard/paste.ts index 49554e0e..800cbb90 100644 --- a/apps/web/src/lib/commands/timeline/clipboard/paste.ts +++ b/apps/web/src/lib/commands/timeline/clipboard/paste.ts @@ -13,6 +13,7 @@ import { isMainTrack, enforceMainTrackStart, } from "@/lib/timeline/track-utils"; +import { cloneAnimations } from "@/lib/animation"; export class PasteCommand extends Command { private savedState: TimelineTrack[] | null = null; @@ -176,6 +177,10 @@ function buildPastedElements({ ...item.element, id: newElementId, startTime, + animations: cloneAnimations({ + animations: item.element.animations, + shouldRegenerateKeyframeIds: true, + }), } as TimelineElement); } diff --git a/apps/web/src/lib/commands/timeline/element/delete-elements.ts b/apps/web/src/lib/commands/timeline/element/delete-elements.ts index 91eb6397..7fb46c36 100644 --- a/apps/web/src/lib/commands/timeline/element/delete-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/delete-elements.ts @@ -1,13 +1,23 @@ import { Command } from "@/lib/commands/base-command"; import type { TimelineTrack } from "@/types/timeline"; import { EditorCore } from "@/core"; -import { isMainTrack } from "@/lib/timeline"; +import { isMainTrack, rippleShiftElements } from "@/lib/timeline"; export class DeleteElementsCommand extends Command { private savedState: TimelineTrack[] | null = null; + private readonly elements: { trackId: string; elementId: string }[]; + private readonly rippleEnabled: boolean; - constructor(private elements: { trackId: string; elementId: string }[]) { + constructor({ + elements, + rippleEnabled = false, + }: { + elements: { trackId: string; elementId: string }[]; + rippleEnabled?: boolean; + }) { super(); + this.elements = elements; + this.rippleEnabled = rippleEnabled; } execute(): void { @@ -16,23 +26,44 @@ export class DeleteElementsCommand extends Command { const updatedTracks = this.savedState .map((track) => { - const hasElementsToDelete = this.elements.some( - (el) => el.trackId === track.id, + const elementsToDeleteOnTrack = this.elements.filter( + (target) => target.trackId === track.id, ); + const hasElementsToDelete = elementsToDeleteOnTrack.length > 0; if (!hasElementsToDelete) { return track; } - return { - ...track, - elements: track.elements.filter( - (element) => - !this.elements.some( - (el) => el.trackId === track.id && el.elementId === element.id, - ), - ), - } as typeof track; + const deletedElementInfos = elementsToDeleteOnTrack + .map((target) => + track.elements.find((element) => element.id === target.elementId), + ) + .filter((element): element is NonNullable<typeof element> => element !== undefined) + .map((element) => ({ startTime: element.startTime, duration: element.duration })); + + let elements = track.elements.filter( + (element) => + !this.elements.some( + (target) => + target.trackId === track.id && target.elementId === element.id, + ), + ); + + if (this.rippleEnabled && deletedElementInfos.length > 0) { + const sortedByStartDesc = [...deletedElementInfos].sort( + (a, b) => b.startTime - a.startTime, + ); + for (const { startTime, duration } of sortedByStartDesc) { + elements = rippleShiftElements({ + elements, + afterTime: startTime, + shiftAmount: duration, + }); + } + } + + return { ...track, elements } as typeof track; }) .filter((track) => track.elements.length > 0 || isMainTrack(track)); diff --git a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts index cf1bd94c..97b475f4 100644 --- a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts @@ -6,6 +6,7 @@ import { buildEmptyTrack, getHighestInsertIndexForTrack, } from "@/lib/timeline/track-utils"; +import { cloneAnimations } from "@/lib/animation"; interface DuplicateElementsParams { elements: { trackId: string; elementId: string }[]; @@ -32,7 +33,7 @@ export class DuplicateElementsCommand extends Command { for (const track of this.savedState) { const elementsToDuplicate = this.elements.filter( - (el) => el.trackId === track.id, + (elementEntry) => elementEntry.trackId === track.id, ); if (elementsToDuplicate.length === 0) { @@ -114,5 +115,14 @@ function buildDuplicateElement({ id: string; startTime: number; }): TimelineElement { - return { ...element, id, name: `${element.name} (copy)`, startTime }; + return { + ...element, + id, + name: `${element.name} (copy)`, + startTime, + animations: cloneAnimations({ + animations: element.animations, + shouldRegenerateKeyframeIds: true, + }), + }; } diff --git a/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts new file mode 100644 index 00000000..5299749d --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts @@ -0,0 +1,74 @@ +import { Command } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; +import type { TimelineTrack, VisualElement } from "@/types/timeline"; +import { buildDefaultEffectInstance } from "@/lib/effects"; + +function addEffectToElement({ + element, + effectType, +}: { + element: VisualElement; + effectType: string; +}): VisualElement { + const instance = buildDefaultEffectInstance({ effectType }); + const currentEffects = element.effects ?? []; + return { ...element, effects: [...currentEffects, instance] }; +} + +export class AddClipEffectCommand extends Command { + private savedState: TimelineTrack[] | null = null; + private effectId: string | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly effectType: string; + + constructor({ + trackId, + elementId, + effectType, + }: { + trackId: string; + elementId: string; + effectType: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.effectType = effectType; + } + + execute(): void { + const editor = EditorCore.getInstance(); + this.savedState = editor.timeline.getTracks(); + + const updatedTracks = updateElementInTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isVisualElement, + update: (element) => { + const updated = addEffectToElement({ + element: element as VisualElement, + effectType: this.effectType, + }); + const effects = updated.effects ?? []; + this.effectId = effects[effects.length - 1]?.id ?? null; + return updated; + }, + }); + + editor.timeline.updateTracks(updatedTracks); + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } + + getEffectId(): string | null { + return this.effectId; + } +} diff --git a/apps/web/src/lib/commands/timeline/element/effects/index.ts b/apps/web/src/lib/commands/timeline/element/effects/index.ts new file mode 100644 index 00000000..133ce661 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/effects/index.ts @@ -0,0 +1,5 @@ +export { AddClipEffectCommand } from "./add-effect"; +export { RemoveClipEffectCommand } from "./remove-effect"; +export { ToggleClipEffectCommand } from "./toggle-effect"; +export { UpdateClipEffectParamsCommand } from "./update-effect-params"; +export { ReorderClipEffectsCommand } from "./reorder-effect"; diff --git a/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts new file mode 100644 index 00000000..b475474f --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts @@ -0,0 +1,65 @@ +import { Command } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; +import type { TimelineTrack, VisualElement } from "@/types/timeline"; + +function removeEffectFromElement({ + element, + effectId, +}: { + element: VisualElement; + effectId: string; +}): VisualElement { + const currentEffects = element.effects ?? []; + const filtered = currentEffects.filter((effect) => effect.id !== effectId); + return { ...element, effects: filtered }; +} + +export class RemoveClipEffectCommand extends Command { + private savedState: TimelineTrack[] | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly effectId: string; + + constructor({ + trackId, + elementId, + effectId, + }: { + trackId: string; + elementId: string; + effectId: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.effectId = effectId; + } + + execute(): void { + const editor = EditorCore.getInstance(); + this.savedState = editor.timeline.getTracks(); + + const updatedTracks = updateElementInTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isVisualElement, + update: (element) => { + return removeEffectFromElement({ + element: element as VisualElement, + effectId: this.effectId, + }); + }, + }); + + editor.timeline.updateTracks(updatedTracks); + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts new file mode 100644 index 00000000..81c66503 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts @@ -0,0 +1,73 @@ +import { Command } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; +import type { TimelineTrack, VisualElement } from "@/types/timeline"; + +function reorderEffectsOnElement({ + element, + fromIndex, + toIndex, +}: { + element: VisualElement; + fromIndex: number; + toIndex: number; +}): VisualElement { + const effects = [...(element.effects ?? [])]; + const [moved] = effects.splice(fromIndex, 1); + effects.splice(toIndex, 0, moved); + return { ...element, effects }; +} + +export class ReorderClipEffectsCommand extends Command { + private savedState: TimelineTrack[] | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly fromIndex: number; + private readonly toIndex: number; + + constructor({ + trackId, + elementId, + fromIndex, + toIndex, + }: { + trackId: string; + elementId: string; + fromIndex: number; + toIndex: number; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.fromIndex = fromIndex; + this.toIndex = toIndex; + } + + execute(): void { + const editor = EditorCore.getInstance(); + this.savedState = editor.timeline.getTracks(); + + const updatedTracks = updateElementInTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isVisualElement, + update: (element) => { + return reorderEffectsOnElement({ + element: element as VisualElement, + fromIndex: this.fromIndex, + toIndex: this.toIndex, + }); + }, + }); + + editor.timeline.updateTracks(updatedTracks); + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts new file mode 100644 index 00000000..70bf955d --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts @@ -0,0 +1,67 @@ +import { Command } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; +import type { TimelineTrack, VisualElement } from "@/types/timeline"; + +export function toggleEffectOnElement({ + element, + effectId, +}: { + element: VisualElement; + effectId: string; +}): VisualElement { + const currentEffects = element.effects ?? []; + const updated = currentEffects.map((effect) => + effect.id === effectId ? { ...effect, enabled: !effect.enabled } : effect, + ); + return { ...element, effects: updated }; +} + +export class ToggleClipEffectCommand extends Command { + private savedState: TimelineTrack[] | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly effectId: string; + + constructor({ + trackId, + elementId, + effectId, + }: { + trackId: string; + elementId: string; + effectId: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.effectId = effectId; + } + + execute(): void { + const editor = EditorCore.getInstance(); + this.savedState = editor.timeline.getTracks(); + + const updatedTracks = updateElementInTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isVisualElement, + update: (element) => { + return toggleEffectOnElement({ + element: element as VisualElement, + effectId: this.effectId, + }); + }, + }); + + editor.timeline.updateTracks(updatedTracks); + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts b/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts new file mode 100644 index 00000000..d9bf0aad --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts @@ -0,0 +1,86 @@ +import { Command } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; +import type { EffectParamValues } from "@/types/effects"; +import type { TimelineTrack, VisualElement } from "@/types/timeline"; + +function updateEffectParamsOnElement({ + element, + effectId, + params, +}: { + element: VisualElement; + effectId: string; + params: Partial<EffectParamValues>; +}): VisualElement { + const currentEffects = element.effects ?? []; + const updated = currentEffects.map((effect) => { + if (effect.id !== effectId) { + return effect; + } + + const nextParams = { ...effect.params }; + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + nextParams[key] = value; + } + } + + return { ...effect, params: nextParams }; + }); + return { ...element, effects: updated }; +} + +export class UpdateClipEffectParamsCommand extends Command { + private savedState: TimelineTrack[] | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly effectId: string; + private readonly params: Partial<EffectParamValues>; + + constructor({ + trackId, + elementId, + effectId, + params, + }: { + trackId: string; + elementId: string; + effectId: string; + params: Partial<EffectParamValues>; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.effectId = effectId; + this.params = params; + } + + execute(): void { + const editor = EditorCore.getInstance(); + this.savedState = editor.timeline.getTracks(); + + const updatedTracks = updateElementInTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isVisualElement, + update: (element) => { + return updateEffectParamsOnElement({ + element: element as VisualElement, + effectId: this.effectId, + params: this.params, + }); + }, + }); + + editor.timeline.updateTracks(updatedTracks); + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/element/index.ts b/apps/web/src/lib/commands/timeline/element/index.ts index 3f10baa4..413d1adf 100644 --- a/apps/web/src/lib/commands/timeline/element/index.ts +++ b/apps/web/src/lib/commands/timeline/element/index.ts @@ -9,3 +9,5 @@ export { UpdateElementCommand } from "./update-element"; export { ToggleElementsVisibilityCommand } from "./toggle-elements-visibility"; export { ToggleElementsMutedCommand } from "./toggle-elements-muted"; export { MoveElementCommand } from "./move-elements"; +export * from "./keyframes"; +export * from "./effects"; diff --git a/apps/web/src/lib/commands/timeline/element/insert-element.ts b/apps/web/src/lib/commands/timeline/element/insert-element.ts index e1551471..68fc9841 100644 --- a/apps/web/src/lib/commands/timeline/element/insert-element.ts +++ b/apps/web/src/lib/commands/timeline/element/insert-element.ts @@ -173,6 +173,11 @@ export class InsertElementCommand extends Command { return false; } + if (element.type === "effect" && !element.effectType) { + console.error("Effect element must have effectType"); + return false; + } + return true; } diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/index.ts b/apps/web/src/lib/commands/timeline/element/keyframes/index.ts new file mode 100644 index 00000000..c03b14e9 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/keyframes/index.ts @@ -0,0 +1,5 @@ +export * from "./remove-effect-param-keyframe"; +export * from "./remove-keyframe"; +export * from "./retime-keyframe"; +export * from "./upsert-effect-param-keyframe"; +export * from "./upsert-keyframe"; diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts new file mode 100644 index 00000000..d4922532 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts @@ -0,0 +1,68 @@ +import { EditorCore } from "@/core"; +import { Command } from "@/lib/commands/base-command"; +import { removeEffectParamKeyframe } from "@/lib/animation/effect-param-channel"; +import { updateElementInTracks } from "@/lib/timeline"; +import { isVisualElement } from "@/lib/timeline/element-utils"; +import type { TimelineTrack } from "@/types/timeline"; + +export class RemoveEffectParamKeyframeCommand extends Command { + private savedState: TimelineTrack[] | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly effectId: string; + private readonly paramKey: string; + private readonly keyframeId: string; + + constructor({ + trackId, + elementId, + effectId, + paramKey, + keyframeId, + }: { + trackId: string; + elementId: string; + effectId: string; + paramKey: string; + keyframeId: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.effectId = effectId; + this.paramKey = paramKey; + this.keyframeId = keyframeId; + } + + execute(): void { + const editor = EditorCore.getInstance(); + this.savedState = editor.timeline.getTracks(); + + const updatedTracks = updateElementInTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isVisualElement, + update: (element) => { + const animations = removeEffectParamKeyframe({ + animations: element.animations, + effectId: this.effectId, + paramKey: this.paramKey, + keyframeId: this.keyframeId, + }); + return { ...element, animations }; + }, + }); + + editor.timeline.updateTracks(updatedTracks); + } + + undo(): void { + if (!this.savedState) { + return; + } + + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } +} diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts new file mode 100644 index 00000000..f12f9829 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts @@ -0,0 +1,141 @@ +import { EditorCore } from "@/core"; +import { + getChannel, + getChannelValueAtTime, + getElementBaseValueForProperty, + removeElementKeyframe, + supportsAnimationProperty, + withElementBaseValueForProperty, +} from "@/lib/animation"; +import { Command } from "@/lib/commands/base-command"; +import { updateElementInTracks } from "@/lib/timeline"; +import type { AnimationPropertyPath } from "@/types/animation"; +import type { TimelineElement, TimelineTrack } from "@/types/timeline"; + +function sampleValueBeforeRemoval({ + element, + propertyPath, + keyframeId, +}: { + element: TimelineElement; + propertyPath: AnimationPropertyPath; + keyframeId: string; +}): number | null { + const channel = getChannel({ + animations: element.animations, + propertyPath, + }); + const keyframe = channel?.keyframes.find( + (candidate) => candidate.id === keyframeId, + ); + if (!channel || !keyframe) { + return null; + } + + const baseValue = getElementBaseValueForProperty({ element, propertyPath }); + if (baseValue === null || typeof baseValue !== "number") { + return null; + } + + const sampled = getChannelValueAtTime({ + channel, + time: keyframe.time, + fallbackValue: baseValue, + }); + return typeof sampled === "number" ? sampled : null; +} + +function removeKeyframeAndPersist({ + element, + propertyPath, + keyframeId, +}: { + element: TimelineElement; + propertyPath: AnimationPropertyPath; + keyframeId: string; +}): TimelineElement { + const valueBefore = sampleValueBeforeRemoval({ + element, + propertyPath, + keyframeId, + }); + + const nextAnimations = removeElementKeyframe({ + animations: element.animations, + propertyPath, + keyframeId, + }); + + const isChannelNowEmpty = + getChannel({ animations: nextAnimations, propertyPath }) === undefined; + const shouldPersistToBase = isChannelNowEmpty && valueBefore !== null; + + const baseElement = shouldPersistToBase + ? withElementBaseValueForProperty({ + element, + propertyPath, + value: valueBefore, + }) + : element; + + return { ...baseElement, animations: nextAnimations }; +} + +export class RemoveKeyframeCommand extends Command { + private savedState: TimelineTrack[] | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly propertyPath: AnimationPropertyPath; + private readonly keyframeId: string; + + constructor({ + trackId, + elementId, + propertyPath, + keyframeId, + }: { + trackId: string; + elementId: string; + propertyPath: AnimationPropertyPath; + keyframeId: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.propertyPath = propertyPath; + this.keyframeId = keyframeId; + } + + execute(): void { + const editor = EditorCore.getInstance(); + this.savedState = editor.timeline.getTracks(); + + const updatedTracks = updateElementInTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: (element) => + supportsAnimationProperty({ + element, + propertyPath: this.propertyPath, + }), + update: (element) => + removeKeyframeAndPersist({ + element, + propertyPath: this.propertyPath, + keyframeId: this.keyframeId, + }), + }); + + editor.timeline.updateTracks(updatedTracks); + } + + undo(): void { + if (!this.savedState) { + return; + } + + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } +} diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts new file mode 100644 index 00000000..5328419f --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts @@ -0,0 +1,76 @@ +import { EditorCore } from "@/core"; +import { retimeElementKeyframe, supportsAnimationProperty } from "@/lib/animation"; +import { Command } from "@/lib/commands/base-command"; +import { updateElementInTracks } from "@/lib/timeline"; +import type { AnimationPropertyPath } from "@/types/animation"; +import type { TimelineTrack } from "@/types/timeline"; + +export class RetimeKeyframeCommand extends Command { + private savedState: TimelineTrack[] | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly propertyPath: AnimationPropertyPath; + private readonly keyframeId: string; + private readonly nextTime: number; + + constructor({ + trackId, + elementId, + propertyPath, + keyframeId, + nextTime, + }: { + trackId: string; + elementId: string; + propertyPath: AnimationPropertyPath; + keyframeId: string; + nextTime: number; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.propertyPath = propertyPath; + this.keyframeId = keyframeId; + this.nextTime = nextTime; + } + + execute(): void { + const editor = EditorCore.getInstance(); + this.savedState = editor.timeline.getTracks(); + + const updatedTracks = updateElementInTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: (element) => + supportsAnimationProperty({ + element, + propertyPath: this.propertyPath, + }), + update: (element) => { + const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration)); + if (!Number.isFinite(boundedTime)) return element; + return { + ...element, + animations: retimeElementKeyframe({ + animations: element.animations, + propertyPath: this.propertyPath, + keyframeId: this.keyframeId, + time: boundedTime, + }), + }; + }, + }); + + editor.timeline.updateTracks(updatedTracks); + } + + undo(): void { + if (!this.savedState) { + return; + } + + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } +} diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts new file mode 100644 index 00000000..f5236279 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts @@ -0,0 +1,84 @@ +import { EditorCore } from "@/core"; +import { Command } from "@/lib/commands/base-command"; +import { upsertEffectParamKeyframe } from "@/lib/animation/effect-param-channel"; +import { updateElementInTracks } from "@/lib/timeline"; +import { isVisualElement } from "@/lib/timeline/element-utils"; +import type { TimelineTrack } from "@/types/timeline"; + +export class UpsertEffectParamKeyframeCommand extends Command { + private savedState: TimelineTrack[] | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly effectId: string; + private readonly paramKey: string; + private readonly time: number; + private readonly value: number; + private readonly interpolation: "linear" | "hold" | undefined; + private readonly keyframeId: string | undefined; + + constructor({ + trackId, + elementId, + effectId, + paramKey, + time, + value, + interpolation, + keyframeId, + }: { + trackId: string; + elementId: string; + effectId: string; + paramKey: string; + time: number; + value: number; + interpolation?: "linear" | "hold"; + keyframeId?: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.effectId = effectId; + this.paramKey = paramKey; + this.time = time; + this.value = value; + this.interpolation = interpolation; + this.keyframeId = keyframeId; + } + + execute(): void { + const editor = EditorCore.getInstance(); + this.savedState = editor.timeline.getTracks(); + + const updatedTracks = updateElementInTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isVisualElement, + update: (element) => { + const boundedTime = Math.max(0, Math.min(this.time, element.duration)); + const animations = upsertEffectParamKeyframe({ + animations: element.animations, + effectId: this.effectId, + paramKey: this.paramKey, + time: boundedTime, + value: this.value, + interpolation: this.interpolation, + keyframeId: this.keyframeId, + }); + return { ...element, animations }; + }, + }); + + editor.timeline.updateTracks(updatedTracks); + } + + undo(): void { + if (!this.savedState) { + return; + } + + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } +} diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts new file mode 100644 index 00000000..370f6865 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts @@ -0,0 +1,89 @@ +import { EditorCore } from "@/core"; +import { Command } from "@/lib/commands/base-command"; +import { supportsAnimationProperty, upsertElementKeyframe } from "@/lib/animation"; +import { updateElementInTracks } from "@/lib/timeline"; +import type { TimelineTrack } from "@/types/timeline"; +import type { + AnimationInterpolation, + AnimationPropertyPath, + AnimationValue, +} from "@/types/animation"; + +export class UpsertKeyframeCommand extends Command { + private savedState: TimelineTrack[] | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly propertyPath: AnimationPropertyPath; + private readonly time: number; + private readonly value: AnimationValue; + private readonly interpolation: AnimationInterpolation | undefined; + private readonly keyframeId: string | undefined; + + constructor({ + trackId, + elementId, + propertyPath, + time, + value, + interpolation, + keyframeId, + }: { + trackId: string; + elementId: string; + propertyPath: AnimationPropertyPath; + time: number; + value: AnimationValue; + interpolation?: AnimationInterpolation; + keyframeId?: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.propertyPath = propertyPath; + this.time = time; + this.value = value; + this.interpolation = interpolation; + this.keyframeId = keyframeId; + } + + execute(): void { + const editor = EditorCore.getInstance(); + this.savedState = editor.timeline.getTracks(); + + const updatedTracks = updateElementInTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: (element) => + supportsAnimationProperty({ + element, + propertyPath: this.propertyPath, + }), + update: (element) => { + const boundedTime = Math.max(0, Math.min(this.time, element.duration)); + return { + ...element, + animations: upsertElementKeyframe({ + animations: element.animations, + propertyPath: this.propertyPath, + time: boundedTime, + value: this.value, + interpolation: this.interpolation, + keyframeId: this.keyframeId, + }), + }; + }, + }); + + editor.timeline.updateTracks(updatedTracks); + } + + undo(): void { + if (!this.savedState) { + return; + } + + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } +} diff --git a/apps/web/src/lib/commands/timeline/element/move-elements.ts b/apps/web/src/lib/commands/timeline/element/move-elements.ts index 2fe06694..51e18c21 100644 --- a/apps/web/src/lib/commands/timeline/element/move-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/move-elements.ts @@ -11,18 +11,39 @@ import { validateElementTrackCompatibility, enforceMainTrackStart, } from "@/lib/timeline/track-utils"; +import { rippleShiftElements } from "@/lib/timeline/ripple-utils"; export class MoveElementCommand extends Command { private savedState: TimelineTrack[] | null = null; + private readonly sourceTrackId: string; + private readonly targetTrackId: string; + private readonly elementId: string; + private readonly newStartTime: number; + private readonly createTrack: { type: TrackType; index: number } | undefined; + private readonly rippleEnabled: boolean; - constructor( - private sourceTrackId: string, - private targetTrackId: string, - private elementId: string, - private newStartTime: number, - private createTrack?: { type: TrackType; index: number }, - ) { + constructor({ + sourceTrackId, + targetTrackId, + elementId, + newStartTime, + createTrack, + rippleEnabled = false, + }: { + sourceTrackId: string; + targetTrackId: string; + elementId: string; + newStartTime: number; + createTrack?: { type: TrackType; index: number }; + rippleEnabled?: boolean; + }) { super(); + this.sourceTrackId = sourceTrackId; + this.targetTrackId = targetTrackId; + this.elementId = elementId; + this.newStartTime = newStartTime; + this.createTrack = createTrack; + this.rippleEnabled = rippleEnabled; } execute(): void { @@ -30,18 +51,17 @@ export class MoveElementCommand extends Command { this.savedState = editor.timeline.getTracks(); const sourceTrack = this.savedState.find( - (t) => t.id === this.sourceTrackId, + (track) => track.id === this.sourceTrackId, ); const element = sourceTrack?.elements.find( - (el) => el.id === this.elementId, + (trackElement) => trackElement.id === this.elementId, ); if (!sourceTrack || !element) { - console.error("Source track or element not found"); - return; + throw new Error("Source track or element not found"); } - let targetTrack = this.savedState.find((t) => t.id === this.targetTrackId); + let targetTrack = this.savedState.find((track) => track.id === this.targetTrackId); let tracksToUpdate = this.savedState; if (!targetTrack && this.createTrack) { const newTrack = buildEmptyTrack({ @@ -53,8 +73,7 @@ export class MoveElementCommand extends Command { targetTrack = newTrack; } if (!targetTrack) { - console.error("Target track not found"); - return; + throw new Error("Target track not found"); } const validation = validateElementTrackCompatibility({ @@ -63,8 +82,7 @@ export class MoveElementCommand extends Command { }); if (!validation.isValid) { - console.error(validation.errorMessage); - return; + throw new Error(validation.errorMessage); } const adjustedStartTime = enforceMainTrackStart({ @@ -74,6 +92,7 @@ export class MoveElementCommand extends Command { excludeElementId: this.elementId, }); + // keyframe times remain clip-local, so moving only changes element startTime. const movedElement: TimelineElement = { ...element, startTime: adjustedStartTime, @@ -81,32 +100,39 @@ export class MoveElementCommand extends Command { const isSameTrack = this.sourceTrackId === this.targetTrackId; - let updatedTracks = tracksToUpdate.map((track) => { + let updatedTracks = tracksToUpdate.map((track): TimelineTrack => { if (isSameTrack && track.id === this.sourceTrackId) { return { ...track, - elements: track.elements.map((el) => - el.id === this.elementId ? movedElement : el, + elements: track.elements.map((trackElement) => + trackElement.id === this.elementId ? movedElement : trackElement, ), - }; + } as typeof track; } if (track.id === this.sourceTrackId) { - return { - ...track, - elements: track.elements.filter((el) => el.id !== this.elementId), - }; + const remainingElements = track.elements.filter( + (trackElement) => trackElement.id !== this.elementId, + ); + const shiftedElements = this.rippleEnabled + ? rippleShiftElements({ + elements: remainingElements, + afterTime: element.startTime, + shiftAmount: element.duration, + }) + : remainingElements; + return { ...track, elements: shiftedElements } as typeof track; } if (track.id === this.targetTrackId) { return { ...track, elements: [...track.elements, movedElement], - }; + } as typeof track; } return track; - }) as TimelineTrack[]; + }); if (!isSameTrack) { const sourceTrackAfterMove = updatedTracks.find( diff --git a/apps/web/src/lib/commands/timeline/element/split-elements.ts b/apps/web/src/lib/commands/timeline/element/split-elements.ts index abe133c9..37ccbb12 100644 --- a/apps/web/src/lib/commands/timeline/element/split-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/split-elements.ts @@ -2,18 +2,34 @@ import { Command } from "@/lib/commands/base-command"; import type { TimelineTrack } from "@/types/timeline"; import { generateUUID } from "@/utils/id"; import { EditorCore } from "@/core"; +import { rippleShiftElements } from "@/lib/timeline"; +import { splitAnimationsAtTime } from "@/lib/animation"; export class SplitElementsCommand extends Command { private savedState: TimelineTrack[] | null = null; private rightSideElements: { trackId: string; elementId: string }[] = []; private previousSelection: { trackId: string; elementId: string }[] = []; + private readonly elements: { trackId: string; elementId: string }[]; + private readonly splitTime: number; + private readonly retainSide: "both" | "left" | "right"; + private readonly rippleEnabled: boolean; - constructor( - private elements: { trackId: string; elementId: string }[], - private splitTime: number, - private retainSide: "both" | "left" | "right" = "both", - ) { + constructor({ + elements, + splitTime, + retainSide = "both", + rippleEnabled = false, + }: { + elements: { trackId: string; elementId: string }[]; + splitTime: number; + retainSide?: "both" | "left" | "right"; + rippleEnabled?: boolean; + }) { super(); + this.elements = elements; + this.splitTime = splitTime; + this.retainSide = retainSide; + this.rippleEnabled = rippleEnabled; } getRightSideElements(): { trackId: string; elementId: string }[] { @@ -28,92 +44,113 @@ export class SplitElementsCommand extends Command { const updatedTracks = this.savedState.map((track) => { const elementsToSplit = this.elements.filter( - (el) => el.trackId === track.id, + (target) => target.trackId === track.id, ); if (elementsToSplit.length === 0) { return track; } - return { - ...track, - elements: track.elements.flatMap((element) => { - const shouldSplit = elementsToSplit.some( - (el) => el.elementId === element.id, - ); + let leftVisibleDurationForRipple: number | null = null; - if (!shouldSplit) { - return [element]; - } - - const effectiveStart = element.startTime; - const effectiveEnd = element.startTime + element.duration; + let elements = track.elements.flatMap((element) => { + const shouldSplit = elementsToSplit.some( + (target) => target.elementId === element.id, + ); - if ( - this.splitTime <= effectiveStart || - this.splitTime >= effectiveEnd - ) { - return [element]; - } + if (!shouldSplit) { + return [element]; + } - const relativeTime = this.splitTime - element.startTime; - const leftVisibleDuration = relativeTime; - const rightVisibleDuration = element.duration - relativeTime; + const effectiveStart = element.startTime; + const effectiveEnd = element.startTime + element.duration; - if (this.retainSide === "left") { - return [ - { - ...element, - duration: leftVisibleDuration, - trimEnd: element.trimEnd + rightVisibleDuration, - name: `${element.name} (left)`, - }, - ]; - } + if ( + this.splitTime <= effectiveStart || + this.splitTime >= effectiveEnd + ) { + return [element]; + } - if (this.retainSide === "right") { - const newId = generateUUID(); - this.rightSideElements.push({ - trackId: track.id, - elementId: newId, - }); - return [ - { - ...element, - id: newId, - startTime: this.splitTime, - duration: rightVisibleDuration, - trimStart: element.trimStart + leftVisibleDuration, - name: `${element.name} (right)`, - }, - ]; - } - - // "both" - split into two pieces - const secondElementId = generateUUID(); - this.rightSideElements.push({ - trackId: track.id, - elementId: secondElementId, - }); + const relativeTime = this.splitTime - element.startTime; + const leftVisibleDuration = relativeTime; + const rightVisibleDuration = element.duration - relativeTime; + const { leftAnimations, rightAnimations } = splitAnimationsAtTime({ + animations: element.animations, + splitTime: relativeTime, + shouldIncludeSplitBoundary: true, + }); + if (this.retainSide === "left") { return [ { ...element, duration: leftVisibleDuration, trimEnd: element.trimEnd + rightVisibleDuration, name: `${element.name} (left)`, + animations: leftAnimations, }, + ]; + } + + if (this.retainSide === "right") { + if (this.rippleEnabled && elementsToSplit.length === 1) { + leftVisibleDurationForRipple = leftVisibleDuration; + } + const newId = generateUUID(); + this.rightSideElements.push({ + trackId: track.id, + elementId: newId, + }); + return [ { ...element, - id: secondElementId, + id: newId, startTime: this.splitTime, duration: rightVisibleDuration, trimStart: element.trimStart + leftVisibleDuration, name: `${element.name} (right)`, + animations: rightAnimations, }, ]; - }), - } as typeof track; + } + + // "both" - split into two pieces + const secondElementId = generateUUID(); + this.rightSideElements.push({ + trackId: track.id, + elementId: secondElementId, + }); + + return [ + { + ...element, + duration: leftVisibleDuration, + trimEnd: element.trimEnd + rightVisibleDuration, + name: `${element.name} (left)`, + animations: leftAnimations, + }, + { + ...element, + id: secondElementId, + startTime: this.splitTime, + duration: rightVisibleDuration, + trimStart: element.trimStart + leftVisibleDuration, + name: `${element.name} (right)`, + animations: rightAnimations, + }, + ]; + }); + + if (this.rippleEnabled && leftVisibleDurationForRipple !== null) { + elements = rippleShiftElements({ + elements, + afterTime: this.splitTime, + shiftAmount: leftVisibleDurationForRipple, + }); + } + + return { ...track, elements } as typeof track; }); editor.timeline.updateTracks(updatedTracks); diff --git a/apps/web/src/lib/commands/timeline/element/update-element-duration.ts b/apps/web/src/lib/commands/timeline/element/update-element-duration.ts index 6b3a860b..3366548d 100644 --- a/apps/web/src/lib/commands/timeline/element/update-element-duration.ts +++ b/apps/web/src/lib/commands/timeline/element/update-element-duration.ts @@ -1,28 +1,48 @@ import { Command } from "@/lib/commands/base-command"; import type { TimelineTrack } from "@/types/timeline"; import { EditorCore } from "@/core"; +import { clampAnimationsToDuration } from "@/lib/animation"; export class UpdateElementDurationCommand extends Command { private savedState: TimelineTrack[] | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly duration: number; - constructor( - private trackId: string, - private elementId: string, - private duration: number, - ) { + constructor({ + trackId, + elementId, + duration, + }: { + trackId: string; + elementId: string; + duration: number; + }) { super(); + this.trackId = trackId; + this.elementId = elementId; + this.duration = duration; } execute(): void { const editor = EditorCore.getInstance(); this.savedState = editor.timeline.getTracks(); - const updatedTracks = this.savedState.map((t) => { - if (t.id !== this.trackId) return t; - const newElements = t.elements.map((el) => - el.id === this.elementId ? { ...el, duration: this.duration } : el, + const updatedTracks = this.savedState.map((track) => { + if (track.id !== this.trackId) return track; + const newElements = track.elements.map((element) => + element.id === this.elementId + ? { + ...element, + duration: this.duration, + animations: clampAnimationsToDuration({ + animations: element.animations, + duration: this.duration, + }), + } + : element, ); - return { ...t, elements: newElements } as typeof t; + return { ...track, elements: newElements } as typeof track; }); editor.timeline.updateTracks(updatedTracks); diff --git a/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts b/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts index 51dcb63d..e25bc0e3 100644 --- a/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts +++ b/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts @@ -5,12 +5,19 @@ import { enforceMainTrackStart } from "@/lib/timeline/track-utils"; export class UpdateElementStartTimeCommand extends Command { private savedState: TimelineTrack[] | null = null; + private readonly elements: { trackId: string; elementId: string }[]; + private readonly startTime: number; - constructor( - private elements: { trackId: string; elementId: string }[], - private startTime: number, - ) { + constructor({ + elements, + startTime, + }: { + elements: { trackId: string; elementId: string }[]; + startTime: number; + }) { super(); + this.elements = elements; + this.startTime = startTime; } execute(): void { @@ -20,7 +27,7 @@ export class UpdateElementStartTimeCommand extends Command { const currentTracks = this.savedState; const updatedTracks = currentTracks.map((track) => { const hasElementsToUpdate = this.elements.some( - (el) => el.trackId === track.id, + (elementEntry) => elementEntry.trackId === track.id, ); if (!hasElementsToUpdate) { @@ -29,7 +36,9 @@ export class UpdateElementStartTimeCommand extends Command { const newElements = track.elements.map((element) => { const shouldUpdate = this.elements.some( - (el) => el.elementId === element.id && el.trackId === track.id, + (elementEntry) => + elementEntry.elementId === element.id && + elementEntry.trackId === track.id, ); if (!shouldUpdate) { return element; diff --git a/apps/web/src/lib/commands/timeline/element/update-element-trim.ts b/apps/web/src/lib/commands/timeline/element/update-element-trim.ts index 99c63287..7548638a 100644 --- a/apps/web/src/lib/commands/timeline/element/update-element-trim.ts +++ b/apps/web/src/lib/commands/timeline/element/update-element-trim.ts @@ -1,18 +1,40 @@ import { Command } from "@/lib/commands/base-command"; import type { TimelineTrack } from "@/types/timeline"; import { EditorCore } from "@/core"; +import { clampAnimationsToDuration } from "@/lib/animation"; +import { rippleShiftElements } from "@/lib/timeline"; export class UpdateElementTrimCommand extends Command { private savedState: TimelineTrack[] | null = null; + private readonly elementId: string; + private readonly trimStart: number; + private readonly trimEnd: number; + private readonly startTime: number | undefined; + private readonly duration: number | undefined; + private readonly rippleEnabled: boolean; - constructor( - private elementId: string, - private trimStart: number, - private trimEnd: number, - private startTime?: number, - private duration?: number, - ) { + constructor({ + elementId, + trimStart, + trimEnd, + startTime, + duration, + rippleEnabled = false, + }: { + elementId: string; + trimStart: number; + trimEnd: number; + startTime?: number; + duration?: number; + rippleEnabled?: boolean; + }) { super(); + this.elementId = elementId; + this.trimStart = trimStart; + this.trimEnd = trimEnd; + this.startTime = startTime; + this.duration = duration; + this.rippleEnabled = rippleEnabled; } execute(): void { @@ -20,20 +42,52 @@ export class UpdateElementTrimCommand extends Command { this.savedState = editor.timeline.getTracks(); const updatedTracks = this.savedState.map((track) => { - const newElements = track.elements.map((element) => { - if (element.id !== this.elementId) { - return element; - } + const targetElement = track.elements.find( + (element) => element.id === this.elementId, + ); + if (!targetElement) return track; + const nextDuration = this.duration ?? targetElement.duration; + const nextStartTime = this.startTime ?? targetElement.startTime; + + const oldEndTime = targetElement.startTime + targetElement.duration; + const newEndTime = nextStartTime + nextDuration; + const shiftAmount = oldEndTime - newEndTime; + + const updatedElement = { + ...targetElement, + trimStart: this.trimStart, + trimEnd: this.trimEnd, + startTime: nextStartTime, + duration: nextDuration, + animations: clampAnimationsToDuration({ + animations: targetElement.animations, + duration: nextDuration, + }), + }; + + if (this.rippleEnabled && Math.abs(shiftAmount) > 0) { + const shiftedOthers = rippleShiftElements({ + elements: track.elements.filter((element) => element.id !== this.elementId), + afterTime: oldEndTime, + shiftAmount, + }); return { - ...element, - trimStart: this.trimStart, - trimEnd: this.trimEnd, - startTime: this.startTime ?? element.startTime, - duration: this.duration ?? element.duration, - }; - }); - return { ...track, elements: newElements } as typeof track; + ...track, + elements: track.elements.map((element) => + element.id === this.elementId + ? updatedElement + : (shiftedOthers.find((shifted) => shifted.id === element.id) ?? element) + ), + } as typeof track; + } + + return { + ...track, + elements: track.elements.map((element) => + element.id === this.elementId ? updatedElement : element + ), + } as typeof track; }); editor.timeline.updateTracks(updatedTracks); diff --git a/apps/web/src/lib/commands/timeline/element/update-element.ts b/apps/web/src/lib/commands/timeline/element/update-element.ts index 4ee54726..643f8e4b 100644 --- a/apps/web/src/lib/commands/timeline/element/update-element.ts +++ b/apps/web/src/lib/commands/timeline/element/update-element.ts @@ -1,28 +1,38 @@ import { Command } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/types/timeline"; +import type { TimelineElement, TimelineTrack } from "@/types/timeline"; import { EditorCore } from "@/core"; +import { updateElementInTracks } from "@/lib/timeline"; export class UpdateElementCommand extends Command { private savedState: TimelineTrack[] | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly updates: Partial<TimelineElement>; - constructor( - private trackId: string, - private elementId: string, - private updates: Partial<Record<string, unknown>>, - ) { + constructor({ + trackId, + elementId, + updates, + }: { + trackId: string; + elementId: string; + updates: Partial<TimelineElement>; + }) { super(); + this.trackId = trackId; + this.elementId = elementId; + this.updates = updates; } execute(): void { const editor = EditorCore.getInstance(); this.savedState = editor.timeline.getTracks(); - const updatedTracks = this.savedState.map((t) => { - if (t.id !== this.trackId) return t; - const newElements = t.elements.map((el) => - el.id === this.elementId ? { ...el, ...this.updates } : el, - ); - return { ...t, elements: newElements } as typeof t; + const updatedTracks = updateElementInTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + update: (element) => ({ ...element, ...this.updates }) as TimelineElement, }); editor.timeline.updateTracks(updatedTracks); diff --git a/apps/web/src/lib/effects/definitions/blur.frag.glsl b/apps/web/src/lib/effects/definitions/blur.frag.glsl new file mode 100644 index 00000000..2c015725 --- /dev/null +++ b/apps/web/src/lib/effects/definitions/blur.frag.glsl @@ -0,0 +1,25 @@ +precision mediump float; + +uniform sampler2D u_texture; +uniform vec2 u_resolution; +uniform float u_sigma; +uniform vec2 u_direction; + +varying vec2 v_texCoord; + +void main() { + vec2 texelSize = 1.0 / u_resolution; + + vec4 color = vec4(0.0); + float totalWeight = 0.0; + + // step=1 texel — scaling step size instead causes discrete ghosting artifacts + for (int i = -30; i <= 30; i++) { + float fi = float(i); + float weight = exp(-(fi * fi) / (2.0 * u_sigma * u_sigma)); + color += texture2D(u_texture, v_texCoord + texelSize * u_direction * fi) * weight; + totalWeight += weight; + } + + gl_FragColor = color / totalWeight; +} diff --git a/apps/web/src/lib/effects/definitions/blur.ts b/apps/web/src/lib/effects/definitions/blur.ts new file mode 100644 index 00000000..3a8f691a --- /dev/null +++ b/apps/web/src/lib/effects/definitions/blur.ts @@ -0,0 +1,50 @@ +import type { EffectDefinition } from "@/types/effects"; +import blurFragmentShader from "./blur.frag.glsl"; + +export const blurEffectDefinition: EffectDefinition = { + type: "blur", + name: "Blur", + keywords: ["blur", "soft", "defocus"], + params: [ + { + key: "intensity", + label: "Intensity", + type: "number", + default: 15, + min: 0, + max: 100, + step: 1, + }, + ], + renderer: { + type: "webgl", + passes: [ + { + fragmentShader: blurFragmentShader, + uniforms: ({ effectParams }) => { + const intensity = + typeof effectParams.intensity === "number" + ? effectParams.intensity + : Number.parseFloat(String(effectParams.intensity)); + return { + u_sigma: Math.max(intensity / 5, 0.001), + u_direction: [1, 0], + }; + }, + }, + { + fragmentShader: blurFragmentShader, + uniforms: ({ effectParams }) => { + const intensity = + typeof effectParams.intensity === "number" + ? effectParams.intensity + : Number.parseFloat(String(effectParams.intensity)); + return { + u_sigma: Math.max(intensity / 5, 0.001), + u_direction: [0, 1], + }; + }, + }, + ], + }, +}; diff --git a/apps/web/src/lib/effects/definitions/index.ts b/apps/web/src/lib/effects/definitions/index.ts new file mode 100644 index 00000000..1742b1d7 --- /dev/null +++ b/apps/web/src/lib/effects/definitions/index.ts @@ -0,0 +1,13 @@ +import { hasEffect, registerEffect } from "../registry"; +import { blurEffectDefinition } from "./blur"; + +const defaultEffects = [blurEffectDefinition]; + +export function registerDefaultEffects(): void { + for (const definition of defaultEffects) { + if (hasEffect({ effectType: definition.type })) { + continue; + } + registerEffect({ definition }); + } +} diff --git a/apps/web/src/lib/effects/effect.vert.glsl b/apps/web/src/lib/effects/effect.vert.glsl new file mode 100644 index 00000000..5febc835 --- /dev/null +++ b/apps/web/src/lib/effects/effect.vert.glsl @@ -0,0 +1,7 @@ +attribute vec2 a_position; +varying vec2 v_texCoord; + +void main() { + v_texCoord = a_position * 0.5 + 0.5; + gl_Position = vec4(a_position, 0.0, 1.0); +} diff --git a/apps/web/src/lib/effects/index.ts b/apps/web/src/lib/effects/index.ts new file mode 100644 index 00000000..14d06d84 --- /dev/null +++ b/apps/web/src/lib/effects/index.ts @@ -0,0 +1,34 @@ +import { generateUUID } from "@/utils/id"; +import { getEffect } from "./registry"; +import type { Effect, EffectParamValues } from "@/types/effects"; +import type { VisualElement } from "@/types/timeline"; + +export { getEffect, getAllEffects, hasEffect, registerEffect } from "./registry"; +export { registerDefaultEffects } from "./definitions"; + +export const EFFECT_TARGET_ELEMENT_TYPES: VisualElement["type"][] = [ + "video", + "image", + "text", + "sticker", +]; + +export function buildDefaultEffectInstance({ + effectType, +}: { + effectType: string; +}): Effect { + const definition = getEffect({ effectType }); + + const params: EffectParamValues = {}; + for (const paramDef of definition.params) { + params[paramDef.key] = paramDef.default; + } + + return { + id: generateUUID(), + type: effectType, + params, + enabled: true, + }; +} diff --git a/apps/web/src/lib/effects/registry.ts b/apps/web/src/lib/effects/registry.ts new file mode 100644 index 00000000..568b8924 --- /dev/null +++ b/apps/web/src/lib/effects/registry.ts @@ -0,0 +1,31 @@ +import type { EffectDefinition } from "@/types/effects"; + +const effectDefinitions = new Map<string, EffectDefinition>(); + +export function registerEffect({ + definition, +}: { + definition: EffectDefinition; +}): void { + effectDefinitions.set(definition.type, definition); +} + +export function hasEffect({ effectType }: { effectType: string }): boolean { + return effectDefinitions.has(effectType); +} + +export function getEffect({ + effectType, +}: { + effectType: string; +}): EffectDefinition { + const definition = effectDefinitions.get(effectType); + if (!definition) { + throw new Error(`Unknown effect type: ${effectType}`); + } + return definition; +} + +export function getAllEffects(): EffectDefinition[] { + return Array.from(effectDefinitions.values()); +} diff --git a/apps/web/src/lib/export.ts b/apps/web/src/lib/export.ts index 67621e16..ae68144b 100644 --- a/apps/web/src/lib/export.ts +++ b/apps/web/src/lib/export.ts @@ -16,3 +16,23 @@ export function getExportFileExtension({ }): string { return `.${format}`; } + +export function downloadBuffer({ + buffer, + filename, + mimeType, +}: { + buffer: ArrayBuffer; + filename: string; + mimeType: string; +}): void { + const blob = new Blob([buffer], { type: mimeType }); + const url = URL.createObjectURL(blob); + const downloadLink = document.createElement("a"); + downloadLink.href = url; + downloadLink.download = filename; + document.body.appendChild(downloadLink); + downloadLink.click(); + document.body.removeChild(downloadLink); + URL.revokeObjectURL(url); +} diff --git a/apps/web/src/lib/preview/element-bounds.ts b/apps/web/src/lib/preview/element-bounds.ts index 9e58ec52..08ffbfa8 100644 --- a/apps/web/src/lib/preview/element-bounds.ts +++ b/apps/web/src/lib/preview/element-bounds.ts @@ -4,9 +4,15 @@ import { isMainTrack } from "@/lib/timeline"; import { DEFAULT_TEXT_ELEMENT, DEFAULT_LINE_HEIGHT, + DEFAULT_TEXT_BACKGROUND, FONT_SIZE_SCALE_REFERENCE, } from "@/constants/text-constants"; import { getTextVisualRect, measureTextBlock } from "@/lib/text/layout"; +import { + getElementLocalTime, + resolveTransformAtTime, + resolveNumberAtTime, +} from "@/lib/animation"; export interface ElementBounds { cx: number; @@ -62,17 +68,24 @@ export function getElementBounds({ element, canvasSize, mediaAsset, + localTime, }: { element: TimelineElement; canvasSize: { width: number; height: number }; mediaAsset?: MediaAsset | null; + localTime: number; }): ElementBounds | null { - if (element.type === "audio") return null; + if (element.type === "audio" || element.type === "effect") return null; if ("hidden" in element && element.hidden) return null; const { width: canvasWidth, height: canvasHeight } = canvasSize; if (element.type === "video" || element.type === "image") { + const transform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); const sourceWidth = mediaAsset?.width ?? canvasWidth; const sourceHeight = mediaAsset?.height ?? canvasHeight; return getVisualElementBounds({ @@ -80,21 +93,31 @@ export function getElementBounds({ canvasHeight, sourceWidth, sourceHeight, - transform: element.transform, + transform, }); } if (element.type === "sticker") { + const transform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); return getVisualElementBounds({ canvasWidth, canvasHeight, sourceWidth: 200, sourceHeight: 200, - transform: element.transform, + transform, }); } if (element.type === "text") { + const transform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); const scaledFontSize = element.fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE); const letterSpacing = element.letterSpacing ?? 0; @@ -129,40 +152,71 @@ export function getElementBounds({ fallbackFontSize: scaledFontSize, }); const fontSizeRatio = element.fontSize / DEFAULT_TEXT_ELEMENT.fontSize; + const resolvedBackground = { + ...element.background, + paddingX: resolveNumberAtTime({ + baseValue: + element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX, + animations: element.animations, + propertyPath: "background.paddingX", + localTime, + }), + paddingY: resolveNumberAtTime({ + baseValue: + element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY, + animations: element.animations, + propertyPath: "background.paddingY", + localTime, + }), + offsetX: resolveNumberAtTime({ + baseValue: + element.background.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX, + animations: element.animations, + propertyPath: "background.offsetX", + localTime, + }), + offsetY: resolveNumberAtTime({ + baseValue: + element.background.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY, + animations: element.animations, + propertyPath: "background.offsetY", + localTime, + }), + }; const visualRect = getTextVisualRect({ textAlign: element.textAlign, block, - background: element.background, + background: resolvedBackground, fontSizeRatio, }); measuredWidth = visualRect.width; measuredHeight = visualRect.height; const localCenterX = visualRect.left + visualRect.width / 2; const localCenterY = visualRect.top + visualRect.height / 2; - const scaledCenterX = localCenterX * element.transform.scale; - const scaledCenterY = localCenterY * element.transform.scale; - const rotationRad = (element.transform.rotate * Math.PI) / 180; + const scaledCenterX = localCenterX * transform.scale; + const scaledCenterY = localCenterY * transform.scale; + const rotationRad = (transform.rotate * Math.PI) / 180; const cos = Math.cos(rotationRad); const sin = Math.sin(rotationRad); const rotatedCenterX = scaledCenterX * cos - scaledCenterY * sin; const rotatedCenterY = scaledCenterX * sin + scaledCenterY * cos; return { - cx: canvasWidth / 2 + element.transform.position.x + rotatedCenterX, - cy: canvasHeight / 2 + element.transform.position.y + rotatedCenterY, - width: measuredWidth * element.transform.scale, - height: measuredHeight * element.transform.scale, - rotation: element.transform.rotate, + cx: canvasWidth / 2 + transform.position.x + rotatedCenterX, + cy: canvasHeight / 2 + transform.position.y + rotatedCenterY, + width: measuredWidth * transform.scale, + height: measuredHeight * transform.scale, + rotation: transform.rotate, }; } - const width = measuredWidth * element.transform.scale; - const height = measuredHeight * element.transform.scale; + const width = measuredWidth * transform.scale; + const height = measuredHeight * transform.scale; return { - cx: canvasWidth / 2 + element.transform.position.x, - cy: canvasHeight / 2 + element.transform.position.y, + cx: canvasWidth / 2 + transform.position.x, + cy: canvasHeight / 2 + transform.position.y, width, height, - rotation: element.transform.rotate, + rotation: transform.rotate, }; } @@ -206,6 +260,11 @@ export function getVisibleElementsWithBounds({ }); for (const element of elements) { + const localTime = getElementLocalTime({ + timelineTime: currentTime, + elementStartTime: element.startTime, + elementDuration: element.duration, + }); const mediaAsset = element.type === "video" || element.type === "image" ? mediaMap.get(element.mediaId) @@ -214,6 +273,7 @@ export function getVisibleElementsWithBounds({ element, canvasSize, mediaAsset, + localTime, }); if (bounds) { result.push({ diff --git a/apps/web/src/lib/text/layout.ts b/apps/web/src/lib/text/layout.ts index 8c38e9aa..3eadcec2 100644 --- a/apps/web/src/lib/text/layout.ts +++ b/apps/web/src/lib/text/layout.ts @@ -1,5 +1,5 @@ import { DEFAULT_TEXT_BACKGROUND } from "@/constants/text-constants"; -import type { TextElement } from "@/types/timeline"; +import type { TextBackground, TextElement } from "@/types/timeline"; type TextRect = { left: number; @@ -74,8 +74,12 @@ function getTextRect({ textAlign: TextElement["textAlign"]; block: TextBlockMeasurement; }): TextRect { - const left = - textAlign === "left" ? 0 : textAlign === "right" ? -block.maxWidth : -block.maxWidth / 2; + const textAlignToLeft: Record<typeof textAlign, number> = { + left: 0, + right: -block.maxWidth, + center: -block.maxWidth / 2, + }; + const left = textAlignToLeft[textAlign]; return { left, @@ -88,9 +92,13 @@ function getTextRect({ function isTextBackgroundVisible({ background, }: { - background: TextElement["background"]; + background: TextBackground; }): boolean { - return Boolean(background.color) && background.color !== "transparent"; + return ( + background.enabled && + Boolean(background.color) && + background.color !== "transparent" + ); } export function getTextBackgroundRect({ @@ -101,7 +109,7 @@ export function getTextBackgroundRect({ }: { textAlign: TextElement["textAlign"]; block: TextBlockMeasurement; - background: TextElement["background"]; + background: TextBackground; fontSizeRatio?: number; }): TextRect | null { if (!isTextBackgroundVisible({ background })) { @@ -132,7 +140,7 @@ export function getTextVisualRect({ }: { textAlign: TextElement["textAlign"]; block: TextBlockMeasurement; - background: TextElement["background"]; + background: TextBackground; fontSizeRatio?: number; }): TextRect { const textRect = getTextRect({ textAlign, block }); diff --git a/apps/web/src/lib/timeline/drop-utils.ts b/apps/web/src/lib/timeline/drop-utils.ts index 9182892f..b502974d 100644 --- a/apps/web/src/lib/timeline/drop-utils.ts +++ b/apps/web/src/lib/timeline/drop-utils.ts @@ -1,9 +1,42 @@ -import type { TimelineTrack, ElementType } from "@/types/timeline"; -import { TRACK_HEIGHTS, TRACK_GAP } from "@/constants/timeline-constants"; +import type { + TimelineTrack, + ElementType, + TimelineElement, +} from "@/types/timeline"; +import { TRACK_CONFIG, TRACK_GAP } from "@/constants/timeline-constants"; import { wouldElementOverlap } from "./element-utils"; import type { ComputeDropTargetParams, DropTarget } from "@/types/timeline"; import { isMainTrack, enforceMainTrackStart } from "./track-utils"; +function findElementAtPosition({ + mouseX, + tracks, + trackIndex, + targetElementTypes, + pixelsPerSecond, + zoomLevel, +}: { + mouseX: number; + tracks: TimelineTrack[]; + trackIndex: number; + targetElementTypes: string[]; + pixelsPerSecond: number; + zoomLevel: number; +}): { elementId: string; trackId: string } | null { + const time = mouseX / (pixelsPerSecond * zoomLevel); + const track = tracks[trackIndex]; + if (!track || !("elements" in track)) return null; + + const hit = track.elements.find( + (element: TimelineElement) => + targetElementTypes.includes(element.type) && + element.startTime <= time && + time < element.startTime + element.duration, + ); + if (!hit) return null; + return { elementId: hit.id, trackId: track.id }; +} + function getTrackAtY({ mouseY, tracks, @@ -16,7 +49,7 @@ function getTrackAtY({ let cumulativeHeight = 0; for (let i = 0; i < tracks.length; i++) { - const trackHeight = TRACK_HEIGHTS[tracks[i].type]; + const trackHeight = TRACK_CONFIG[tracks[i].type].height; const trackTop = cumulativeHeight; const trackBottom = trackTop + trackHeight; @@ -55,6 +88,7 @@ function isCompatible({ if (elementType === "text") return trackType === "text"; if (elementType === "audio") return trackType === "audio"; if (elementType === "sticker") return trackType === "sticker"; + if (elementType === "effect") return trackType === "effect"; if (elementType === "video" || elementType === "image") { return trackType === "video"; } @@ -100,6 +134,8 @@ function findInsertIndex({ }; } +const EMPTY_TARGET_ELEMENT = null; + export function computeDropTarget({ elementType, mouseX, @@ -113,6 +149,7 @@ export function computeDropTarget({ verticalDragDirection, startTimeOverride, excludeElementId, + targetElementTypes, }: ComputeDropTargetParams): DropTarget { const xPosition = typeof startTimeOverride === "number" @@ -130,9 +167,16 @@ export function computeDropTarget({ isNewTrack: true, insertPosition: "below", xPosition, + targetElement: EMPTY_TARGET_ELEMENT, }; } - return { trackIndex: 0, isNewTrack: true, insertPosition: null, xPosition }; + return { + trackIndex: 0, + isNewTrack: true, + insertPosition: null, + xPosition, + targetElement: EMPTY_TARGET_ELEMENT, + }; } const trackAtMouse = getTrackAtY({ mouseY, tracks, verticalDragDirection }); @@ -146,6 +190,7 @@ export function computeDropTarget({ isNewTrack: true, insertPosition: "below", xPosition, + targetElement: EMPTY_TARGET_ELEMENT, }; } @@ -155,6 +200,7 @@ export function computeDropTarget({ isNewTrack: true, insertPosition: "above", xPosition, + targetElement: EMPTY_TARGET_ELEMENT, }; } @@ -163,12 +209,37 @@ export function computeDropTarget({ isNewTrack: true, insertPosition: "above", xPosition, + targetElement: EMPTY_TARGET_ELEMENT, }; } const { trackIndex, relativeY } = trackAtMouse; const track = tracks[trackIndex]; - const trackHeight = TRACK_HEIGHTS[track.type]; + + if ( + targetElementTypes && + targetElementTypes.length > 0 + ) { + const targetElement = findElementAtPosition({ + mouseX, + tracks, + trackIndex, + targetElementTypes, + pixelsPerSecond, + zoomLevel, + }); + if (targetElement) { + return { + trackIndex, + isNewTrack: false, + insertPosition: null, + xPosition, + targetElement, + }; + } + } + + const trackHeight = TRACK_CONFIG[track.type].height; const isInUpperHalf = relativeY < trackHeight / 2; const isTrackCompatible = isCompatible({ @@ -200,6 +271,7 @@ export function computeDropTarget({ isNewTrack: false, insertPosition: null, xPosition: adjustedXPosition, + targetElement: EMPTY_TARGET_ELEMENT, }; } @@ -220,6 +292,7 @@ export function computeDropTarget({ isNewTrack: true, insertPosition: position, xPosition, + targetElement: EMPTY_TARGET_ELEMENT, }; } @@ -237,7 +310,7 @@ export function getDropLineY({ let y = 0; for (let i = 0; i < safeTrackIndex; i++) { - y += TRACK_HEIGHTS[tracks[i].type] + TRACK_GAP; + y += TRACK_CONFIG[tracks[i].type].height + TRACK_GAP; } return y; diff --git a/apps/web/src/lib/timeline/element-utils.ts b/apps/web/src/lib/timeline/element-utils.ts index c51667bf..11564376 100644 --- a/apps/web/src/lib/timeline/element-utils.ts +++ b/apps/web/src/lib/timeline/element-utils.ts @@ -6,22 +6,26 @@ import { TIMELINE_CONSTANTS, } from "@/constants/timeline-constants"; import type { + CreateEffectElement, CreateTimelineElement, CreateVideoElement, CreateImageElement, CreateStickerElement, CreateUploadAudioElement, CreateLibraryAudioElement, + TextBackground, TextElement, TimelineElement, TimelineTrack, AudioElement, VideoElement, ImageElement, - StickerElement, + VisualElement, UploadAudioElement, } from "@/types/timeline"; import type { MediaType } from "@/types/assets"; +import { buildDefaultEffectInstance } from "@/lib/effects"; +import { capitalizeFirstLetter } from "@/utils/string"; export function canElementHaveAudio( element: TimelineElement, @@ -31,7 +35,7 @@ export function canElementHaveAudio( export function isVisualElement( element: TimelineElement, -): element is VideoElement | ImageElement | TextElement | StickerElement { +): element is VisualElement { return ( element.type === "video" || element.type === "image" || @@ -42,8 +46,8 @@ export function isVisualElement( export function canElementBeHidden( element: TimelineElement, -): element is VideoElement | ImageElement | TextElement | StickerElement { - return element.type !== "audio"; +): element is VisualElement { + return isVisualElement(element); } export function hasMediaId( @@ -124,13 +128,30 @@ export function wouldElementOverlap({ endTime: number; excludeElementId?: string; }): boolean { - return elements.some((el) => { - if (excludeElementId && el.id === excludeElementId) return false; - const elEnd = el.startTime + el.duration; - return startTime < elEnd && endTime > el.startTime; + return elements.some((element) => { + if (excludeElementId && element.id === excludeElementId) return false; + const elementEnd = element.startTime + element.duration; + return startTime < elementEnd && endTime > element.startTime; }); } +function buildTextBackground( + raw: Partial<TextBackground> | undefined, +): TextBackground { + const color = raw?.color ?? DEFAULT_TEXT_ELEMENT.background.color; + const enabled = + typeof raw?.enabled === "boolean" ? raw.enabled : color !== "transparent"; + return { + enabled, + color, + cornerRadius: raw?.cornerRadius, + paddingX: raw?.paddingX, + paddingY: raw?.paddingY, + offsetX: raw?.offsetX, + offsetY: raw?.offsetY, + }; +} + export function buildTextElement({ raw, startTime, @@ -154,14 +175,7 @@ export function buildTextElement({ : DEFAULT_TEXT_ELEMENT.fontSize, fontFamily: t.fontFamily ?? DEFAULT_TEXT_ELEMENT.fontFamily, color: t.color ?? DEFAULT_TEXT_ELEMENT.color, - background: { - color: t.background?.color ?? DEFAULT_TEXT_ELEMENT.background.color, - cornerRadius: t.background?.cornerRadius, - paddingX: t.background?.paddingX, - paddingY: t.background?.paddingY, - offsetX: t.background?.offsetX, - offsetY: t.background?.offsetY, - }, + background: buildTextBackground(t.background), textAlign: t.textAlign ?? DEFAULT_TEXT_ELEMENT.textAlign, fontWeight: t.fontWeight ?? DEFAULT_TEXT_ELEMENT.fontWeight, fontStyle: t.fontStyle ?? DEFAULT_TEXT_ELEMENT.fontStyle, @@ -174,6 +188,28 @@ export function buildTextElement({ }; } +export function buildEffectElement({ + effectType, + startTime, + duration, +}: { + effectType: string; + startTime: number; + duration?: number; +}): CreateEffectElement { + const instance = buildDefaultEffectInstance({ effectType }); + return { + type: "effect", + name: capitalizeFirstLetter({ string: instance.type }), + effectType, + params: instance.params, + duration: duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION, + startTime, + trimStart: 0, + trimEnd: 0, + }; +} + export function buildStickerElement({ stickerId, name, @@ -218,6 +254,7 @@ export function buildVideoElement({ startTime, trimStart: 0, trimEnd: 0, + sourceDuration: duration, muted: false, hidden: false, transform: { ...DEFAULT_TRANSFORM }, @@ -274,6 +311,7 @@ export function buildUploadAudioElement({ startTime, trimStart: 0, trimEnd: 0, + sourceDuration: duration, volume: 1, muted: false, }; @@ -336,6 +374,7 @@ export function buildLibraryAudioElement({ startTime, trimStart: 0, trimEnd: 0, + sourceDuration: duration, volume: 1, muted: false, }; diff --git a/apps/web/src/lib/timeline/index.ts b/apps/web/src/lib/timeline/index.ts index f3ace94f..887ff7ab 100644 --- a/apps/web/src/lib/timeline/index.ts +++ b/apps/web/src/lib/timeline/index.ts @@ -1,9 +1,12 @@ import type { TimelineTrack } from "@/types/timeline"; export * from "./track-utils"; +export * from "./track-element-update"; export * from "./element-utils"; export * from "./zoom-utils"; export * from "./ruler-utils"; +export * from "./ripple-utils"; +export * from "./pixel-utils"; export function calculateTotalDuration({ tracks, diff --git a/apps/web/src/lib/timeline/pixel-utils.ts b/apps/web/src/lib/timeline/pixel-utils.ts new file mode 100644 index 00000000..4c0c243c --- /dev/null +++ b/apps/web/src/lib/timeline/pixel-utils.ts @@ -0,0 +1,79 @@ +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; + +export const TIMELINE_INDICATOR_LINE_WIDTH_PX = 2; + +function getDevicePixelRatio({ + devicePixelRatio, +}: { + devicePixelRatio?: number; +}): number { + if ( + typeof devicePixelRatio === "number" && + Number.isFinite(devicePixelRatio) && + devicePixelRatio > 0 + ) { + return devicePixelRatio; + } + + if (typeof window === "undefined") { + return 1; + } + + if (Number.isFinite(window.devicePixelRatio) && window.devicePixelRatio > 0) { + return window.devicePixelRatio; + } + + return 1; +} + +export function getTimelinePixelsPerSecond({ + zoomLevel, +}: { + zoomLevel: number; +}): number { + return TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; +} + +export function timelineTimeToPixels({ + time, + zoomLevel, +}: { + time: number; + zoomLevel: number; +}): number { + return time * getTimelinePixelsPerSecond({ zoomLevel }); +} + +export function snapPixelToDeviceGrid({ + pixel, + devicePixelRatio, +}: { + pixel: number; + devicePixelRatio?: number; +}): number { + const safeDevicePixelRatio = getDevicePixelRatio({ devicePixelRatio }); + return Math.round(pixel * safeDevicePixelRatio) / safeDevicePixelRatio; +} + +export function timelineTimeToSnappedPixels({ + time, + zoomLevel, + devicePixelRatio, +}: { + time: number; + zoomLevel: number; + devicePixelRatio?: number; +}): number { + const rawPixel = timelineTimeToPixels({ time, zoomLevel }); + return snapPixelToDeviceGrid({ pixel: rawPixel, devicePixelRatio }); +} + +export function getCenteredLineLeft({ + centerPixel, + lineWidthPx = TIMELINE_INDICATOR_LINE_WIDTH_PX, +}: { + centerPixel: number; + lineWidthPx?: number; +}): number { + return centerPixel - lineWidthPx / 2; +} diff --git a/apps/web/src/lib/timeline/ripple-utils.ts b/apps/web/src/lib/timeline/ripple-utils.ts new file mode 100644 index 00000000..5f37bf29 --- /dev/null +++ b/apps/web/src/lib/timeline/ripple-utils.ts @@ -0,0 +1,17 @@ +import type { TimelineElement } from "@/types/timeline"; + +export function rippleShiftElements({ + elements, + afterTime, + shiftAmount, +}: { + elements: TimelineElement[]; + afterTime: number; + shiftAmount: number; +}): TimelineElement[] { + return elements.map((element) => + element.startTime >= afterTime + ? { ...element, startTime: element.startTime - shiftAmount } + : element, + ); +} diff --git a/apps/web/src/lib/timeline/snap-utils.ts b/apps/web/src/lib/timeline/snap-utils.ts index a29737d0..c7fac090 100644 --- a/apps/web/src/lib/timeline/snap-utils.ts +++ b/apps/web/src/lib/timeline/snap-utils.ts @@ -1,10 +1,11 @@ import type { Bookmark, TimelineTrack } from "@/types/timeline"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks"; +import { getElementKeyframes } from "@/lib/animation"; export interface SnapPoint { time: number; - type: "element-start" | "element-end" | "playhead" | "bookmark"; + type: "element-start" | "element-end" | "playhead" | "bookmark" | "keyframe"; elementId?: string; trackId?: string; } @@ -26,6 +27,7 @@ export function findSnapPoints({ enableElementSnapping = true, enablePlayheadSnapping = true, enableBookmarkSnapping = true, + enableKeyframeSnapping = true, }: { tracks: Array<TimelineTrack>; playheadTime: number; @@ -35,13 +37,15 @@ export function findSnapPoints({ enableElementSnapping?: boolean; enablePlayheadSnapping?: boolean; enableBookmarkSnapping?: boolean; + enableKeyframeSnapping?: boolean; }): SnapPoint[] { const snapPoints: SnapPoint[] = []; - if (enableElementSnapping) { - for (const track of tracks) { - for (const element of track.elements) { - if (element.id === excludeElementId) continue; + for (const track of tracks) { + for (const element of track.elements) { + if (element.id === excludeElementId) continue; + + if (enableElementSnapping) { snapPoints.push( { time: element.startTime, @@ -57,6 +61,19 @@ export function findSnapPoints({ }, ); } + + if (enableKeyframeSnapping) { + for (const keyframe of getElementKeyframes({ + animations: element.animations, + })) { + snapPoints.push({ + time: element.startTime + keyframe.time, + type: "keyframe", + elementId: element.id, + trackId: track.id, + }); + } + } } } diff --git a/apps/web/src/lib/timeline/track-element-update.ts b/apps/web/src/lib/timeline/track-element-update.ts new file mode 100644 index 00000000..6118b327 --- /dev/null +++ b/apps/web/src/lib/timeline/track-element-update.ts @@ -0,0 +1,33 @@ +import type { TimelineElement, TimelineTrack } from "@/types/timeline"; + +export function updateElementInTracks({ + tracks, + trackId, + elementId, + update, + elementPredicate, +}: { + tracks: TimelineTrack[]; + trackId: string; + elementId: string; + update: (element: TimelineElement) => TimelineElement; + elementPredicate?: (element: TimelineElement) => boolean; +}): TimelineTrack[] { + return tracks.map((track) => { + if (track.id !== trackId) { + return track; + } + + const nextElements = track.elements.map((element) => { + if (element.id !== elementId) { + return element; + } + if (elementPredicate && !elementPredicate(element)) { + return element; + } + return update(element); + }); + + return { ...track, elements: nextElements } as TimelineTrack; + }); +} diff --git a/apps/web/src/lib/timeline/track-utils.ts b/apps/web/src/lib/timeline/track-utils.ts index 415d0ace..94ff21ba 100644 --- a/apps/web/src/lib/timeline/track-utils.ts +++ b/apps/web/src/lib/timeline/track-utils.ts @@ -6,11 +6,11 @@ import type { AudioTrack, StickerTrack, TextTrack, + EffectTrack, TimelineElement, } from "@/types/timeline"; import { - TRACK_COLORS, - TRACK_HEIGHTS, + TRACK_CONFIG, TRACK_GAP, } from "@/constants/timeline-constants"; import { generateUUID } from "@/utils/id"; @@ -23,21 +23,20 @@ export function canTracktHaveAudio( export function canTrackBeHidden( track: TimelineTrack, -): track is VideoTrack | TextTrack | StickerTrack { +): track is VideoTrack | TextTrack | StickerTrack | EffectTrack { return track.type !== "audio"; } export function getTrackColor({ type }: { type: TrackType }) { - return TRACK_COLORS[type]; + return TRACK_CONFIG[type]; } export function getTrackClasses({ type }: { type: TrackType }) { - const colors = TRACK_COLORS[type]; - return `${colors.background}`.trim(); + return TRACK_CONFIG[type].background.trim(); } export function getTrackHeight({ type }: { type: TrackType }): number { - return TRACK_HEIGHTS[type]; + return TRACK_CONFIG[type].height; } export function getCumulativeHeightBefore({ @@ -77,17 +76,7 @@ export function buildEmptyTrack({ type: TrackType; name?: string; }): TimelineTrack { - const trackName = - name ?? - (type === "video" - ? "Video track" - : type === "text" - ? "Text track" - : type === "audio" - ? "Audio track" - : type === "sticker" - ? "Sticker track" - : "Track"); + const trackName = name ?? TRACK_CONFIG[type].defaultName; switch (type) { case "video": @@ -124,6 +113,14 @@ export function buildEmptyTrack({ elements: [], muted: false, }; + case "effect": + return { + id, + name: trackName, + type: "effect", + elements: [], + hidden: false, + }; default: throw new Error(`Unsupported track type: ${type}`); } @@ -140,6 +137,10 @@ export function getDefaultInsertIndexForTrack({ return tracks.length; } + if (trackType === "effect") { + return 0; + } + const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track)); if (mainTrackIndex >= 0) { return mainTrackIndex; @@ -216,6 +217,7 @@ export function canElementGoOnTrack({ if (elementType === "text") return trackType === "text"; if (elementType === "audio") return trackType === "audio"; if (elementType === "sticker") return trackType === "sticker"; + if (elementType === "effect") return trackType === "effect"; if (elementType === "video" || elementType === "image") { return trackType === "video"; } diff --git a/apps/web/src/services/renderer/canvas-utils.ts b/apps/web/src/services/renderer/canvas-utils.ts new file mode 100644 index 00000000..093168e6 --- /dev/null +++ b/apps/web/src/services/renderer/canvas-utils.ts @@ -0,0 +1,16 @@ +export function createOffscreenCanvas({ + width, + height, +}: { + width: number; + height: number; +}): OffscreenCanvas | HTMLCanvasElement { + try { + return new OffscreenCanvas(width, height); + } catch { + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + return canvas; + } +} diff --git a/apps/web/src/services/renderer/effect-preview.ts b/apps/web/src/services/renderer/effect-preview.ts new file mode 100644 index 00000000..2cebec5d --- /dev/null +++ b/apps/web/src/services/renderer/effect-preview.ts @@ -0,0 +1,194 @@ +import { createOffscreenCanvas } from "./canvas-utils"; +import { getEffect } from "@/lib/effects"; +import type { EffectParamValues } from "@/types/effects"; +import { applyMultiPassEffect } from "./webgl-utils"; +import type { EffectPassData } from "./webgl-utils"; + +const PREVIEW_SIZE = 160; +const PREVIEW_IMAGE_PATH = "/effects/preview.jpg"; + +let previewGl: WebGLRenderingContext | null = null; +let previewCanvas: OffscreenCanvas | HTMLCanvasElement | null = null; +let testSourceCanvas: OffscreenCanvas | HTMLCanvasElement | null = null; +let previewImageElement: HTMLImageElement | null = null; +const programCache = new Map<string, WebGLProgram>(); +const onReadyCallbacks = new Set<() => void>(); + +export function onPreviewImageReady({ + callback, +}: { + callback: () => void; +}): () => void { + onReadyCallbacks.add(callback); + return () => onReadyCallbacks.delete(callback); +} + +function loadPreviewImage(): void { + if (typeof window === "undefined") return; + const image = new Image(); + image.onload = () => { + testSourceCanvas = null; + for (const callback of onReadyCallbacks) { + callback(); + } + }; + image.src = PREVIEW_IMAGE_PATH; + previewImageElement = image; +} + +loadPreviewImage(); + +function buildDefaultParams({ + effectType, +}: { + effectType: string; +}): EffectParamValues { + const definition = getEffect({ effectType }); + const params: EffectParamValues = {}; + for (const paramDef of definition.params) { + params[paramDef.key] = paramDef.default; + } + return params; +} + +function createTestSource({ + width, + height, +}: { + width: number; + height: number; +}): OffscreenCanvas | HTMLCanvasElement | null { + const isImageReady = + previewImageElement?.complete && + (previewImageElement.naturalWidth ?? 0) > 0; + if (!isImageReady || !previewImageElement) { + return null; + } + + const canvas = createOffscreenCanvas({ width, height }); + const ctx = canvas.getContext("2d") as + | CanvasRenderingContext2D + | OffscreenCanvasRenderingContext2D + | null; + if (!ctx) { + throw new Error("failed to get 2d context for test source"); + } + ctx.drawImage(previewImageElement, 0, 0, width, height); + return canvas; +} + +function getOrCreatePreviewContext({ + width, + height, +}: { + width: number; + height: number; +}): { canvas: OffscreenCanvas | HTMLCanvasElement; gl: WebGLRenderingContext } { + if (!previewCanvas || !previewGl) { + previewCanvas = createOffscreenCanvas({ width, height }); + previewGl = previewCanvas.getContext("webgl", { + premultipliedAlpha: false, + }) as WebGLRenderingContext | null; + if (!previewGl) { + throw new Error("WebGL not supported"); + } + } + if (previewCanvas.width !== width || previewCanvas.height !== height) { + previewCanvas.width = width; + previewCanvas.height = height; + } + return { canvas: previewCanvas, gl: previewGl }; +} + +function getTestSource({ + width, + height, +}: { + width: number; + height: number; +}): CanvasImageSource | null { + if ( + !testSourceCanvas || + testSourceCanvas.width !== width || + testSourceCanvas.height !== height + ) { + testSourceCanvas = createTestSource({ width, height }); + } + return testSourceCanvas; +} + +function applyWebGlEffect({ + source, + width, + height, + passes, +}: { + source: CanvasImageSource; + width: number; + height: number; + passes: EffectPassData[]; +}): OffscreenCanvas | HTMLCanvasElement { + const { canvas: glCanvas, gl } = getOrCreatePreviewContext({ width, height }); + + applyMultiPassEffect({ context: gl, source, width, height, passes, programCache }); + + const outputCanvas = createOffscreenCanvas({ width, height }); + const outputCtx = outputCanvas.getContext("2d") as + | CanvasRenderingContext2D + | OffscreenCanvasRenderingContext2D + | null; + if (outputCtx) { + outputCtx.drawImage(glCanvas, 0, 0, width, height); + } + return outputCanvas; +} + +export function renderPreview({ + effectType, + params, + targetCanvas, +}: { + effectType: string; + params: EffectParamValues; + targetCanvas: HTMLCanvasElement; +}): void { + const size = PREVIEW_SIZE; + const source = getTestSource({ width: size, height: size }); + if (!source) return; + + const definition = getEffect({ effectType }); + const resolvedParams = + Object.keys(params).length > 0 + ? params + : buildDefaultParams({ effectType }); + + const passes = definition.renderer.passes.map((pass) => ({ + fragmentShader: pass.fragmentShader, + uniforms: pass.uniforms({ + effectParams: resolvedParams, + width: size, + height: size, + }), + })); + const result = applyWebGlEffect({ + source, + width: size, + height: size, + passes, + }); + + const targetCtx = targetCanvas.getContext( + "2d", + ) as CanvasRenderingContext2D | null; + if (targetCtx) { + targetCanvas.width = size; + targetCanvas.height = size; + targetCtx.drawImage(result, 0, 0, size, size); + } +} + +export const effectPreviewService = { + renderPreview, + onPreviewImageReady, + PREVIEW_SIZE, +}; diff --git a/apps/web/src/services/renderer/nodes/blur-background-node.ts b/apps/web/src/services/renderer/nodes/blur-background-node.ts deleted file mode 100644 index 504161d9..00000000 --- a/apps/web/src/services/renderer/nodes/blur-background-node.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { CanvasRenderer } from "../canvas-renderer"; -import { BaseNode } from "./base-node"; - -export type BlurBackgroundNodeParams = { - blurIntensity: number; - contentNodes: BaseNode[]; -}; - -export class BlurBackgroundNode extends BaseNode<BlurBackgroundNodeParams> { - private blurIntensity: number; - private contentNodes: BaseNode[]; - - constructor(params: BlurBackgroundNodeParams) { - super(params); - this.blurIntensity = params.blurIntensity; - this.contentNodes = params.contentNodes; - } - - async render({ - renderer, - time, - }: { - renderer: CanvasRenderer; - time: number; - }): Promise<void> { - let offscreen: OffscreenCanvas | HTMLCanvasElement; - let offscreenCtx: - | OffscreenCanvasRenderingContext2D - | CanvasRenderingContext2D; - - try { - offscreen = new OffscreenCanvas(renderer.width, renderer.height); - const ctx = offscreen.getContext("2d"); - if (!ctx) { - throw new Error("failed to get offscreen canvas context"); - } - offscreenCtx = ctx; - } catch { - offscreen = document.createElement("canvas"); - offscreen.width = renderer.width; - offscreen.height = renderer.height; - const ctx = offscreen.getContext("2d"); - if (!ctx) { - throw new Error("failed to get canvas context"); - } - offscreenCtx = ctx; - } - - const originalContext = renderer.context; - renderer.context = offscreenCtx; - - for (const node of this.contentNodes) { - await node.render({ renderer, time }); - } - - renderer.context = originalContext; - - const zoomScale = 1.4; - const scaledWidth = renderer.width * zoomScale; - const scaledHeight = renderer.height * zoomScale; - const offsetX = (renderer.width - scaledWidth) / 2; - const offsetY = (renderer.height - scaledHeight) / 2; - - renderer.context.save(); - renderer.context.filter = `blur(${this.blurIntensity}px)`; - renderer.context.drawImage( - offscreen as CanvasImageSource, - offsetX, - offsetY, - scaledWidth, - scaledHeight, - ); - renderer.context.restore(); - } -} diff --git a/apps/web/src/services/renderer/nodes/composite-effect-node.ts b/apps/web/src/services/renderer/nodes/composite-effect-node.ts new file mode 100644 index 00000000..3fbfffc1 --- /dev/null +++ b/apps/web/src/services/renderer/nodes/composite-effect-node.ts @@ -0,0 +1,77 @@ +import type { CanvasRenderer } from "../canvas-renderer"; +import { createOffscreenCanvas } from "../canvas-utils"; +import { getEffect } from "@/lib/effects"; +import type { EffectParamValues } from "@/types/effects"; +import { BaseNode } from "./base-node"; +import { webglEffectRenderer } from "../webgl-effect-renderer"; + +export type CompositeEffectNodeParams = { + contentNodes: BaseNode[]; + effectType: string; + effectParams: EffectParamValues; + scale: number; +}; + +export class CompositeEffectNode extends BaseNode<CompositeEffectNodeParams> { + async render({ + renderer, + time, + }: { + renderer: CanvasRenderer; + time: number; + }): Promise<void> { + const offscreen = createOffscreenCanvas({ + width: renderer.width, + height: renderer.height, + }); + const offscreenCtx = offscreen.getContext("2d") as OffscreenCanvasRenderingContext2D | null; + if (!offscreenCtx) { + throw new Error("failed to get offscreen canvas context"); + } + + const originalContext = renderer.context; + renderer.context = offscreenCtx; + + for (const node of this.params.contentNodes) { + await node.render({ renderer, time }); + } + + renderer.context = originalContext; + + const effectDefinition = getEffect({ effectType: this.params.effectType }); + const scale = this.params.scale; + const scaledWidth = renderer.width * scale; + const scaledHeight = renderer.height * scale; + const offsetX = (renderer.width - scaledWidth) / 2; + const offsetY = (renderer.height - scaledHeight) / 2; + + const passes = effectDefinition.renderer.passes.map((pass) => ({ + fragmentShader: pass.fragmentShader, + uniforms: pass.uniforms({ + effectParams: this.params.effectParams, + width: renderer.width, + height: renderer.height, + }), + })); + const effectResult = webglEffectRenderer.applyEffect({ + source: offscreen as CanvasImageSource, + width: renderer.width, + height: renderer.height, + passes, + }); + + renderer.context.save(); + renderer.context.drawImage( + effectResult, + 0, + 0, + renderer.width, + renderer.height, + offsetX, + offsetY, + scaledWidth, + scaledHeight, + ); + renderer.context.restore(); + } +} diff --git a/apps/web/src/services/renderer/nodes/effect-layer-node.ts b/apps/web/src/services/renderer/nodes/effect-layer-node.ts new file mode 100644 index 00000000..3d84be2b --- /dev/null +++ b/apps/web/src/services/renderer/nodes/effect-layer-node.ts @@ -0,0 +1,82 @@ +import type { CanvasRenderer } from "../canvas-renderer"; +import { getEffect } from "@/lib/effects"; +import type { EffectParamValues } from "@/types/effects"; +import { BaseNode } from "./base-node"; +import { webglEffectRenderer } from "../webgl-effect-renderer"; + +const TIME_EPSILON = 1e-6; + +export type EffectLayerNodeParams = { + effectType: string; + effectParams: EffectParamValues; + timeOffset: number; + duration: number; +}; + +function isInRange({ + time, + timeOffset, + duration, +}: { + time: number; + timeOffset: number; + duration: number; +}): boolean { + return ( + time >= timeOffset - TIME_EPSILON && + time < timeOffset + duration + TIME_EPSILON + ); +} + +// snapshots whatever is currently on the canvas, applies the effect, draws it back +export class EffectLayerNode extends BaseNode<EffectLayerNodeParams> { + async render({ + renderer, + time, + }: { + renderer: CanvasRenderer; + time: number; + }): Promise<void> { + if ( + !isInRange({ + time, + timeOffset: this.params.timeOffset, + duration: this.params.duration, + }) + ) { + return; + } + + const source = renderer.context.canvas as CanvasImageSource; + + const effectDefinition = getEffect({ + effectType: this.params.effectType, + }); + + const passes = effectDefinition.renderer.passes.map((pass) => ({ + fragmentShader: pass.fragmentShader, + uniforms: pass.uniforms({ + effectParams: this.params.effectParams, + width: renderer.width, + height: renderer.height, + }), + })); + const effectResult = webglEffectRenderer.applyEffect({ + source, + width: renderer.width, + height: renderer.height, + passes, + }); + + renderer.context.save(); + renderer.context.clearRect(0, 0, renderer.width, renderer.height); + renderer.context.drawImage( + effectResult, + 0, + 0, + renderer.width, + renderer.height, + ); + renderer.context.restore(); + } +} diff --git a/apps/web/src/services/renderer/nodes/image-node.ts b/apps/web/src/services/renderer/nodes/image-node.ts index b3a0be74..6720a5e5 100644 --- a/apps/web/src/services/renderer/nodes/image-node.ts +++ b/apps/web/src/services/renderer/nodes/image-node.ts @@ -73,7 +73,7 @@ export class ImageNode extends VisualNode<ImageNodeParams> { async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) { await super.render({ renderer, time }); - if (!this.isInRange(time)) { + if (!this.isInRange({ time })) { return; } @@ -84,6 +84,7 @@ export class ImageNode extends VisualNode<ImageNodeParams> { source, sourceWidth: width || renderer.width, sourceHeight: height || renderer.height, + timelineTime: time, }); } } diff --git a/apps/web/src/services/renderer/nodes/sticker-node.ts b/apps/web/src/services/renderer/nodes/sticker-node.ts index acf638eb..da271685 100644 --- a/apps/web/src/services/renderer/nodes/sticker-node.ts +++ b/apps/web/src/services/renderer/nodes/sticker-node.ts @@ -51,7 +51,7 @@ export class StickerNode extends VisualNode<StickerNodeParams> { async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) { await super.render({ renderer, time }); - if (!this.isInRange(time)) { + if (!this.isInRange({ time })) { return; } @@ -62,6 +62,7 @@ export class StickerNode extends VisualNode<StickerNodeParams> { source, sourceWidth: width, sourceHeight: height, + timelineTime: time, }); } } diff --git a/apps/web/src/services/renderer/nodes/text-node.ts b/apps/web/src/services/renderer/nodes/text-node.ts index 76d78b31..86e24fdf 100644 --- a/apps/web/src/services/renderer/nodes/text-node.ts +++ b/apps/web/src/services/renderer/nodes/text-node.ts @@ -1,10 +1,14 @@ import type { CanvasRenderer } from "../canvas-renderer"; +import { createOffscreenCanvas } from "../canvas-utils"; import { BaseNode } from "./base-node"; import type { TextElement } from "@/types/timeline"; import { + DEFAULT_TEXT_BACKGROUND, DEFAULT_TEXT_ELEMENT, DEFAULT_LINE_HEIGHT, FONT_SIZE_SCALE_REFERENCE, + CORNER_RADIUS_MAX, + CORNER_RADIUS_MIN, } from "@/constants/text-constants"; import { getMetricAscent, @@ -12,6 +16,17 @@ import { getTextBackgroundRect, measureTextBlock, } from "@/lib/text/layout"; +import { + getElementLocalTime, + resolveColorAtTime, + resolveNumberAtTime, + resolveOpacityAtTime, + resolveTransformAtTime, +} from "@/lib/animation"; +import { resolveEffectParamsAtTime } from "@/lib/animation/effect-param-channel"; +import { getEffect } from "@/lib/effects"; +import { webglEffectRenderer } from "../webgl-effect-renderer"; +import { clamp } from "@/utils/math"; function scaleFontSize({ fontSize, @@ -27,6 +42,9 @@ function quoteFontFamily({ fontFamily }: { fontFamily: string }): string { return `"${fontFamily.replace(/"/g, '\\"')}"`; } +const TEXT_DECORATION_THICKNESS_RATIO = 0.07; +const STRIKETHROUGH_VERTICAL_RATIO = 0.35; + function drawTextDecoration({ ctx, textDecoration, @@ -46,7 +64,7 @@ function drawTextDecoration({ }): void { if (textDecoration === "none" || !textDecoration) return; - const thickness = Math.max(1, scaledFontSize * 0.07); + const thickness = Math.max(1, scaledFontSize * TEXT_DECORATION_THICKNESS_RATIO); const ascent = getMetricAscent({ metrics, fallbackFontSize: scaledFontSize }); const descent = getMetricDescent({ metrics, fallbackFontSize: scaledFontSize }); @@ -60,7 +78,7 @@ function drawTextDecoration({ } if (textDecoration === "line-through") { - const strikeY = lineY - (ascent - descent) * 0.35; + const strikeY = lineY - (ascent - descent) * STRIKETHROUGH_VERTICAL_RATIO; ctx.fillRect(xStart, strikeY, lineWidth, thickness); } } @@ -84,19 +102,24 @@ export class TextNode extends BaseNode<TextNodeParams> { return; } - renderer.context.save(); - - const x = this.params.transform.position.x + this.params.canvasCenter.x; - const y = this.params.transform.position.y + this.params.canvasCenter.y; + const localTime = getElementLocalTime({ + timelineTime: time, + elementStartTime: this.params.startTime, + elementDuration: this.params.duration, + }); + const transform = resolveTransformAtTime({ + baseTransform: this.params.transform, + animations: this.params.animations, + localTime, + }); + const opacity = resolveOpacityAtTime({ + baseOpacity: this.params.opacity, + animations: this.params.animations, + localTime, + }); - renderer.context.translate(x, y); - renderer.context.scale( - this.params.transform.scale, - this.params.transform.scale, - ); - if (this.params.transform.rotate) { - renderer.context.rotate((this.params.transform.rotate * Math.PI) / 180); - } + const x = transform.position.x + this.params.canvasCenter.x; + const y = transform.position.y + this.params.canvasCenter.y; const fontWeight = this.params.fontWeight === "bold" ? "bold" : "normal"; const fontStyle = this.params.fontStyle === "italic" ? "italic" : "normal"; @@ -105,83 +128,193 @@ export class TextNode extends BaseNode<TextNodeParams> { canvasHeight: this.params.canvasHeight, }); const fontFamily = quoteFontFamily({ fontFamily: this.params.fontFamily }); - renderer.context.font = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`; - renderer.context.textAlign = this.params.textAlign; - renderer.context.fillStyle = this.params.color; - + const fontString = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`; const letterSpacing = this.params.letterSpacing ?? 0; const lineHeight = this.params.lineHeight ?? DEFAULT_LINE_HEIGHT; - if ("letterSpacing" in renderer.context) { - ( - renderer.context as CanvasRenderingContext2D & { letterSpacing: string } - ).letterSpacing = `${letterSpacing}px`; - } - const lines = this.params.content.split("\n"); const lineHeightPx = scaledFontSize * lineHeight; const fontSizeRatio = this.params.fontSize / DEFAULT_TEXT_ELEMENT.fontSize; const baseline = this.params.textBaseline ?? "middle"; + const blendMode = ( + this.params.blendMode && this.params.blendMode !== "normal" + ? this.params.blendMode + : "source-over" + ) as GlobalCompositeOperation; + renderer.context.save(); + renderer.context.font = fontString; renderer.context.textBaseline = baseline; + if ("letterSpacing" in renderer.context) { + (renderer.context as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = `${letterSpacing}px`; + } const lineMetrics = lines.map((line) => renderer.context.measureText(line)); + renderer.context.restore(); + const lineCount = lines.length; + const block = measureTextBlock({ lineMetrics, lineHeightPx, fallbackFontSize: scaledFontSize }); - const block = measureTextBlock({ - lineMetrics, - lineHeightPx, - fallbackFontSize: scaledFontSize, + const textColor = resolveColorAtTime({ + baseColor: this.params.color, + animations: this.params.animations, + propertyPath: "color", + localTime, }); + const bg = this.params.background; + const resolvedBackground = { + ...bg, + color: resolveColorAtTime({ + baseColor: bg.color, + animations: this.params.animations, + propertyPath: "background.color", + localTime, + }), + paddingX: resolveNumberAtTime({ + baseValue: bg.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX, + animations: this.params.animations, + propertyPath: "background.paddingX", + localTime, + }), + paddingY: resolveNumberAtTime({ + baseValue: bg.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY, + animations: this.params.animations, + propertyPath: "background.paddingY", + localTime, + }), + offsetX: resolveNumberAtTime({ + baseValue: bg.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX, + animations: this.params.animations, + propertyPath: "background.offsetX", + localTime, + }), + offsetY: resolveNumberAtTime({ + baseValue: bg.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY, + animations: this.params.animations, + propertyPath: "background.offsetY", + localTime, + }), + cornerRadius: resolveNumberAtTime({ + baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN, + animations: this.params.animations, + propertyPath: "background.cornerRadius", + localTime, + }), + }; - const prevAlpha = renderer.context.globalAlpha; - renderer.context.globalCompositeOperation = ( - this.params.blendMode && this.params.blendMode !== "normal" - ? this.params.blendMode - : "source-over" - ) as GlobalCompositeOperation; - renderer.context.globalAlpha = this.params.opacity; + const drawContent = (ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D) => { + ctx.font = fontString; + ctx.textAlign = this.params.textAlign; + ctx.textBaseline = baseline; + ctx.fillStyle = textColor; + if ("letterSpacing" in ctx) { + (ctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = `${letterSpacing}px`; + } - if ( - this.params.background.color && - this.params.background.color !== "transparent" && - lineCount > 0 - ) { - const { color, cornerRadius = 0 } = this.params.background; - const backgroundRect = getTextBackgroundRect({ - textAlign: this.params.textAlign, - block, - background: this.params.background, - fontSizeRatio, - }); - if (backgroundRect) { - renderer.context.fillStyle = color; - renderer.context.beginPath(); - renderer.context.roundRect( - backgroundRect.left, - backgroundRect.top, - backgroundRect.width, - backgroundRect.height, - cornerRadius, - ); - renderer.context.fill(); - renderer.context.fillStyle = this.params.color; + if ( + this.params.background.enabled && + this.params.background.color && + this.params.background.color !== "transparent" && + lineCount > 0 + ) { + const backgroundRect = getTextBackgroundRect({ + textAlign: this.params.textAlign, + block, + background: resolvedBackground, + fontSizeRatio, + }); + if (backgroundRect) { + const p = clamp({ value: resolvedBackground.cornerRadius, min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX }) / 100; + const radius = Math.min(backgroundRect.width, backgroundRect.height) / 2 * p; + ctx.fillStyle = resolvedBackground.color; + ctx.beginPath(); + ctx.roundRect(backgroundRect.left, backgroundRect.top, backgroundRect.width, backgroundRect.height, radius); + ctx.fill(); + ctx.fillStyle = textColor; + } } + + for (let i = 0; i < lineCount; i++) { + const lineY = i * lineHeightPx - block.visualCenterOffset; + ctx.fillText(lines[i], 0, lineY); + drawTextDecoration({ + ctx, + textDecoration: this.params.textDecoration ?? "none", + lineWidth: lineMetrics[i].width, + lineY, + metrics: lineMetrics[i], + scaledFontSize, + textAlign: this.params.textAlign, + }); + } + }; + + const applyTransform = (ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D) => { + ctx.translate(x, y); + ctx.scale(transform.scale, transform.scale); + if (transform.rotate) { + ctx.rotate((transform.rotate * Math.PI) / 180); + } + }; + + const enabledEffects = this.params.effects?.filter((effect) => effect.enabled) ?? []; + + if (enabledEffects.length === 0) { + renderer.context.save(); + applyTransform(renderer.context); + renderer.context.globalCompositeOperation = blendMode; + renderer.context.globalAlpha = opacity; + drawContent(renderer.context); + renderer.context.restore(); + return; + } + + // Effects path: render text to a same-size offscreen canvas so the blur + // can spread into the surrounding transparent area without hard clipping. + const offscreen = createOffscreenCanvas({ width: renderer.width, height: renderer.height }); + const offscreenCtx = offscreen.getContext("2d") as OffscreenCanvasRenderingContext2D | null; + + if (!offscreenCtx) { + renderer.context.save(); + applyTransform(renderer.context); + renderer.context.globalCompositeOperation = blendMode; + renderer.context.globalAlpha = opacity; + drawContent(renderer.context); + renderer.context.restore(); + return; } - for (let i = 0; i < lineCount; i++) { - const y = i * lineHeightPx - block.visualCenterOffset; - renderer.context.fillText(lines[i], 0, y); - drawTextDecoration({ - ctx: renderer.context, - textDecoration: this.params.textDecoration ?? "none", - lineWidth: lineMetrics[i].width, - lineY: y, - metrics: lineMetrics[i], - scaledFontSize, - textAlign: this.params.textAlign, + offscreenCtx.save(); + applyTransform(offscreenCtx); + drawContent(offscreenCtx); + offscreenCtx.restore(); + + let currentSource: CanvasImageSource = offscreen; + for (const effect of enabledEffects) { + const resolvedParams = resolveEffectParamsAtTime({ + effect, + animations: this.params.animations, + localTime, + }); + const definition = getEffect({ effectType: effect.type }); + const passes = definition.renderer.passes.map((pass) => ({ + fragmentShader: pass.fragmentShader, + uniforms: pass.uniforms({ + effectParams: resolvedParams, + width: renderer.width, + height: renderer.height, + }), + })); + currentSource = webglEffectRenderer.applyEffect({ + source: currentSource, + width: renderer.width, + height: renderer.height, + passes, }); } - renderer.context.globalAlpha = prevAlpha; + renderer.context.save(); + renderer.context.globalCompositeOperation = blendMode; + renderer.context.globalAlpha = opacity; + renderer.context.drawImage(currentSource, 0, 0); renderer.context.restore(); } } diff --git a/apps/web/src/services/renderer/nodes/video-node.ts b/apps/web/src/services/renderer/nodes/video-node.ts index 8e2ddccd..503c9c03 100644 --- a/apps/web/src/services/renderer/nodes/video-node.ts +++ b/apps/web/src/services/renderer/nodes/video-node.ts @@ -12,11 +12,11 @@ export class VideoNode extends VisualNode<VideoNodeParams> { async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) { await super.render({ renderer, time }); - if (!this.isInRange(time)) { + if (!this.isInRange({ time })) { return; } - const videoTime = this.getLocalTime(time); + const videoTime = this.getSourceLocalTime({ time }); const frame = await videoCache.getFrameAt({ mediaId: this.params.mediaId, file: this.params.file, @@ -29,6 +29,7 @@ export class VideoNode extends VisualNode<VideoNodeParams> { source: frame.canvas, sourceWidth: frame.canvas.width, sourceHeight: frame.canvas.height, + timelineTime: time, }); } } diff --git a/apps/web/src/services/renderer/nodes/visual-node.ts b/apps/web/src/services/renderer/nodes/visual-node.ts index 7179369e..6c50aa12 100644 --- a/apps/web/src/services/renderer/nodes/visual-node.ts +++ b/apps/web/src/services/renderer/nodes/visual-node.ts @@ -1,9 +1,19 @@ import type { CanvasRenderer } from "../canvas-renderer"; +import { createOffscreenCanvas } from "../canvas-utils"; import { BaseNode } from "./base-node"; +import type { Effect } from "@/types/effects"; import type { BlendMode } from "@/types/rendering"; import type { Transform } from "@/types/timeline"; - -const VISUAL_EPSILON = 1 / 1000; +import type { ElementAnimations } from "@/types/animation"; +import { + getElementLocalTime, + resolveOpacityAtTime, + resolveTransformAtTime, +} from "@/lib/animation"; +import { resolveEffectParamsAtTime } from "@/lib/animation/effect-param-channel"; +import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; +import { getEffect } from "@/lib/effects"; +import { webglEffectRenderer } from "../webgl-effect-renderer"; export interface VisualNodeParams { duration: number; @@ -11,21 +21,31 @@ export interface VisualNodeParams { trimStart: number; trimEnd: number; transform: Transform; + animations?: ElementAnimations; opacity: number; blendMode?: BlendMode; + effects?: Effect[]; } export abstract class VisualNode< Params extends VisualNodeParams = VisualNodeParams, > extends BaseNode<Params> { - protected getLocalTime(time: number): number { + protected getSourceLocalTime({ time }: { time: number }): number { return time - this.params.timeOffset + this.params.trimStart; } - protected isInRange(time: number): boolean { - const localTime = this.getLocalTime(time); + protected getAnimationLocalTime({ time }: { time: number }): number { + return getElementLocalTime({ + timelineTime: time, + elementStartTime: this.params.timeOffset, + elementDuration: this.params.duration, + }); + } + + protected isInRange({ time }: { time: number }): boolean { + const localTime = this.getSourceLocalTime({ time }); return ( - localTime >= this.params.trimStart - VISUAL_EPSILON && + localTime >= this.params.trimStart - TIME_EPSILON_SECONDS && localTime < this.params.trimStart + this.params.duration ); } @@ -35,15 +55,27 @@ export abstract class VisualNode< source, sourceWidth, sourceHeight, + timelineTime, }: { renderer: CanvasRenderer; source: CanvasImageSource; sourceWidth: number; sourceHeight: number; + timelineTime: number; }): void { renderer.context.save(); - const { transform, opacity } = this.params; + const animationLocalTime = this.getAnimationLocalTime({ time: timelineTime }); + const transform = resolveTransformAtTime({ + baseTransform: this.params.transform, + animations: this.params.animations, + localTime: animationLocalTime, + }); + const opacity = resolveOpacityAtTime({ + baseOpacity: this.params.opacity, + animations: this.params.animations, + localTime: animationLocalTime, + }); const containScale = Math.min( renderer.width / sourceWidth, renderer.height / sourceHeight, @@ -68,7 +100,63 @@ export abstract class VisualNode< renderer.context.translate(-centerX, -centerY); } - renderer.context.drawImage(source, x, y, scaledWidth, scaledHeight); + const enabledEffects = + this.params.effects?.filter((effect) => effect.enabled) ?? []; + + if (enabledEffects.length === 0) { + renderer.context.drawImage(source, x, y, scaledWidth, scaledHeight); + renderer.context.restore(); + return; + } + + const elementCanvas = createOffscreenCanvas({ + width: Math.round(scaledWidth), + height: Math.round(scaledHeight), + }); + const elementCtx = elementCanvas.getContext("2d") as + | CanvasRenderingContext2D + | OffscreenCanvasRenderingContext2D + | null; + if (!elementCtx) { + renderer.context.drawImage(source, x, y, scaledWidth, scaledHeight); + renderer.context.restore(); + return; + } + + elementCtx.drawImage(source, 0, 0, scaledWidth, scaledHeight); + + let currentResult: CanvasImageSource = elementCanvas; + + for (const effect of enabledEffects) { + const resolvedParams = resolveEffectParamsAtTime({ + effect, + animations: this.params.animations, + localTime: animationLocalTime, + }); + const definition = getEffect({ effectType: effect.type }); + const passes = definition.renderer.passes.map((pass) => ({ + fragmentShader: pass.fragmentShader, + uniforms: pass.uniforms({ + effectParams: resolvedParams, + width: scaledWidth, + height: scaledHeight, + }), + })); + currentResult = webglEffectRenderer.applyEffect({ + source: currentResult, + width: Math.round(scaledWidth), + height: Math.round(scaledHeight), + passes, + }); + } + + renderer.context.drawImage( + currentResult, + x, + y, + scaledWidth, + scaledHeight, + ); renderer.context.restore(); } } diff --git a/apps/web/src/services/renderer/scene-builder.ts b/apps/web/src/services/renderer/scene-builder.ts index 530b2eb3..695e077f 100644 --- a/apps/web/src/services/renderer/scene-builder.ts +++ b/apps/web/src/services/renderer/scene-builder.ts @@ -6,51 +6,59 @@ import { ImageNode } from "./nodes/image-node"; import { TextNode } from "./nodes/text-node"; import { StickerNode } from "./nodes/sticker-node"; import { ColorNode } from "./nodes/color-node"; -import { BlurBackgroundNode } from "./nodes/blur-background-node"; +import { CompositeEffectNode } from "./nodes/composite-effect-node"; +import { EffectLayerNode } from "./nodes/effect-layer-node"; +import type { BaseNode } from "./nodes/base-node"; import type { TBackground, TCanvasSize } from "@/types/project"; import { DEFAULT_BLUR_INTENSITY } from "@/constants/project-constants"; import { isMainTrack } from "@/lib/timeline"; const PREVIEW_MAX_IMAGE_SIZE = 2048; +const BLUR_BACKGROUND_ZOOM_SCALE = 1.4; -export type BuildSceneParams = { - canvasSize: TCanvasSize; +function getVisibleSortedElements({ + track, +}: { + track: TimelineTrack; +}) { + return track.elements + .filter((element) => !("hidden" in element && element.hidden)) + .slice() + .sort((a, b) => { + if (a.startTime !== b.startTime) return a.startTime - b.startTime; + return a.id.localeCompare(b.id); + }); +} + +function buildTrackNodes({ + tracks, + mediaMap, + canvasSize, + isPreview, +}: { tracks: TimelineTrack[]; - mediaAssets: MediaAsset[]; - duration: number; - background: TBackground; + mediaMap: Map<string, MediaAsset>; + canvasSize: TCanvasSize; isPreview?: boolean; -}; - -export function buildScene(params: BuildSceneParams) { - const { tracks, mediaAssets, duration, canvasSize, background } = params; - - const rootNode = new RootNode({ duration }); - const mediaMap = new Map(mediaAssets.map((m) => [m.id, m])); +}): BaseNode[] { + const nodes: BaseNode[] = []; - const visibleTracks = tracks.filter( - (track) => !("hidden" in track && track.hidden), - ); - - const orderedTracksTopToBottom = [ - ...visibleTracks.filter((track) => !isMainTrack(track)), - ...visibleTracks.filter((track) => isMainTrack(track)), - ]; - - const orderedTracksBottomToTop = orderedTracksTopToBottom.slice().reverse(); - - const contentNodes = []; - - for (const track of orderedTracksBottomToTop) { - const elements = track.elements - .filter((element) => !("hidden" in element && element.hidden)) - .slice() - .sort((a, b) => { - if (a.startTime !== b.startTime) return a.startTime - b.startTime; - return a.id.localeCompare(b.id); - }); + for (const track of tracks) { + const elements = getVisibleSortedElements({ track }); for (const element of elements) { + if (element.type === "effect") { + nodes.push( + new EffectLayerNode({ + effectType: element.effectType, + effectParams: element.params, + timeOffset: element.startTime, + duration: element.duration, + }), + ); + continue; + } + if (element.type === "video" || element.type === "image") { const mediaAsset = mediaMap.get(element.mediaId); if (!mediaAsset?.file || !mediaAsset?.url) { @@ -58,7 +66,7 @@ export function buildScene(params: BuildSceneParams) { } if (mediaAsset.type === "video") { - contentNodes.push( + nodes.push( new VideoNode({ mediaId: mediaAsset.id, url: mediaAsset.url, @@ -68,13 +76,15 @@ export function buildScene(params: BuildSceneParams) { trimStart: element.trimStart, trimEnd: element.trimEnd, transform: element.transform, + animations: element.animations, opacity: element.opacity, blendMode: element.blendMode, + effects: element.effects, }), ); } if (mediaAsset.type === "image") { - contentNodes.push( + nodes.push( new ImageNode({ url: mediaAsset.url, duration: element.duration, @@ -82,9 +92,11 @@ export function buildScene(params: BuildSceneParams) { trimStart: element.trimStart, trimEnd: element.trimEnd, transform: element.transform, + animations: element.animations, opacity: element.opacity, blendMode: element.blendMode, - ...(params.isPreview && { + effects: element.effects, + ...(isPreview && { maxSourceSize: PREVIEW_MAX_IMAGE_SIZE, }), }), @@ -93,18 +105,19 @@ export function buildScene(params: BuildSceneParams) { } if (element.type === "text") { - contentNodes.push( + nodes.push( new TextNode({ ...element, canvasCenter: { x: canvasSize.width / 2, y: canvasSize.height / 2 }, canvasHeight: canvasSize.height, textBaseline: "middle", + effects: element.effects, }), ); } if (element.type === "sticker") { - contentNodes.push( + nodes.push( new StickerNode({ stickerId: element.stickerId, duration: element.duration, @@ -112,31 +125,77 @@ export function buildScene(params: BuildSceneParams) { trimStart: element.trimStart, trimEnd: element.trimEnd, transform: element.transform, + animations: element.animations, opacity: element.opacity, blendMode: element.blendMode, + effects: element.effects, }), ); } } } + return nodes; +} + +export type BuildSceneParams = { + canvasSize: TCanvasSize; + tracks: TimelineTrack[]; + mediaAssets: MediaAsset[]; + duration: number; + background: TBackground; + isPreview?: boolean; +}; + +export function buildScene({ + canvasSize, + tracks, + mediaAssets, + duration, + background, + isPreview, +}: BuildSceneParams) { + const rootNode = new RootNode({ duration }); + const mediaMap = new Map(mediaAssets.map((m) => [m.id, m])); + + const visibleTracks = tracks.filter( + (track) => !("hidden" in track && track.hidden), + ); + + const orderedTracksTopToBottom = [ + ...visibleTracks.filter((track) => !isMainTrack(track)), + ...visibleTracks.filter((track) => isMainTrack(track)), + ]; + + const orderedTracksBottomToTop = orderedTracksTopToBottom.slice().reverse(); + + const allNodes = buildTrackNodes({ + tracks: orderedTracksBottomToTop, + mediaMap, + canvasSize, + isPreview, + }); + if (background.type === "blur") { rootNode.add( - new BlurBackgroundNode({ - blurIntensity: background.blurIntensity ?? DEFAULT_BLUR_INTENSITY, - contentNodes, + new CompositeEffectNode({ + contentNodes: allNodes.filter( + (node) => !(node instanceof EffectLayerNode), + ), + effectType: "blur", + effectParams: { + intensity: + background.blurIntensity ?? DEFAULT_BLUR_INTENSITY, + }, + scale: BLUR_BACKGROUND_ZOOM_SCALE, }), ); - for (const node of contentNodes) { - rootNode.add(node); - } - } else { - if (background.type === "color" && background.color !== "transparent") { - rootNode.add(new ColorNode({ color: background.color })); - } - for (const node of contentNodes) { - rootNode.add(node); - } + } else if (background.type === "color" && background.color !== "transparent") { + rootNode.add(new ColorNode({ color: background.color })); + } + + for (const node of allNodes) { + rootNode.add(node); } return rootNode; diff --git a/apps/web/src/services/renderer/webgl-effect-renderer.ts b/apps/web/src/services/renderer/webgl-effect-renderer.ts new file mode 100644 index 00000000..ad5cd40e --- /dev/null +++ b/apps/web/src/services/renderer/webgl-effect-renderer.ts @@ -0,0 +1,73 @@ +import { createOffscreenCanvas } from "./canvas-utils"; +import { applyMultiPassEffect } from "./webgl-utils"; +import type { EffectPassData } from "./webgl-utils"; + +export interface ApplyEffectParams { + source: CanvasImageSource; + width: number; + height: number; + passes: EffectPassData[]; +} + +let gl: WebGLRenderingContext | null = null; +let canvas: OffscreenCanvas | HTMLCanvasElement | null = null; +const programCache = new Map<string, WebGLProgram>(); + +function getOrCreateCanvas({ + width, + height, +}: { + width: number; + height: number; +}): OffscreenCanvas | HTMLCanvasElement { + if (!canvas) { + canvas = createOffscreenCanvas({ width, height }); + gl = canvas.getContext("webgl", { + premultipliedAlpha: false, + }) as WebGLRenderingContext | null; + if (!gl) { + throw new Error("WebGL not supported"); + } + } + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + } + return canvas; +} + +function applyEffect({ + source, + width, + height, + passes, +}: ApplyEffectParams): OffscreenCanvas | HTMLCanvasElement { + const targetCanvas = getOrCreateCanvas({ width, height }); + const context = gl; + if (!context) { + throw new Error("WebGL context not initialized"); + } + + applyMultiPassEffect({ + context, + source, + width, + height, + passes, + programCache, + }); + + const outputCanvas = createOffscreenCanvas({ width, height }); + const outputCtx = outputCanvas.getContext("2d") as + | CanvasRenderingContext2D + | OffscreenCanvasRenderingContext2D + | null; + if (outputCtx) { + outputCtx.drawImage(targetCanvas, 0, 0, width, height); + } + return outputCanvas; +} + +export const webglEffectRenderer = { + applyEffect, +}; diff --git a/apps/web/src/services/renderer/webgl-utils.ts b/apps/web/src/services/renderer/webgl-utils.ts new file mode 100644 index 00000000..834c652e --- /dev/null +++ b/apps/web/src/services/renderer/webgl-utils.ts @@ -0,0 +1,298 @@ +import VERTEX_SHADER_SOURCE from "@/lib/effects/effect.vert.glsl"; + +export interface EffectPassData { + fragmentShader: string; + uniforms: Record<string, number | number[]>; +} + +export const QUAD_POSITIONS = new Float32Array([ + -1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1, +]); + +export function compileProgram({ + context, + fragmentShaderSource, + programCache, +}: { + context: WebGLRenderingContext; + fragmentShaderSource: string; + programCache: Map<string, WebGLProgram>; +}): WebGLProgram { + const cached = programCache.get(fragmentShaderSource); + if (cached) { + return cached; + } + const vertexShader = compileShader({ + context, + source: VERTEX_SHADER_SOURCE, + type: context.VERTEX_SHADER, + }); + const fragmentShader = compileShader({ + context, + source: fragmentShaderSource, + type: context.FRAGMENT_SHADER, + }); + const program = context.createProgram(); + if (!program) { + throw new Error("Failed to create WebGL program"); + } + context.attachShader(program, vertexShader); + context.attachShader(program, fragmentShader); + context.linkProgram(program); + if (!context.getProgramParameter(program, context.LINK_STATUS)) { + const info = context.getProgramInfoLog(program); + context.deleteProgram(program); + throw new Error(`WebGL program link failed: ${info}`); + } + context.deleteShader(vertexShader); + context.deleteShader(fragmentShader); + programCache.set(fragmentShaderSource, program); + return program; +} + +export function compileShader({ + context, + source, + type, +}: { + context: WebGLRenderingContext; + source: string; + type: number; +}): WebGLShader { + const shader = context.createShader(type); + if (!shader) { + throw new Error("Failed to create WebGL shader"); + } + context.shaderSource(shader, source); + context.compileShader(shader); + if (!context.getShaderParameter(shader, context.COMPILE_STATUS)) { + const info = context.getShaderInfoLog(shader); + context.deleteShader(shader); + throw new Error(`WebGL shader compile failed: ${info}`); + } + return shader; +} + +export function createTexture({ + context, + source, +}: { + context: WebGLRenderingContext; + source: CanvasImageSource; +}): WebGLTexture { + const texture = context.createTexture(); + if (!texture) { + throw new Error("Failed to create WebGL texture"); + } + context.activeTexture(context.TEXTURE0); + context.bindTexture(context.TEXTURE_2D, texture); + context.pixelStorei(context.UNPACK_FLIP_Y_WEBGL, 1); + context.texParameteri( + context.TEXTURE_2D, + context.TEXTURE_WRAP_S, + context.CLAMP_TO_EDGE, + ); + context.texParameteri( + context.TEXTURE_2D, + context.TEXTURE_WRAP_T, + context.CLAMP_TO_EDGE, + ); + context.texParameteri( + context.TEXTURE_2D, + context.TEXTURE_MIN_FILTER, + context.LINEAR, + ); + context.texParameteri( + context.TEXTURE_2D, + context.TEXTURE_MAG_FILTER, + context.LINEAR, + ); + context.texImage2D( + context.TEXTURE_2D, + 0, + context.RGBA, + context.RGBA, + context.UNSIGNED_BYTE, + source as TexImageSource, + ); + return texture; +} + +export function setUniforms({ + context, + program, + uniforms, +}: { + context: WebGLRenderingContext; + program: WebGLProgram; + uniforms: Record<string, number | number[]>; +}): void { + for (const [name, value] of Object.entries(uniforms)) { + const location = context.getUniformLocation(program, name); + if (location === null) continue; + + if (typeof value === "number") { + context.uniform1f(location, value); + } else if (Array.isArray(value)) { + if (value.length === 2) { + context.uniform2fv(location, new Float32Array(value)); + } else if (value.length === 3) { + context.uniform3fv(location, new Float32Array(value)); + } else if (value.length === 4) { + context.uniform4fv(location, new Float32Array(value)); + } + } + } +} + +export function drawFullscreenQuad({ + context, + program, + width, + height, +}: { + context: WebGLRenderingContext; + program: WebGLProgram; + width: number; + height: number; +}): void { + const positionLocation = context.getAttribLocation(program, "a_position"); + const buffer = context.createBuffer(); + context.bindBuffer(context.ARRAY_BUFFER, buffer); + context.bufferData(context.ARRAY_BUFFER, QUAD_POSITIONS, context.STATIC_DRAW); + context.enableVertexAttribArray(positionLocation); + context.vertexAttribPointer(positionLocation, 2, context.FLOAT, false, 0, 0); + + context.viewport(0, 0, width, height); + context.clearColor(0, 0, 0, 0); + context.clear(context.COLOR_BUFFER_BIT); + context.drawArrays(context.TRIANGLES, 0, 6); +} + +export function createFramebufferTexture({ + context, + width, + height, +}: { + context: WebGLRenderingContext; + width: number; + height: number; +}): { texture: WebGLTexture; framebuffer: WebGLFramebuffer } { + const texture = context.createTexture(); + if (!texture) throw new Error("Failed to create framebuffer texture"); + context.bindTexture(context.TEXTURE_2D, texture); + context.texImage2D( + context.TEXTURE_2D, + 0, + context.RGBA, + width, + height, + 0, + context.RGBA, + context.UNSIGNED_BYTE, + null, + ); + context.texParameteri( + context.TEXTURE_2D, + context.TEXTURE_WRAP_S, + context.CLAMP_TO_EDGE, + ); + context.texParameteri( + context.TEXTURE_2D, + context.TEXTURE_WRAP_T, + context.CLAMP_TO_EDGE, + ); + context.texParameteri( + context.TEXTURE_2D, + context.TEXTURE_MIN_FILTER, + context.LINEAR, + ); + context.texParameteri( + context.TEXTURE_2D, + context.TEXTURE_MAG_FILTER, + context.LINEAR, + ); + context.bindTexture(context.TEXTURE_2D, null); + + const framebuffer = context.createFramebuffer(); + if (!framebuffer) throw new Error("Failed to create framebuffer"); + context.bindFramebuffer(context.FRAMEBUFFER, framebuffer); + context.framebufferTexture2D( + context.FRAMEBUFFER, + context.COLOR_ATTACHMENT0, + context.TEXTURE_2D, + texture, + 0, + ); + context.bindFramebuffer(context.FRAMEBUFFER, null); + + return { texture, framebuffer }; +} + +export function applyMultiPassEffect({ + context, + source, + width, + height, + passes, + programCache, +}: { + context: WebGLRenderingContext; + source: CanvasImageSource; + width: number; + height: number; + passes: EffectPassData[]; + programCache: Map<string, WebGLProgram>; +}): void { + const sourceTexture = createTexture({ context, source }); + let currentTexture: WebGLTexture = sourceTexture; + + const intermediates: Array<{ + texture: WebGLTexture; + framebuffer: WebGLFramebuffer; + }> = []; + for (let i = 0; i < passes.length - 1; i++) { + intermediates.push(createFramebufferTexture({ context, width, height })); + } + + for (let i = 0; i < passes.length; i++) { + const pass = passes[i]; + const program = compileProgram({ + context, + fragmentShaderSource: pass.fragmentShader, + programCache, + }); + const isLastPass = i === passes.length - 1; + const targetFramebuffer = isLastPass ? null : intermediates[i].framebuffer; + + context.bindFramebuffer(context.FRAMEBUFFER, targetFramebuffer); + // biome-ignore lint/correctness/useHookAtTopLevel: WebGL API method, not a React hook + context.useProgram(program); + context.activeTexture(context.TEXTURE0); + context.bindTexture(context.TEXTURE_2D, currentTexture); + + const uTextureLocation = context.getUniformLocation(program, "u_texture"); + if (uTextureLocation) { + context.uniform1i(uTextureLocation, 0); + } + + setUniforms({ + context, + program, + uniforms: { ...pass.uniforms, u_resolution: [width, height] }, + }); + drawFullscreenQuad({ context, program, width, height }); + + if (!isLastPass) { + currentTexture = intermediates[i].texture; + } + } + + context.deleteTexture(sourceTexture); + for (const intermediate of intermediates) { + context.deleteTexture(intermediate.texture); + context.deleteFramebuffer(intermediate.framebuffer); + } + context.bindTexture(context.TEXTURE_2D, null); + context.bindFramebuffer(context.FRAMEBUFFER, null); +} diff --git a/apps/web/src/services/storage/migrations/__tests__/v8-to-v9.test.ts b/apps/web/src/services/storage/migrations/__tests__/v8-to-v9.test.ts new file mode 100644 index 00000000..51e91f78 --- /dev/null +++ b/apps/web/src/services/storage/migrations/__tests__/v8-to-v9.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, test } from "bun:test"; +import { transformProjectV8ToV9 } from "../transformers/v8-to-v9"; + +const v8ProjectWithText = { + id: "project-v8-text", + version: 8, + metadata: { + id: "project-v8-text", + name: "V8 Project with Text", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }, + settings: { + fps: 30, + canvasSize: { width: 1920, height: 1080 }, + background: { type: "color", color: "#000000" }, + }, + currentSceneId: "scene-main", + scenes: [ + { + id: "scene-main", + name: "Main scene", + isMain: true, + tracks: [ + { + id: "track-text", + type: "text", + name: "Text Track", + hidden: false, + elements: [ + { + id: "el-1", + type: "text", + content: "With color", + startTime: 0, + duration: 5, + background: { + color: "#ff0000", + cornerRadius: 0, + paddingX: 8, + paddingY: 4, + }, + }, + { + id: "el-2", + type: "text", + content: "Transparent", + startTime: 5, + duration: 5, + background: { + color: "transparent", + paddingX: 30, + paddingY: 42, + }, + }, + ], + }, + ], + bookmarks: [], + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }, + ], +} as Parameters<typeof transformProjectV8ToV9>[0]["project"]; + +describe("V8 to V9 Migration", () => { + test("adds background.enabled from color (transparent => false, otherwise true)", () => { + const result = transformProjectV8ToV9({ project: v8ProjectWithText }); + + expect(result.skipped).toBe(false); + expect(result.project.version).toBe(9); + + const track = ( + result.project.scenes as Array<{ tracks: Array<{ elements: unknown[] }> }> + )[0].tracks[0]; + const elements = track.elements as Array<{ background: { enabled: boolean; color: string } }>; + + expect(elements[0].background.enabled).toBe(true); + expect(elements[0].background.color).toBe("#ff0000"); + + expect(elements[1].background.enabled).toBe(false); + expect(elements[1].background.color).toBe("transparent"); + }); + + test("preserves existing background.enabled if already present", () => { + const projectWithEnabled = { + ...v8ProjectWithText, + scenes: [ + { + ...(v8ProjectWithText.scenes as Record<string, unknown>[])[0], + tracks: [ + { + id: "track-text", + type: "text", + name: "Text Track", + hidden: false, + elements: [ + { + id: "el-1", + type: "text", + content: "Already has enabled", + startTime: 0, + duration: 5, + background: { + enabled: false, + color: "#00ff00", + }, + }, + ], + }, + ], + }, + ], + } as Parameters<typeof transformProjectV8ToV9>[0]["project"]; + + const result = transformProjectV8ToV9({ project: projectWithEnabled }); + + expect(result.skipped).toBe(false); + const elements = ( + result.project.scenes as Array<{ tracks: Array<{ elements: unknown[] }> }> + )[0].tracks[0].elements as Array<{ background: { enabled: boolean } }>; + expect(elements[0].background.enabled).toBe(false); + }); + + test("skips non-text elements and tracks", () => { + const projectWithVideoOnly = { + ...v8ProjectWithText, + scenes: [ + { + id: "scene-main", + name: "Main scene", + isMain: true, + tracks: [ + { + id: "track-video", + type: "video", + name: "Video Track", + isMain: true, + elements: [], + }, + ], + bookmarks: [], + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }, + ], + } as Parameters<typeof transformProjectV8ToV9>[0]["project"]; + + const result = transformProjectV8ToV9({ project: projectWithVideoOnly }); + + expect(result.skipped).toBe(false); + expect(result.project.version).toBe(9); + }); + + test("skips projects that are already v9", () => { + const result = transformProjectV8ToV9({ + project: { ...v8ProjectWithText, version: 9 }, + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("already v9"); + }); + + test("skips projects with no id", () => { + const result = transformProjectV8ToV9({ + project: { + version: 8, + scenes: [], + } as Parameters<typeof transformProjectV8ToV9>[0]["project"], + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("no project id"); + }); +}); diff --git a/apps/web/src/services/storage/migrations/index.ts b/apps/web/src/services/storage/migrations/index.ts index fd40f14c..bffacb66 100644 --- a/apps/web/src/services/storage/migrations/index.ts +++ b/apps/web/src/services/storage/migrations/index.ts @@ -6,10 +6,12 @@ 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"; export { runStorageMigrations } from "./runner"; export type { MigrationProgress } from "./runner"; -export const CURRENT_PROJECT_VERSION = 7; +export const CURRENT_PROJECT_VERSION = 9; export const migrations = [ new V0toV1Migration(), @@ -19,4 +21,6 @@ export const migrations = [ new V4toV5Migration(), new V5toV6Migration(), new V6toV7Migration(), + new V7toV8Migration(), + new V8toV9Migration(), ]; diff --git a/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts b/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts index 098101d6..297d1c58 100644 --- a/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts +++ b/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts @@ -388,7 +388,7 @@ async function transformMediaTrack({ ); const validElements = transformedElements.filter( - (el): el is VideoElement | ImageElement => el !== null, + (element): element is VideoElement | ImageElement => element !== null, ); return { @@ -450,10 +450,18 @@ function transformTextTrack({ value: textElement.color, fallback: "#000000", }), - backgroundColor: getStringValue({ - value: textElement.backgroundColor, - fallback: "#FFFFFF", - }), + background: { + enabled: false, + color: getStringValue({ + value: textElement.backgroundColor, + fallback: "transparent", + }), + cornerRadius: 0, + paddingX: 8, + paddingY: 4, + offsetX: 0, + offsetY: 0, + }, textAlign: (getStringValue({ value: textElement.textAlign, fallback: "left", @@ -479,7 +487,7 @@ function transformTextTrack({ trimEnd: getNumberValue({ value: element.trimEnd, fallback: 0 }), }; }) - .filter((el): el is TextElement => el !== null); + .filter((element): element is TextElement => element !== null); return { id: getStringValue({ value: track.id, fallback: "" }), @@ -522,7 +530,7 @@ function transformAudioTrack({ trimEnd: getNumberValue({ value: element.trimEnd, fallback: 0 }), }; }) - .filter((el): el is AudioElement => el !== null); + .filter((element): element is AudioElement => element !== null); return { id: getStringValue({ value: track.id, fallback: "" }), diff --git a/apps/web/src/services/storage/migrations/transformers/v7-to-v8.ts b/apps/web/src/services/storage/migrations/transformers/v7-to-v8.ts new file mode 100644 index 00000000..7393f776 --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v7-to-v8.ts @@ -0,0 +1,96 @@ +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +export function transformProjectV7ToV8({ + project, +}: { + project: ProjectRecord; +}): MigrationResult<ProjectRecord> { + const projectId = getProjectId({ project }); + if (!projectId) { + return { project, skipped: true, reason: "no project id" }; + } + + if (isV8Project({ project })) { + return { project, skipped: true, reason: "already v8" }; + } + + const migratedProject = migrateProjectElements({ project }); + + return { + project: { ...migratedProject, version: 8 }, + skipped: false, + }; +} + +function migrateProjectElements({ + project, +}: { + project: ProjectRecord; +}): ProjectRecord { + const scenesValue = project.scenes; + if (!Array.isArray(scenesValue)) return project; + + let hasChanges = false; + const migratedScenes = scenesValue.map((scene) => { + const migrated = migrateSceneElements({ scene }); + if (migrated !== scene) hasChanges = true; + return migrated; + }); + + if (!hasChanges) return project; + return { ...project, scenes: migratedScenes }; +} + +function migrateSceneElements({ scene }: { scene: unknown }): unknown { + if (!isRecord(scene)) return scene; + + const tracksValue = scene.tracks; + if (!Array.isArray(tracksValue)) return scene; + + let hasChanges = false; + const migratedTracks = tracksValue.map((track) => { + const migrated = migrateTrackElements({ track }); + if (migrated !== track) hasChanges = true; + return migrated; + }); + + if (!hasChanges) return scene; + return { ...scene, tracks: migratedTracks }; +} + +function migrateTrackElements({ track }: { track: unknown }): unknown { + if (!isRecord(track)) return track; + + const elementsValue = track.elements; + if (!Array.isArray(elementsValue)) return track; + + let hasChanges = false; + const migratedElements = elementsValue.map((element) => { + const migrated = migrateElement({ element }); + if (migrated !== element) hasChanges = true; + return migrated; + }); + + if (!hasChanges) return track; + return { ...track, elements: migratedElements }; +} + +function migrateElement({ element }: { element: unknown }): unknown { + if (!isRecord(element)) return element; + if (element.type !== "video" && element.type !== "audio") return element; + if (typeof element.sourceDuration === "number") return element; + + const trimStart = typeof element.trimStart === "number" ? element.trimStart : 0; + const duration = typeof element.duration === "number" ? element.duration : 0; + const trimEnd = typeof element.trimEnd === "number" ? element.trimEnd : 0; + + return { + ...element, + sourceDuration: trimStart + duration + trimEnd, + }; +} + +function isV8Project({ project }: { project: ProjectRecord }): boolean { + return typeof project.version === "number" && project.version >= 8; +} diff --git a/apps/web/src/services/storage/migrations/transformers/v8-to-v9.ts b/apps/web/src/services/storage/migrations/transformers/v8-to-v9.ts new file mode 100644 index 00000000..1dc895bc --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v8-to-v9.ts @@ -0,0 +1,99 @@ +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +export function transformProjectV8ToV9({ + project, +}: { + project: ProjectRecord; +}): MigrationResult<ProjectRecord> { + const projectId = getProjectId({ project }); + if (!projectId) { + return { project, skipped: true, reason: "no project id" }; + } + + if (isV9Project({ project })) { + return { project, skipped: true, reason: "already v9" }; + } + + const migratedProject = migrateProjectTextElements({ project }); + + return { + project: { ...migratedProject, version: 9 }, + skipped: false, + }; +} + +function migrateProjectTextElements({ + project, +}: { + project: ProjectRecord; +}): ProjectRecord { + const scenesValue = project.scenes; + if (!Array.isArray(scenesValue)) return project; + + let hasChanges = false; + const migratedScenes = scenesValue.map((scene) => { + const migrated = migrateSceneTextElements({ scene }); + if (migrated !== scene) hasChanges = true; + return migrated; + }); + + if (!hasChanges) return project; + return { ...project, scenes: migratedScenes }; +} + +function migrateSceneTextElements({ scene }: { scene: unknown }): unknown { + if (!isRecord(scene)) return scene; + + const tracksValue = scene.tracks; + if (!Array.isArray(tracksValue)) return scene; + + let hasChanges = false; + const migratedTracks = tracksValue.map((track) => { + const migrated = migrateTrackTextElements({ track }); + if (migrated !== track) hasChanges = true; + return migrated; + }); + + if (!hasChanges) return scene; + return { ...scene, tracks: migratedTracks }; +} + +function migrateTrackTextElements({ track }: { track: unknown }): unknown { + if (!isRecord(track)) return track; + if (track.type !== "text") return track; + + const elementsValue = track.elements; + if (!Array.isArray(elementsValue)) return track; + + let hasChanges = false; + const migratedElements = elementsValue.map((element) => { + const migrated = migrateTextElement({ element }); + if (migrated !== element) hasChanges = true; + return migrated; + }); + + if (!hasChanges) return track; + return { ...track, elements: migratedElements }; +} + +function migrateTextElement({ element }: { element: unknown }): unknown { + if (!isRecord(element)) return element; + if (element.type !== "text") return element; + + const bg = element.background; + if (!isRecord(bg)) return element; + if (typeof bg.enabled === "boolean") return element; + + const color = typeof bg.color === "string" ? bg.color : "transparent"; + const enabled = color !== "transparent"; + + return { + ...element, + background: { ...bg, enabled }, + }; +} + +function isV9Project({ project }: { project: ProjectRecord }): boolean { + return typeof project.version === "number" && project.version >= 9; +} diff --git a/apps/web/src/services/storage/migrations/v7-to-v8.ts b/apps/web/src/services/storage/migrations/v7-to-v8.ts new file mode 100644 index 00000000..33372c0a --- /dev/null +++ b/apps/web/src/services/storage/migrations/v7-to-v8.ts @@ -0,0 +1,16 @@ +import { StorageMigration } from "./base"; +import type { ProjectRecord } from "./transformers/types"; +import { transformProjectV7ToV8 } from "./transformers/v7-to-v8"; + +export class V7toV8Migration extends StorageMigration { + from = 7; + to = 8; + + async transform(project: ProjectRecord): Promise<{ + project: ProjectRecord; + skipped: boolean; + reason?: string; + }> { + return transformProjectV7ToV8({ project }); + } +} diff --git a/apps/web/src/services/storage/migrations/v8-to-v9.ts b/apps/web/src/services/storage/migrations/v8-to-v9.ts new file mode 100644 index 00000000..6222eeac --- /dev/null +++ b/apps/web/src/services/storage/migrations/v8-to-v9.ts @@ -0,0 +1,16 @@ +import { StorageMigration } from "./base"; +import type { ProjectRecord } from "./transformers/types"; +import { transformProjectV8ToV9 } from "./transformers/v8-to-v9"; + +export class V8toV9Migration extends StorageMigration { + from = 8; + to = 9; + + async transform(project: ProjectRecord): Promise<{ + project: ProjectRecord; + skipped: boolean; + reason?: string; + }> { + return transformProjectV8ToV9({ project }); + } +} diff --git a/apps/web/src/stores/assets-panel-store.tsx b/apps/web/src/stores/assets-panel-store.tsx index 21ceb6a3..35466573 100644 --- a/apps/web/src/stores/assets-panel-store.tsx +++ b/apps/web/src/stores/assets-panel-store.tsx @@ -1,5 +1,6 @@ import type { ElementType } from "react"; import { create } from "zustand"; +import { persist } from "zustand/middleware"; import { ArrowRightDoubleIcon, ClosedCaptionIcon, @@ -81,7 +82,9 @@ export const tabs = { { icon: ElementType<{ className?: string }>; label: string } >; -type MediaViewMode = "grid" | "list"; +export type MediaViewMode = "grid" | "list"; +export type MediaSortKey = "name" | "type" | "duration" | "size"; +export type MediaSortOrder = "asc" | "desc"; interface AssetsPanelStore { activeTab: Tab; @@ -93,15 +96,34 @@ interface AssetsPanelStore { /* Media */ mediaViewMode: MediaViewMode; setMediaViewMode: (mode: MediaViewMode) => void; + mediaSortBy: MediaSortKey; + mediaSortOrder: MediaSortOrder; + setMediaSort: (key: MediaSortKey, order: MediaSortOrder) => void; } -export const useAssetsPanelStore = create<AssetsPanelStore>((set) => ({ - activeTab: "media", - setActiveTab: (tab) => set({ activeTab: tab }), - highlightMediaId: null, - requestRevealMedia: (mediaId) => - set({ activeTab: "media", highlightMediaId: mediaId }), - clearHighlight: () => set({ highlightMediaId: null }), - mediaViewMode: "grid", - setMediaViewMode: (mode) => set({ mediaViewMode: mode }), -})); +export const useAssetsPanelStore = create<AssetsPanelStore>()( + persist( + (set) => ({ + activeTab: "media", + setActiveTab: (tab) => set({ activeTab: tab }), + highlightMediaId: null, + requestRevealMedia: (mediaId) => + set({ activeTab: "media", highlightMediaId: mediaId }), + clearHighlight: () => set({ highlightMediaId: null }), + mediaViewMode: "grid", + setMediaViewMode: (mode) => set({ mediaViewMode: mode }), + mediaSortBy: "name", + mediaSortOrder: "asc", + setMediaSort: (key, order) => + set({ mediaSortBy: key, mediaSortOrder: order }), + }), + { + name: "assets-panel", + partialize: (state) => ({ + mediaViewMode: state.mediaViewMode, + mediaSortBy: state.mediaSortBy, + mediaSortOrder: state.mediaSortOrder, + }), + }, + ), +); diff --git a/apps/web/src/stores/keybindings-store.ts b/apps/web/src/stores/keybindings-store.ts index fbbf169e..906e9aa8 100644 --- a/apps/web/src/stores/keybindings-store.ts +++ b/apps/web/src/stores/keybindings-store.ts @@ -39,8 +39,8 @@ interface KeybindingsState { getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null; } -function isDOMElement(el: EventTarget | null): el is HTMLElement { - return !!el && (el instanceof Element || el instanceof HTMLElement); +function isDOMElement(element: EventTarget | null): element is HTMLElement { + return element instanceof HTMLElement; } export const useKeybindingsStore = create<KeybindingsState>()( @@ -90,11 +90,9 @@ export const useKeybindingsStore = create<KeybindingsState>()( set({ keybindingsEnabled: false }); }, - importKeybindings: (config: KeybindingConfig) => { - // Validate all keys and actions - for (const [key] of Object.entries(config)) { - // Validate the key format - if (typeof key !== "string" || key.length === 0) { + importKeybindings: (config: KeybindingConfig) => { + for (const [key] of Object.entries(config)) { + if (typeof key !== "string" || key.length === 0) { throw new Error(`Invalid key format: ${key}`); } } @@ -153,20 +151,13 @@ export const useKeybindingsStore = create<KeybindingsState>()( ), ); -// Utility functions function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null { const target = ev.target; - - // We may or may not have a modifier key const modifierKey = getActiveModifier(ev); - - // We will always have a non-modifier key const key = getPressedKey(ev); if (!key) return null; - // All key combos backed by modifiers are valid shortcuts (whether currently typing or not) if (modifierKey) { - // If the modifier is shift and the target is an input, we ignore if ( modifierKey === "shift" && isDOMElement(target) && @@ -178,61 +169,44 @@ function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null { return `${modifierKey}+${key}` as ShortcutKey; } - // no modifier key here then we do not do anything while on input - if ( - isDOMElement(target) && - isTypableDOMElement({ element: target as HTMLElement }) - ) + if (isDOMElement(target) && isTypableDOMElement({ element: target as HTMLElement })) return null; - // single key while not input return `${key}` as ShortcutKey; } function getPressedKey(ev: KeyboardEvent): string | null { - // Sometimes the property code is not available on the KeyboardEvent object const key = (ev.key ?? "").toLowerCase(); const code = ev.code ?? ""; if (code === "Space" || key === " " || key === "spacebar" || key === "space") return "space"; - // Check arrow keys - if (key.startsWith("arrow")) { - return key.slice(5); - } + if (key.startsWith("arrow")) return key.slice(5); - // Check for special keys + if (key === "escape") return "escape"; if (key === "tab") return "tab"; if (key === "home") return "home"; if (key === "end") return "end"; if (key === "delete") return "delete"; if (key === "backspace") return "backspace"; - // Check letter keys if (code.startsWith("Key")) { const letter = code.slice(3).toLowerCase(); - if (letter.length === 1 && letter >= "a" && letter <= "z") { - return letter; - } + if (letter.length === 1 && letter >= "a" && letter <= "z") return letter; } - // Check number keys using physical position for AZERTY support + // Use physical key position for AZERTY and other non-QWERTY layouts if (code.startsWith("Digit")) { const digit = code.slice(5); - if (digit.length === 1 && digit >= "0" && digit <= "9") { - return digit; - } + if (digit.length === 1 && digit >= "0" && digit <= "9") return digit; } - // Fallback for other layouts const isDigit = key.length === 1 && key >= "0" && key <= "9"; if (isDigit) return key; - // Check if slash, period or enter if (key === "/" || key === "." || key === "enter") return key; - // If no other cases match, this is not a valid key return null; } @@ -243,8 +217,6 @@ function getActiveModifier(ev: KeyboardEvent): string | null { shift: ev.shiftKey, }; - // active modifier: ctrl | alt | ctrl+alt | ctrl+shift | ctrl+alt+shift | alt+shift - // modiferKeys object's keys are sorted to match the above order const activeModifier = Object.keys(modifierKeys) .filter((key) => modifierKeys[key as keyof typeof modifierKeys]) .join("+"); diff --git a/apps/web/src/stores/keybindings/migrations/index.ts b/apps/web/src/stores/keybindings/migrations/index.ts index 64d86ffa..e6641f41 100644 --- a/apps/web/src/stores/keybindings/migrations/index.ts +++ b/apps/web/src/stores/keybindings/migrations/index.ts @@ -1,18 +1,16 @@ import { v2ToV3 } from "./v2-to-v3"; import { v3ToV4 } from "./v3-to-v4"; +import { v4ToV5 } from "./v4-to-v5"; type MigrationFn = ({ state }: { state: unknown }) => unknown; -/** - * key = version we're migrating from - * value = migration function - */ const migrations: Record<number, MigrationFn> = { 2: v2ToV3, 3: v3ToV4, + 4: v4ToV5, }; -export const CURRENT_VERSION = 4; +export const CURRENT_VERSION = 5; export function runMigrations({ state, diff --git a/apps/web/src/stores/keybindings/migrations/v4-to-v5.ts b/apps/web/src/stores/keybindings/migrations/v4-to-v5.ts new file mode 100644 index 00000000..2ab39247 --- /dev/null +++ b/apps/web/src/stores/keybindings/migrations/v4-to-v5.ts @@ -0,0 +1,17 @@ +import type { KeybindingConfig } from "@/types/keybinding"; + +interface V4State { + keybindings: KeybindingConfig; + isCustomized: boolean; +} + +export function v4ToV5({ state }: { state: unknown }): unknown { + const v4 = state as V4State; + const keybindings = { ...v4.keybindings }; + + if (!keybindings.escape) { + keybindings.escape = "deselect-all"; + } + + return { ...v4, keybindings }; +} diff --git a/apps/web/src/stores/properties-store.ts b/apps/web/src/stores/properties-store.ts new file mode 100644 index 00000000..f45f176d --- /dev/null +++ b/apps/web/src/stores/properties-store.ts @@ -0,0 +1,19 @@ +import { create } from "zustand"; + +interface ClipEffectsTarget { + elementId: string; + trackId: string; +} + +interface PropertiesState { + clipEffectsTarget: ClipEffectsTarget | null; + openClipEffects: ({ elementId, trackId }: ClipEffectsTarget) => void; + closeClipEffects: () => void; +} + +export const usePropertiesStore = create<PropertiesState>()((set) => ({ + clipEffectsTarget: null, + openClipEffects: ({ elementId, trackId }) => + set({ clipEffectsTarget: { elementId, trackId } }), + closeClipEffects: () => set({ clipEffectsTarget: null }), +})); diff --git a/apps/web/src/types/animation.ts b/apps/web/src/types/animation.ts new file mode 100644 index 00000000..825894c5 --- /dev/null +++ b/apps/web/src/types/animation.ts @@ -0,0 +1,97 @@ +export const ANIMATION_PROPERTY_PATHS = [ + "transform.position.x", + "transform.position.y", + "transform.scale", + "transform.rotate", + "opacity", + "volume", + "color", + "background.color", + "background.paddingX", + "background.paddingY", + "background.offsetX", + "background.offsetY", + "background.cornerRadius", +] as const; + +export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number]; + +export type AnimationValueKind = "number" | "color" | "discrete"; +export type DiscreteValue = boolean | string; +export type AnimationValue = number | string | boolean; + +export type ContinuousKeyframeInterpolation = "linear" | "hold"; +export type DiscreteKeyframeInterpolation = "hold"; +export type AnimationInterpolation = + | ContinuousKeyframeInterpolation + | DiscreteKeyframeInterpolation; + +interface BaseAnimationKeyframe< + TValue extends AnimationValue, + TInterpolation extends AnimationInterpolation, +> { + id: string; + time: number; // relative to element start time + value: TValue; + interpolation: TInterpolation; +} + +export interface NumberKeyframe + extends BaseAnimationKeyframe<number, ContinuousKeyframeInterpolation> {} + +export interface ColorKeyframe + extends BaseAnimationKeyframe<string, ContinuousKeyframeInterpolation> {} + +export interface DiscreteKeyframe + extends BaseAnimationKeyframe<DiscreteValue, DiscreteKeyframeInterpolation> {} + +export type AnimationKeyframe = + | NumberKeyframe + | ColorKeyframe + | DiscreteKeyframe; + +interface BaseAnimationChannel< + TValueKind extends AnimationValueKind, + TKeyframe extends AnimationKeyframe, +> { + valueKind: TValueKind; + keyframes: TKeyframe[]; +} + +export interface NumberAnimationChannel + extends BaseAnimationChannel<"number", NumberKeyframe> {} + +export interface ColorAnimationChannel + extends BaseAnimationChannel<"color", ColorKeyframe> {} + +export interface DiscreteAnimationChannel + extends BaseAnimationChannel<"discrete", DiscreteKeyframe> {} + +export type AnimationChannel = + | NumberAnimationChannel + | ColorAnimationChannel + | DiscreteAnimationChannel; + +export type ElementAnimationChannelMap = Record< + string, + AnimationChannel | undefined +>; + +export interface ElementAnimations { + channels: ElementAnimationChannelMap; +} + +export interface ElementKeyframe { + propertyPath: AnimationPropertyPath; + id: string; + time: number; + value: AnimationValue; + interpolation: AnimationInterpolation; +} + +export interface SelectedKeyframeRef { + trackId: string; + elementId: string; + propertyPath: AnimationPropertyPath; + keyframeId: string; +} diff --git a/apps/web/src/types/drag.ts b/apps/web/src/types/drag.ts index 61aba9b5..4e10d767 100644 --- a/apps/web/src/types/drag.ts +++ b/apps/web/src/types/drag.ts @@ -1,3 +1,5 @@ +import type { VisualElement } from "./timeline"; + interface BaseDragData { id: string; name: string; @@ -6,6 +8,7 @@ interface BaseDragData { export interface MediaDragData extends BaseDragData { type: "media"; mediaType: "image" | "video" | "audio"; + targetElementTypes?: ("video" | "image")[]; } export interface TextDragData extends BaseDragData { @@ -18,4 +21,14 @@ export interface StickerDragData extends BaseDragData { stickerId: string; } -export type TimelineDragData = MediaDragData | TextDragData | StickerDragData; +export interface EffectDragData extends BaseDragData { + type: "effect"; + effectType: string; + targetElementTypes: VisualElement["type"][]; +} + +export type TimelineDragData = + | MediaDragData + | TextDragData + | StickerDragData + | EffectDragData; diff --git a/apps/web/src/types/effects.ts b/apps/web/src/types/effects.ts new file mode 100644 index 00000000..6c96dfb2 --- /dev/null +++ b/apps/web/src/types/effects.ts @@ -0,0 +1,69 @@ +export interface Effect { + id: string; + type: string; + params: EffectParamValues; + enabled: boolean; +} + +export type EffectParamType = "number" | "boolean" | "select" | "color"; + +export type EffectParamValues = Record<string, number | string | boolean>; + +interface BaseEffectParamDefinition { + key: string; + label: string; +} + +export interface NumberEffectParamDefinition extends BaseEffectParamDefinition { + type: "number"; + default: number; + min: number; + max: number; + step: number; +} + +interface BooleanEffectParamDefinition extends BaseEffectParamDefinition { + type: "boolean"; + default: boolean; +} + +interface SelectEffectParamDefinition extends BaseEffectParamDefinition { + type: "select"; + default: string; + options: Array<{ value: string; label: string }>; +} + +interface ColorEffectParamDefinition extends BaseEffectParamDefinition { + type: "color"; + default: string; +} + +export type EffectParamDefinition = + | NumberEffectParamDefinition + | BooleanEffectParamDefinition + | SelectEffectParamDefinition + | ColorEffectParamDefinition; + +export interface WebGLEffectPass { + fragmentShader: string; + uniforms(params: { + effectParams: EffectParamValues; + width: number; + height: number; + }): Record<string, number | number[]>; +} + +export interface WebGLEffectRenderer { + type: "webgl"; + passes: WebGLEffectPass[]; +} + +export type EffectRenderer = WebGLEffectRenderer; + +export interface EffectDefinition { + type: string; + name: string; + keywords: string[]; + params: EffectParamDefinition[]; + renderer: EffectRenderer; +} diff --git a/apps/web/src/types/export.ts b/apps/web/src/types/export.ts index 351f5fc4..2c2408d8 100644 --- a/apps/web/src/types/export.ts +++ b/apps/web/src/types/export.ts @@ -15,8 +15,6 @@ export interface ExportOptions { quality: ExportQuality; fps?: number; includeAudio?: boolean; - onProgress?: ({ progress }: { progress: number }) => void; - onCancel?: () => boolean; } export interface ExportResult { @@ -25,3 +23,9 @@ export interface ExportResult { error?: string; cancelled?: boolean; } + +export interface ExportState { + isExporting: boolean; + progress: number; + result: ExportResult | null; +} diff --git a/apps/web/src/types/glsl.d.ts b/apps/web/src/types/glsl.d.ts new file mode 100644 index 00000000..4e93dbe5 --- /dev/null +++ b/apps/web/src/types/glsl.d.ts @@ -0,0 +1,4 @@ +declare module "*.glsl" { + const value: string; + export default value; +} diff --git a/apps/web/src/types/timeline.ts b/apps/web/src/types/timeline.ts index 5a98bd31..15696b34 100644 --- a/apps/web/src/types/timeline.ts +++ b/apps/web/src/types/timeline.ts @@ -1,3 +1,5 @@ +import type { ElementAnimations } from "./animation"; +import type { Effect, EffectParamValues } from "./effects"; import type { BlendMode, Transform } from "./rendering"; export interface Bookmark { @@ -17,7 +19,7 @@ export interface TScene { updatedAt: Date; } -export type TrackType = "video" | "text" | "audio" | "sticker"; +export type TrackType = "video" | "text" | "audio" | "sticker" | "effect"; interface BaseTrack { id: string; @@ -50,7 +52,18 @@ export interface StickerTrack extends BaseTrack { hidden: boolean; } -export type TimelineTrack = VideoTrack | TextTrack | AudioTrack | StickerTrack; +export interface EffectTrack extends BaseTrack { + type: "effect"; + elements: EffectElement[]; + hidden: boolean; +} + +export type TimelineTrack = + | VideoTrack + | TextTrack + | AudioTrack + | StickerTrack + | EffectTrack; export type { Transform } from "./rendering"; @@ -80,6 +93,8 @@ interface BaseTimelineElement { startTime: number; trimStart: number; trimEnd: number; + sourceDuration?: number; + animations?: ElementAnimations; } export interface VideoElement extends BaseTimelineElement { @@ -90,6 +105,7 @@ export interface VideoElement extends BaseTimelineElement { transform: Transform; opacity: number; blendMode?: BlendMode; + effects?: Effect[]; } export interface ImageElement extends BaseTimelineElement { @@ -99,6 +115,17 @@ export interface ImageElement extends BaseTimelineElement { transform: Transform; opacity: number; blendMode?: BlendMode; + effects?: Effect[]; +} + +export interface TextBackground { + enabled: boolean; + color: string; + cornerRadius?: number; + paddingX?: number; + paddingY?: number; + offsetX?: number; + offsetY?: number; } export interface TextElement extends BaseTimelineElement { @@ -107,14 +134,7 @@ export interface TextElement extends BaseTimelineElement { fontSize: number; fontFamily: string; color: string; - background: { - color: string; - cornerRadius?: number; - paddingX?: number; - paddingY?: number; - offsetX?: number; - offsetY?: number; - }; + background: TextBackground; textAlign: "left" | "center" | "right"; fontWeight: "normal" | "bold"; fontStyle: "normal" | "italic"; @@ -125,6 +145,7 @@ export interface TextElement extends BaseTimelineElement { transform: Transform; opacity: number; blendMode?: BlendMode; + effects?: Effect[]; } export interface StickerElement extends BaseTimelineElement { @@ -134,14 +155,33 @@ export interface StickerElement extends BaseTimelineElement { transform: Transform; opacity: number; blendMode?: BlendMode; + effects?: Effect[]; +} + +export interface EffectElement extends BaseTimelineElement { + type: "effect"; + effectType: string; + params: EffectParamValues; } +export type VisualElement = + | VideoElement + | ImageElement + | TextElement + | StickerElement; + +export type ElementUpdatePatch = + | { transform: Transform } + | { opacity: number } + | { volume: number }; + export type TimelineElement = | AudioElement | VideoElement | ImageElement | TextElement - | StickerElement; + | StickerElement + | EffectElement; export type ElementType = TimelineElement["type"]; @@ -154,12 +194,14 @@ export type CreateVideoElement = Omit<VideoElement, "id">; export type CreateImageElement = Omit<ImageElement, "id">; export type CreateTextElement = Omit<TextElement, "id">; export type CreateStickerElement = Omit<StickerElement, "id">; +export type CreateEffectElement = Omit<EffectElement, "id">; export type CreateTimelineElement = | CreateAudioElement | CreateVideoElement | CreateImageElement | CreateTextElement - | CreateStickerElement; + | CreateStickerElement + | CreateEffectElement; export interface ElementDragState { isDragging: boolean; @@ -178,6 +220,7 @@ export interface DropTarget { isNewTrack: boolean; insertPosition: "above" | "below" | null; xPosition: number; + targetElement: { elementId: string; trackId: string } | null; } export interface ComputeDropTargetParams { @@ -193,6 +236,7 @@ export interface ComputeDropTargetParams { verticalDragDirection?: "up" | "down" | null; startTimeOverride?: number; excludeElementId?: string; + targetElementTypes?: string[]; } export interface ClipboardItem { diff --git a/apps/web/src/utils/math.ts b/apps/web/src/utils/math.ts index ac82ca17..d45e71c0 100644 --- a/apps/web/src/utils/math.ts +++ b/apps/web/src/utils/math.ts @@ -10,6 +10,18 @@ export function clamp({ return Math.max(min, Math.min(max, value)); } +export function isNearlyEqual({ + leftValue, + rightValue, + epsilon = 0.0001, +}: { + leftValue: number; + rightValue: number; + epsilon?: number; +}): boolean { + return Math.abs(leftValue - rightValue) <= epsilon; +} + export function evaluateMathExpression({ input, }: { |
