diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-03-01 01:32:09 +0100 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-03-01 01:32:09 +0100 |
| commit | 5e7c27f542e94ffee5d2106ce318b6f43cc7e738 (patch) | |
| tree | b33c3370c01b9b2ee685f1039543bc02f8764f7b /apps | |
| parent | 744419469da403be57d30b5b19f6b43246caa57d (diff) | |
| parent | 354f4dec67ef7eb39d34fca941ca34025a68136b (diff) | |
| download | opencut-5e7c27f542e94ffee5d2106ce318b6f43cc7e738.tar.gz | |
Merge branch 'dev' into ripple-editing
Diffstat (limited to 'apps')
127 files changed, 8000 insertions, 1359 deletions
diff --git a/apps/web/.env.example b/apps/web/.env.example index b321f09a..e1943571 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -13,7 +13,7 @@ DATABASE_URL="postgresql://opencut:opencut@localhost:5432/opencut" BETTER_AUTH_SECRET=your_better_auth_secret UPSTASH_REDIS_REST_URL=http://localhost:8079 -UPSTASH_REDIS_REST_TOKEN=example_token_here +UPSTASH_REDIS_REST_TOKEN=example_token MARBLE_WORKSPACE_KEY=your_workspace_key_here diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 00000000..9f2dfa2c --- /dev/null +++ b/apps/web/Dockerfile @@ -0,0 +1,70 @@ +FROM oven/bun:alpine AS base + +FROM base AS builder + +WORKDIR /app + +ARG FREESOUND_CLIENT_ID +ARG FREESOUND_API_KEY + +COPY package.json package.json +COPY bun.lock bun.lock +COPY turbo.json turbo.json + +COPY apps/web/package.json apps/web/package.json +COPY packages/env/package.json packages/env/package.json +COPY packages/ui/package.json packages/ui/package.json + +RUN bun install + +COPY apps/web/ apps/web/ +COPY packages/env/ packages/env/ +COPY packages/ui/ packages/ui/ + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +# Build-time env stubs to pass zod validation +ENV DATABASE_URL="postgresql://opencut:opencut@localhost:5432/opencut" +ENV BETTER_AUTH_SECRET="build-time-secret" +ENV UPSTASH_REDIS_REST_URL="http://localhost:8079" +ENV UPSTASH_REDIS_REST_TOKEN="example_token" +ENV NEXT_PUBLIC_SITE_URL="http://localhost:3000" +ENV NEXT_PUBLIC_MARBLE_API_URL="https://api.marblecms.com" +ENV MARBLE_WORKSPACE_KEY="build-placeholder" +ENV CLOUDFLARE_ACCOUNT_ID="build-placeholder" +ENV R2_ACCESS_KEY_ID="build-placeholder" +ENV R2_SECRET_ACCESS_KEY="build-placeholder" +ENV R2_BUCKET_NAME="build-placeholder" +ENV MODAL_TRANSCRIPTION_URL="http://localhost:0" + +ENV FREESOUND_CLIENT_ID=$FREESOUND_CLIENT_ID +ENV FREESOUND_API_KEY=$FREESOUND_API_KEY + +WORKDIR /app/apps/web +RUN bun run build + +# Production image +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public +COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static + +RUN chown nextjs:nodejs apps + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +CMD ["bun", "apps/web/server.js"] diff --git a/apps/web/content-collections.ts b/apps/web/content-collections.ts new file mode 100644 index 00000000..b3028846 --- /dev/null +++ b/apps/web/content-collections.ts @@ -0,0 +1,32 @@ +import { defineCollection, defineConfig } from "@content-collections/core"; +import { z } from "zod"; + +const changelog = defineCollection({ + name: "changelog", + directory: "content/changelog", + include: "*.md", + schema: z.object({ + version: z.string(), + date: z.string(), + title: z.string(), + description: z.string().optional(), + changes: z.array( + z.object({ + type: z.string(), + text: z.string(), + }), + ), + }), + transform: async (doc, { collection }) => { + const allDocs = await collection.documents(); + const sorted = [...allDocs].sort((a, b) => + b.version.localeCompare(a.version, undefined, { numeric: true }), + ); + const isLatest = sorted[0]?.version === doc.version; + return { ...doc, isLatest }; + }, +}); + +export default defineConfig({ + content: [changelog], +}); diff --git a/apps/web/content/changelog/0.1.0.md b/apps/web/content/changelog/0.1.0.md new file mode 100644 index 00000000..08a0a9ed --- /dev/null +++ b/apps/web/content/changelog/0.1.0.md @@ -0,0 +1,35 @@ +--- +version: "0.1.0" +date: "2026-02-23" +title: "Editor foundation" +description: "This first release focuses on making editing faster, clearer, and more reliable for day-to-day use." +changes: + - type: new + text: "Rebuilt the properties panel from scratch. Number inputs now support math expressions and click-drag scrubbing instead of just typing values in." + - type: new + text: "The properties panel went from a flat list of basic text styles to organized sections: transform, blending, typography, spacing, and background controls. Text elements finally have position, scale, and rotation." + - type: new + text: "New color picker with eyedropper, opacity control, and multiple color formats." + - type: new + text: "Bookmarks now support notes, color labels, and optional duration to plan scenes more clearly." + - type: new + text: "Move, scale, and rotate elements directly in the preview panel." + - type: new + text: "Blend modes (Multiply, Screen, Overlay, etc). Only available on text elements for now." + - type: new + text: "Paste images, videos, or audio from your clipboard straight into the editor." + - type: new + text: "Hold Shift while moving or resizing to temporarily turn off snapping." + - type: improved + text: "Expanded font selection from 7 to over 1,000." + - type: improved + text: "Fonts load much faster." + - type: improved + text: "All asset panel tabs now share a consistent layout." + - type: improved + text: "Text rendering properly supports multiple lines, line height, and letter spacing." + - type: improved + text: "Better contrast in the dark theme." + - type: fixed + text: "Fixed undo/redo for drag-and-drop so dropped items are reliably undoable." +--- diff --git a/apps/web/content/changelog/0.2.0.md b/apps/web/content/changelog/0.2.0.md new file mode 100644 index 00000000..ea69f78b --- /dev/null +++ b/apps/web/content/changelog/0.2.0.md @@ -0,0 +1,15 @@ +--- +version: "0.2.0" +date: "2026-02-29" +title: "Motion & effects" +description: "This release adds the foundation for motion and effects." +changes: + - type: new + text: "Keyframe animation system, starting with transform properties (position, scale, rotation)." + - type: new + text: "Effects system (with our first effect: Blur!)" + - type: fixed + text: "Fixed an issue where click-and-drag selection on one timeline track could accidentally select items from the track below." + - type: fixed + text: "Audio could flicker or cut out at the start of playback when certain elements were selected. The scheduler now recovers from timing slips without losing audio." +--- diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index fa1ac7f5..2f509d2a 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,7 +1,16 @@ import type { NextConfig } from "next"; import { withBotId } from "botid/next/config"; +import { withContentCollections } from "@content-collections/next"; const nextConfig: NextConfig = { + turbopack: { + rules: { + "*.glsl": { + loaders: [require.resolve("raw-loader")], + as: "*.js", + }, + }, + }, compiler: { removeConsole: process.env.NODE_ENV === "production", }, @@ -46,4 +55,4 @@ const nextConfig: NextConfig = { }, }; -export default withBotId(nextConfig); +export default withContentCollections(withBotId(nextConfig)); diff --git a/apps/web/package.json b/apps/web/package.json index 9a57a379..8094c548 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,107 +1,110 @@ { - "name": "@opencut/web", - "version": "0.1.0", - "private": true, - "packageManager": "bun@1.2.18", - "scripts": { - "dev": "next dev --turbopack", - "build": "next build", - "start": "next start", - "lint": "biome check src/", - "lint:fix": "biome check src/ --write", - "format": "biome format src/ --write", - "db:generate": "drizzle-kit generate", - "db:migrate": "drizzle-kit migrate", - "db:push:local": "cross-env NODE_ENV=development drizzle-kit push", - "db:push:prod": "cross-env NODE_ENV=production drizzle-kit push" - }, - "dependencies": { - "@ffmpeg/core": "^0.12.10", - "@ffmpeg/ffmpeg": "^0.12.15", - "@ffmpeg/util": "^0.12.2", - "@hello-pangea/dnd": "^18.0.1", - "@hookform/resolvers": "^3.9.1", - "@hugeicons/core-free-icons": "^3.1.1", - "@hugeicons/react": "^1.1.4", - "@huggingface/transformers": "^3.8.1", - "@opencut/env": "workspace:*", - "@opencut/ui": "workspace:*", - "@radix-ui/react-accordion": "^1.2.12", - "@radix-ui/react-checkbox": "^1.3.3", - "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-dropdown-menu": "^2.1.16", - "@radix-ui/react-primitive": "^2.1.4", - "@radix-ui/react-select": "^2.2.6", - "@radix-ui/react-separator": "^1.1.8", - "@radix-ui/react-slot": "^1.2.4", - "@radix-ui/react-tooltip": "^1.2.8", - "@types/culori": "^4.0.1", - "@upstash/ratelimit": "^2.0.6", - "@upstash/redis": "^1.35.4", - "@vercel/analytics": "^1.4.1", - "aws4fetch": "^1.0.20", - "better-auth": "^1.2.7", - "botid": "^1.4.2", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "cmdk": "^1.0.0", - "culori": "^4.0.2", - "dayjs": "^1.11.13", - "drizzle-orm": "^0.44.2", - "embla-carousel-react": "^8.5.1", - "eventemitter3": "^5.0.1", - "feed": "^5.1.0", - "input-otp": "^1.4.1", - "lucide-react": "^0.562.0", - "mediabunny": "^1.29.1", - "motion": "^12.18.1", - "nanoid": "^5.1.5", - "next": "16.1.3", - "next-themes": "^0.4.4", - "pg": "^8.16.2", - "postgres": "^3.4.5", - "radix-ui": "^1.4.3", - "react": "^19.0.0", - "react-day-picker": "^8.10.1", - "react-dom": "^19.0.0", - "react-hook-form": "^7.54.0", - "react-icons": "^5.4.0", - "react-markdown": "^10.1.0", - "react-phone-number-input": "^3.4.11", - "react-resizable-panels": "^2.1.7", - "react-window": "^2.2.7", - "recharts": "^2.14.1", - "rehype-autolink-headings": "^7.1.0", - "rehype-parse": "^9.0.1", - "rehype-sanitize": "^6.0.0", - "rehype-slug": "^6.0.0", - "rehype-stringify": "^10.0.1", - "sonner": "^1.7.1", - "tailwind-merge": "^2.5.5", - "tailwindcss-animate": "^1.0.7", - "unified": "^11.0.5", - "use-deep-compare-effect": "^1.8.1", - "vaul": "^1.1.2", - "wavesurfer.js": "^7.9.8", - "zod": "^3.25.67", - "zustand": "^5.0.2" - }, - "devDependencies": { - "@napi-rs/canvas": "^0.1.92", - "@tailwindcss/postcss": "^4.1.11", - "@tailwindcss/typography": "^0.5.16", - "@types/bun": "latest", - "@types/node": "^24.2.1", - "@types/pg": "^8.15.4", - "@types/react": "^19", - "@types/react-dom": "^19", - "cross-env": "^7.0.3", - "dotenv": "^16.5.0", - "drizzle-kit": "^0.31.4", - "postcss": "^8", - "sharp": "^0.34.5", - "tailwindcss": "^4.1.11", - "tsx": "^4.7.1", - "typescript": "^5.8.3" - } + "name": "@opencut/web", + "version": "0.1.0", + "private": true, + "packageManager": "bun@1.2.18", + "scripts": { + "dev": "next dev --turbopack", + "build": "next build", + "start": "next start", + "lint": "biome check src/", + "lint:fix": "biome check src/ --write", + "format": "biome format src/ --write", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:push:local": "cross-env NODE_ENV=development drizzle-kit push", + "db:push:prod": "cross-env NODE_ENV=production drizzle-kit push" + }, + "dependencies": { + "@ffmpeg/core": "^0.12.10", + "@ffmpeg/ffmpeg": "^0.12.15", + "@ffmpeg/util": "^0.12.2", + "@hello-pangea/dnd": "^18.0.1", + "@hookform/resolvers": "^3.9.1", + "@hugeicons/core-free-icons": "^3.1.1", + "@hugeicons/react": "^1.1.4", + "@huggingface/transformers": "^3.8.1", + "@opencut/env": "workspace:*", + "@opencut/ui": "workspace:*", + "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-primitive": "^2.1.4", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tooltip": "^1.2.8", + "@types/culori": "^4.0.1", + "@upstash/ratelimit": "^2.0.6", + "@upstash/redis": "^1.35.4", + "@vercel/analytics": "^1.4.1", + "aws4fetch": "^1.0.20", + "better-auth": "^1.2.7", + "botid": "^1.4.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.0.0", + "culori": "^4.0.2", + "dayjs": "^1.11.13", + "drizzle-orm": "^0.44.2", + "embla-carousel-react": "^8.5.1", + "eventemitter3": "^5.0.1", + "feed": "^5.1.0", + "input-otp": "^1.4.1", + "lucide-react": "^0.562.0", + "mediabunny": "^1.29.1", + "motion": "^12.18.1", + "nanoid": "^5.1.5", + "next": "16.1.3", + "next-themes": "^0.4.4", + "pg": "^8.16.2", + "postgres": "^3.4.5", + "radix-ui": "^1.4.3", + "react": "^19.0.0", + "react-day-picker": "^8.10.1", + "react-dom": "^19.0.0", + "react-hook-form": "^7.54.0", + "react-icons": "^5.4.0", + "react-markdown": "^10.1.0", + "react-phone-number-input": "^3.4.11", + "react-resizable-panels": "^2.1.7", + "react-window": "^2.2.7", + "recharts": "^2.14.1", + "rehype-autolink-headings": "^7.1.0", + "rehype-parse": "^9.0.1", + "rehype-sanitize": "^6.0.0", + "rehype-slug": "^6.0.0", + "rehype-stringify": "^10.0.1", + "sonner": "^1.7.1", + "tailwind-merge": "^2.5.5", + "tailwindcss-animate": "^1.0.7", + "unified": "^11.0.5", + "use-deep-compare-effect": "^1.8.1", + "vaul": "^1.1.2", + "wavesurfer.js": "^7.9.8", + "zod": "^3.25.67", + "zustand": "^5.0.2" + }, + "devDependencies": { + "@content-collections/core": "^0.14.1", + "@content-collections/next": "^0.2.11", + "@napi-rs/canvas": "^0.1.92", + "@tailwindcss/postcss": "^4.1.11", + "@tailwindcss/typography": "^0.5.16", + "@types/bun": "latest", + "@types/node": "^24.2.1", + "@types/pg": "^8.15.4", + "@types/react": "^19", + "@types/react-dom": "^19", + "cross-env": "^7.0.3", + "dotenv": "^16.5.0", + "drizzle-kit": "^0.31.4", + "postcss": "^8", + "raw-loader": "^4.0.2", + "sharp": "^0.34.5", + "tailwindcss": "^4.1.11", + "tsx": "^4.7.1", + "typescript": "^5.8.3" + } } diff --git a/apps/web/public/effects/preview.jpg b/apps/web/public/effects/preview.jpg Binary files differnew file mode 100644 index 00000000..6519189c --- /dev/null +++ b/apps/web/public/effects/preview.jpg diff --git a/apps/web/src/app/changelog/page.tsx b/apps/web/src/app/changelog/page.tsx new file mode 100644 index 00000000..a9072485 --- /dev/null +++ b/apps/web/src/app/changelog/page.tsx @@ -0,0 +1,142 @@ +import type { Metadata } from "next"; +import { BasePage } from "@/app/base-page"; +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/utils/ui"; +import { allChangelogs } from "content-collections"; + +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", + }, +}; + +const knownSectionOrder = ["new", "improved", "fixed", "breaking"]; + +const knownSectionTitles: Record<string, string> = { + new: "Features", + improved: "Improvements", + fixed: "Fixes", + breaking: "Breaking Changes", +}; + +function getSectionTitle(type: string): string { + return ( + knownSectionTitles[type] ?? type.charAt(0).toUpperCase() + type.slice(1) + ); +} + +export default function ChangelogPage() { + const releases = [...allChangelogs].sort((a, b) => + b.version.localeCompare(a.version, undefined, { numeric: true }), + ); + + 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 && ( + // ml-1.5 aligns with the center of the 11px timeline dot + <Separator className="my-10 sm:ml-1.5" /> + )} + </div> + ))} + </div> + </div> + </div> + </BasePage> + ); +} + +type Change = { type: string; text: string }; +type Release = (typeof allChangelogs)[number]; + +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 }; +} + +function ReleaseEntry({ release }: { release: Release }) { + const { grouped: groupedChanges, orderedTypes } = groupAndOrderChanges({ + changes: release.changes, + }); + + 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]", + release.isLatest + ? "border-foreground bg-foreground" + : "border-muted-foreground/30 bg-background", + )} + /> + </div> + + <div className="flex flex-col gap-5"> + <div className="flex items-center gap-2.5 flex-wrap"> + <span className="text-sm font-medium tracking-widest text-muted-foreground"> + {release.version} - {release.date} + </span> + </div> + + <div className="flex flex-col gap-4"> + <h2 className="text-2xl font-bold tracking-tight">{release.title}</h2> + {release.description && ( + <p className="text-base text-foreground leading-relaxed max-w-xl"> + {release.description} + </p> + )} + </div> + + <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"> + {groupedChanges[type].map((change) => ( + <li + key={change.text} + className="text-base text-foreground leading-relaxed" + > + {change.text} + </li> + ))} + </ul> + </div> + ))} + </div> + </div> + </article> + ); +} diff --git a/apps/web/src/app/privacy/page.tsx b/apps/web/src/app/privacy/page.tsx index b71b8d30..4b06405a 100644 --- a/apps/web/src/app/privacy/page.tsx +++ b/apps/web/src/app/privacy/page.tsx @@ -166,7 +166,7 @@ export default function PrivacyPage() { <a href="https://www.databuddy.cc" target="_blank" - rel="noopener" + rel="noopener noreferrer" className="text-primary hover:underline" > Databuddy diff --git a/apps/web/src/app/roadmap/page.tsx b/apps/web/src/app/roadmap/page.tsx index 5227f808..77d9a0cc 100644 --- a/apps/web/src/app/roadmap/page.tsx +++ b/apps/web/src/app/roadmap/page.tsx @@ -5,6 +5,8 @@ import { Badge } from "@/components/ui/badge"; import { ReactMarkdownWrapper } from "@/components/ui/react-markdown-wrapper"; import { cn } from "@/utils/ui"; +const LAST_UPDATED = "February 25, 2026"; + type StatusType = "complete" | "pending" | "default" | "info"; interface Status { @@ -47,9 +49,9 @@ const roadmapItems: RoadmapItem[] = [ }, }, { - title: "Badge (potentially)", + title: "Native app (mobile/desktop)", description: - 'An "Edit with OpenCut" badge web apps can integrate. Shows on video players.', + "Native OpenCut apps for Mac, Windows, Linux, and iOS/Android.", status: { text: "Not started", type: "default", @@ -88,7 +90,7 @@ export default function RoadmapPage() { return ( <BasePage title="Roadmap" - description="What's coming next for OpenCut (last updated: July 14, 2025)" + description={`What's coming next for OpenCut (last updated: ${LAST_UPDATED})`} > <div className="mx-auto flex max-w-4xl flex-col gap-16"> <div className="flex flex-col gap-6"> 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..4c6922ff 100644 --- a/apps/web/src/components/editor/panels/assets/views/assets.tsx +++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx @@ -426,6 +426,9 @@ function GridView({ type: "media", mediaType: item.type, name: item.name, + ...(item.type !== "audio" && { + targetElementTypes: ["video", "image"] as const, + }), }} shouldShowPlusOnDrag={false} onAddToTimeline={({ currentTime }) => @@ -476,6 +479,9 @@ function ListView({ type: "media", mediaType: item.type, name: item.name, + ...(item.type !== "audio" && { + targetElementTypes: ["video", "image"] as const, + }), }} shouldShowPlusOnDrag={false} onAddToTimeline={({ currentTime }) => 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/preview/index.tsx b/apps/web/src/components/editor/panels/preview/index.tsx index 90cb11a3..df60db01 100644 --- a/apps/web/src/components/editor/panels/preview/index.tsx +++ b/apps/web/src/components/editor/panels/preview/index.tsx @@ -69,6 +69,7 @@ function RenderTreeController() { duration, canvasSize: { width, height }, background: activeProject.settings.background, + isPreview: true, }); editor.renderer.setRenderTree({ renderTree }); 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 265e972d..fdd4055d 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 @@ -99,9 +99,9 @@ export function TextEditOverlay({ const fontStyle = element.fontStyle === "italic" ? "italic" : "normal"; const letterSpacing = element.letterSpacing ?? 0; const backgroundColor = - element.backgroundColor === "transparent" + element.background.color === "transparent" ? "transparent" - : element.backgroundColor; + : element.background.color; return ( <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..bf137291 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/effect-properties.tsx @@ -0,0 +1,103 @@ +"use client"; + +import type { EffectElement } from "@/types/timeline"; +import type { EffectParamDefinition } from "@/types/effects"; +import { getEffect } from "@/lib/effects/registry"; +import { useEditor } from "@/hooks/use-editor"; +import { clamp } from "@/utils/math"; +import { Section, SectionContent, SectionHeader, SectionField, SectionFields } from "./section"; +import { Slider } from "@/components/ui/slider"; +import { NumberField } from "@/components/ui/number-field"; +import { usePropertyDraft } from "./hooks/use-property-draft"; + +function EffectParamField({ + param, + element, + trackId, +}: { + param: EffectParamDefinition; + element: EffectElement; + trackId: string; +}) { + const editor = useEditor(); + + const currentValue = Number(element.params[param.key] ?? param.default); + const min = param.min ?? 0; + const max = param.max ?? 100; + const step = param.step ?? 1; + + const updateParam = (value: number) => + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { params: { ...element.params, [param.key]: value } }, + }, + ], + }); + + const commitParam = () => editor.timeline.commitPreview(); + + const draft = usePropertyDraft({ + displayValue: String(currentValue), + parse: (input) => { + const parsed = parseFloat(input); + if (Number.isNaN(parsed)) return null; + return clamp({ value: parsed, min, max }); + }, + onPreview: updateParam, + onCommit: commitParam, + }); + + return ( + <SectionField label={param.label}> + <div className="flex items-center gap-3"> + <Slider + className="flex-1" + min={min} + max={max} + step={step} + value={[currentValue]} + onValueChange={([value]) => updateParam(value)} + onValueCommit={commitParam} + /> + <NumberField + className="w-16 shrink-0" + value={draft.displayValue} + onFocus={draft.onFocus} + onChange={draft.onChange} + onBlur={draft.onBlur} + /> + </div> + </SectionField> + ); +} + +export function EffectProperties({ + element, + trackId, +}: { + element: EffectElement; + trackId: string; +}) { + const definition = getEffect({ effectType: element.effectType }); + + return ( + <Section hasBorderTop={false}> + <SectionHeader title={definition.name} /> + <SectionContent> + <SectionFields> + {definition.params.map((param) => ( + <EffectParamField + key={param.key} + param={param} + element={element} + trackId={trackId} + /> + ))} + </SectionFields> + </SectionContent> + </Section> + ); +} diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts new file mode 100644 index 00000000..cd62ca75 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts @@ -0,0 +1,151 @@ +import { useEditor } from "@/hooks/use-editor"; +import { + getKeyframeAtTime, + hasKeyframesForPath, + upsertElementKeyframe, +} from "@/lib/animation"; +import type { AnimationPropertyPath, ElementAnimations } from "@/types/animation"; +import type { ElementUpdatePatch } from "@/types/timeline"; +import { usePropertyDraft } from "./use-property-draft"; + +export function useKeyframedNumberProperty({ + trackId, + elementId, + animations, + propertyPath, + localTime, + isPlayheadWithinElementRange, + displayValue, + parse, + valueAtPlayhead, + buildBaseUpdates, +}: { + trackId: string; + elementId: string; + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; + localTime: number; + isPlayheadWithinElementRange: boolean; + displayValue: string; + parse: (input: string) => number | null; + valueAtPlayhead: number; + buildBaseUpdates: ({ value }: { value: number }) => ElementUpdatePatch; +}) { + const editor = useEditor(); + + const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath }); + const keyframeAtTime = isPlayheadWithinElementRange + ? getKeyframeAtTime({ animations, propertyPath, time: localTime }) + : null; + const keyframeIdAtTime = keyframeAtTime?.id ?? null; + const isKeyframedAtTime = keyframeAtTime !== null; + const shouldUseAnimatedChannel = + hasAnimatedKeyframes && isPlayheadWithinElementRange; + + const previewValue = ({ value }: { value: number }) => { + if (shouldUseAnimatedChannel) { + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: { + animations: upsertElementKeyframe({ + animations, + propertyPath, + time: localTime, + value, + }), + }, + }, + ], + }); + return; + } + + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: buildBaseUpdates({ value }), + }, + ], + }); + }; + + const propertyDraft = usePropertyDraft({ + displayValue, + parse, + onPreview: (value) => previewValue({ value }), + onCommit: () => editor.timeline.commitPreview(), + }); + + const toggleKeyframe = () => { + if (!isPlayheadWithinElementRange) { + return; + } + + if (keyframeIdAtTime) { + editor.timeline.removeKeyframes({ + keyframes: [ + { + trackId, + elementId, + propertyPath, + keyframeId: keyframeIdAtTime, + }, + ], + }); + return; + } + + editor.timeline.upsertKeyframes({ + keyframes: [ + { + trackId, + elementId, + propertyPath, + time: localTime, + value: valueAtPlayhead, + }, + ], + }); + }; + + const commitValue = ({ value }: { value: number }) => { + if (shouldUseAnimatedChannel) { + editor.timeline.upsertKeyframes({ + keyframes: [ + { + trackId, + elementId, + propertyPath, + time: localTime, + value, + }, + ], + }); + return; + } + + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId, + updates: buildBaseUpdates({ value }), + }, + ], + }); + }; + + return { + ...propertyDraft, + hasAnimatedKeyframes, + isKeyframedAtTime, + keyframeIdAtTime, + toggleKeyframe, + commitValue, + }; +} diff --git a/apps/web/src/components/editor/panels/properties/index.tsx b/apps/web/src/components/editor/panels/properties/index.tsx index 05ad5396..75dd81a9 100644 --- a/apps/web/src/components/editor/panels/properties/index.tsx +++ b/apps/web/src/components/editor/panels/properties/index.tsx @@ -4,9 +4,37 @@ 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 { EmptyView } from "./empty-view"; import { useEditor } from "@/hooks/use-editor"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; +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 _element={element} />; + } + 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(); @@ -16,30 +44,19 @@ export function PropertiesPanel() { elements: selectedElements, }); + const hasSelection = selectedElements.length > 0; + return ( <div className="panel bg-background h-full rounded-sm border overflow-hidden"> - {selectedElements.length > 0 ? ( - <ScrollArea className="h-full"> - {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") { - return ( - <div key={element.id}> - <VideoProperties _element={element} /> - </div> - ); - } - return null; - })} + {hasSelection ? ( + <ScrollArea className="h-full scrollbar-hidden"> + {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 0b45065f..3edd17b5 100644 --- a/apps/web/src/components/editor/panels/properties/section.tsx +++ b/apps/web/src/components/editor/panels/properties/section.tsx @@ -2,6 +2,7 @@ import { createContext, useContext, 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"; const sectionExpandedCache = new Map<string, boolean>(); @@ -131,6 +132,40 @@ export function SectionHeader({ return <div className={baseClassName}>{content}</div>; } +export function SectionFields({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + 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)}> + <div className="flex items-center gap-1.5"> + {beforeLabel} + <Label>{label}</Label> + </div> + {children} + </div> + ); +} + export function SectionContent({ children, className, 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 430efa25..4343c692 100644 --- a/apps/web/src/components/editor/panels/properties/sections/blending.tsx +++ b/apps/web/src/components/editor/panels/properties/sections/blending.tsx @@ -8,7 +8,7 @@ import { } from "@/constants/timeline-constants"; import { OcCheckerboardIcon } from "@opencut/ui/icons"; import { Fragment, useRef } from "react"; -import { Section, SectionContent, SectionHeader } from "../section"; +import { Section, SectionContent, SectionField, SectionHeader } from "../section"; import { Select, SelectContent, @@ -124,73 +124,73 @@ export function BlendingSection({ return ( <Section collapsible sectionKey={`${element.type}:blending`}> <SectionHeader title="Blending" /> - <SectionContent> - <div className="flex items-start gap-2"> - <div className="w-1/2 space-y-1.5"> - <NumberField + <SectionContent> + <div className="flex items-start gap-2"> + <SectionField label="Opacity" className="w-1/2"> + <NumberField + className="w-full" + icon={ + <OcCheckerboardIcon className="size-3.5 text-muted-foreground" /> + } + value={opacity.displayValue} + min={0} + max={100} + onFocus={opacity.onFocus} + onChange={opacity.onChange} + onBlur={opacity.onBlur} + onScrub={opacity.scrubTo} + onScrubEnd={opacity.commitScrub} + onReset={() => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { opacity: DEFAULT_OPACITY }, + }, + ], + }) + } + isDefault={element.opacity === DEFAULT_OPACITY} + dragSensitivity="slow" + /> + </SectionField> + <SectionField label="Blend mode" className="w-1/2"> + <Select + value={committedBlendModeRef.current} + onOpenChange={handleBlendModeOpenChange} + onValueChange={commitBlendMode} + > + <SelectTrigger + icon={<HugeiconsIcon icon={RainDropIcon} />} className="w-full" - icon={ - <OcCheckerboardIcon className="size-3.5 text-muted-foreground" /> - } - value={opacity.displayValue} - min={0} - max={100} - onFocus={opacity.onFocus} - onChange={opacity.onChange} - onBlur={opacity.onBlur} - onScrub={opacity.scrubTo} - onScrubEnd={opacity.commitScrub} - onReset={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { opacity: DEFAULT_OPACITY }, - }, - ], - }) - } - isDefault={element.opacity === DEFAULT_OPACITY} - dragSensitivity="slow" - /> - </div> - <div className="w-1/2 space-y-1.5"> - <Select - value={committedBlendModeRef.current} - onOpenChange={handleBlendModeOpenChange} - onValueChange={commitBlendMode} > - <SelectTrigger - icon={<HugeiconsIcon icon={RainDropIcon} />} - className="w-full" - > - <SelectValue placeholder="Select blend mode" /> - </SelectTrigger> - <SelectContent className="w-36"> - {BLEND_MODE_GROUPS.map((group, groupIndex) => ( - <Fragment key={group[0]?.value ?? `group-${groupIndex}`}> - {group.map((option) => ( - <SelectItem - key={option.value} - value={option.value} - onPointerEnter={() => - previewBlendMode(option.value as BlendMode) - } - > - {option.label} - </SelectItem> - ))} - {groupIndex < BLEND_MODE_GROUPS.length - 1 ? ( - <SelectSeparator /> - ) : null} - </Fragment> - ))} - </SelectContent> - </Select> - </div> - </div> - </SectionContent> + <SelectValue placeholder="Select blend mode" /> + </SelectTrigger> + <SelectContent className="w-36"> + {BLEND_MODE_GROUPS.map((group, groupIndex) => ( + <Fragment key={group[0]?.value ?? `group-${groupIndex}`}> + {group.map((option) => ( + <SelectItem + key={option.value} + value={option.value} + onPointerEnter={() => + previewBlendMode(option.value as BlendMode) + } + > + {option.label} + </SelectItem> + ))} + {groupIndex < BLEND_MODE_GROUPS.length - 1 ? ( + <SelectSeparator /> + ) : null} + </Fragment> + ))} + </SelectContent> + </Select> + </SectionField> + </div> + </SectionContent> </Section> ); } 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 17a54b4c..3fdb47dc 100644 --- a/apps/web/src/components/editor/panels/properties/sections/transform.tsx +++ b/apps/web/src/components/editor/panels/properties/sections/transform.tsx @@ -1,9 +1,15 @@ import { NumberField } from "@/components/ui/number-field"; import { useEditor } from "@/hooks/use-editor"; -import { usePropertyDraft } from "../hooks/use-property-draft"; -import { clamp } from "@/utils/math"; -import type { ElementType, Transform } from "@/types/timeline"; -import { Section, SectionContent, SectionHeader } from "../section"; +import { clamp, isNearlyEqual } from "@/utils/math"; +import type { AnimationPropertyPath } from "@/types/animation"; +import type { VisualElement } from "@/types/timeline"; +import { + Section, + SectionContent, + SectionField, + SectionFields, + SectionHeader, +} from "../section"; import { Button } from "@/components/ui/button"; import { HugeiconsIcon } from "@hugeicons/react"; import { @@ -13,71 +19,125 @@ import { } from "@hugeicons/core-free-icons"; import { useState } from "react"; import { DEFAULT_TRANSFORM } from "@/constants/timeline-constants"; +import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; +import { getElementLocalTime, resolveTransformAtTime } from "@/lib/animation"; +import { KeyframeToggle } from "../keyframe-toggle"; +import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property"; -type TransformElement = { - id: string; - transform: Transform; - type: ElementType; -}; - -function parseFloat_({ input }: { input: string }): number | null { +function parseNumericInput({ input }: { input: string }): number | null { const parsed = parseFloat(input); return Number.isNaN(parsed) ? null : parsed; } +function isPropertyAtDefault({ + hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue, + staticValue, + defaultValue, +}: { + hasAnimatedKeyframes: boolean; + isPlayheadWithinElementRange: boolean; + resolvedValue: number; + staticValue: number; + defaultValue: number; +}): boolean { + if (hasAnimatedKeyframes && isPlayheadWithinElementRange) { + return isNearlyEqual({ + leftValue: resolvedValue, + rightValue: defaultValue, + }); + } + + return staticValue === defaultValue; +} + export function TransformSection({ element, trackId, }: { - element: TransformElement; + element: VisualElement; trackId: string; }) { const editor = useEditor(); const [isScaleLocked, setIsScaleLocked] = useState(false); + const playheadTime = editor.playback.getCurrentTime(); + const localTime = getElementLocalTime({ + timelineTime: playheadTime, + elementStartTime: element.startTime, + elementDuration: element.duration, + }); + const resolvedTransform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); + const isPlayheadWithinElementRange = + playheadTime >= element.startTime - TIME_EPSILON_SECONDS && + playheadTime <= element.startTime + element.duration + TIME_EPSILON_SECONDS; - const previewTransform = (transform: Partial<Transform>) => { - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { transform: { ...element.transform, ...transform } }, + const positionX = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "transform.position.x", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedTransform.position.x).toString(), + parse: (input) => parseNumericInput({ input }), + valueAtPlayhead: resolvedTransform.position.x, + buildBaseUpdates: ({ value }) => ({ + transform: { + ...element.transform, + position: { + ...element.transform.position, + x: value, }, - ], - }); - }; - - const commit = () => editor.timeline.commitPreview(); - - const positionX = usePropertyDraft({ - displayValue: Math.round(element.transform.position.x).toString(), - parse: (input) => parseFloat_({ input }), - onPreview: (value) => - previewTransform({ - position: { ...element.transform.position, x: value }, - }), - onCommit: commit, + }, + }), }); - const positionY = usePropertyDraft({ - displayValue: Math.round(element.transform.position.y).toString(), - parse: (input) => parseFloat_({ input }), - onPreview: (value) => - previewTransform({ - position: { ...element.transform.position, y: value }, - }), - onCommit: commit, + const positionY = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "transform.position.y", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedTransform.position.y).toString(), + parse: (input) => parseNumericInput({ input }), + valueAtPlayhead: resolvedTransform.position.y, + buildBaseUpdates: ({ value }) => ({ + transform: { + ...element.transform, + position: { + ...element.transform.position, + y: value, + }, + }, + }), }); - const scale = usePropertyDraft({ - displayValue: Math.round(element.transform.scale * 100).toString(), + const scale = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "transform.scale", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedTransform.scale * 100).toString(), parse: (input) => { - const parsed = parseFloat_({ input }); + const parsed = parseNumericInput({ input }); if (parsed === null) return null; return Math.max(parsed, 1) / 100; }, - onPreview: (value) => previewTransform({ scale: value }), - onCommit: commit, + valueAtPlayhead: resolvedTransform.scale, + buildBaseUpdates: ({ value }) => ({ + transform: { + ...element.transform, + scale: value, + }, + }), }); const scaleFieldProps = { className: "flex-1", @@ -88,160 +148,227 @@ 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> - <div className="flex flex-col gap-2"> - <div className="flex items-center gap-2"> - {isScaleLocked ? ( - <> - <NumberField icon="W" {...scaleFieldProps} /> - <NumberField icon="H" {...scaleFieldProps} /> - </> - ) : ( + <SectionFields> + <SectionField + label="Scale" + beforeLabel={ + <KeyframeToggle + isActive={scale.isKeyframedAtTime} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle scale keyframe" + onToggle={scale.toggleKeyframe} + /> + } + > + <div className="flex items-center gap-2"> + {isScaleLocked ? ( + <> + <NumberField icon="W" {...scaleFieldProps} /> + <NumberField icon="H" {...scaleFieldProps} /> + </> + ) : ( + <NumberField + icon={<HugeiconsIcon icon={ArrowExpandIcon} />} + {...scaleFieldProps} + className="flex-1" + /> + )} + <Button + type="button" + variant={isScaleLocked ? "secondary" : "ghost"} + size="icon" + aria-pressed={isScaleLocked} + onClick={() => setIsScaleLocked((isLocked) => !isLocked)} + > + <HugeiconsIcon icon={Link05Icon} /> + </Button> + </div> + </SectionField> + <SectionField + label="Position" + beforeLabel={ + <KeyframeToggle + isActive={hasPositionKeyframe} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle position keyframe" + onToggle={togglePositionKeyframe} + /> + } + > + <div className="flex items-center gap-2"> <NumberField - icon={<HugeiconsIcon icon={ArrowExpandIcon} />} - {...scaleFieldProps} + icon="X" className="flex-1" + value={positionX.displayValue} + onFocus={positionX.onFocus} + onChange={positionX.onChange} + onBlur={positionX.onBlur} + onScrub={positionX.scrubTo} + onScrubEnd={positionX.commitScrub} + onReset={() => + positionX.commitValue({ value: DEFAULT_TRANSFORM.position.x }) + } + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: positionX.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedTransform.position.x, + staticValue: element.transform.position.x, + defaultValue: DEFAULT_TRANSFORM.position.x, + })} /> - )} - <Button - variant={isScaleLocked ? "secondary" : "ghost"} - size="icon" - aria-pressed={isScaleLocked} - onClick={() => setIsScaleLocked((isLocked) => !isLocked)} - > - <HugeiconsIcon icon={Link05Icon} /> - </Button> - </div> - <div className="flex items-center gap-2"> - <NumberField - icon="X" - className="flex-1" - value={positionX.displayValue} - onFocus={positionX.onFocus} - onChange={positionX.onChange} - onBlur={positionX.onBlur} - onScrub={positionX.scrubTo} - onScrubEnd={positionX.commitScrub} - onReset={() => - 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 - } - /> - <NumberField - icon="Y" - className="flex-1" - value={positionY.displayValue} - onFocus={positionY.onFocus} - onChange={positionY.onChange} - onBlur={positionY.onBlur} - onScrub={positionY.scrubTo} - onScrubEnd={positionY.commitScrub} - onReset={() => - 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 - } - /> - </div> + <NumberField + icon="Y" + className="flex-1" + value={positionY.displayValue} + onFocus={positionY.onFocus} + onChange={positionY.onChange} + onBlur={positionY.onBlur} + onScrub={positionY.scrubTo} + onScrubEnd={positionY.commitScrub} + onReset={() => + positionY.commitValue({ value: DEFAULT_TRANSFORM.position.y }) + } + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: positionY.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedTransform.position.y, + staticValue: element.transform.position.y, + defaultValue: DEFAULT_TRANSFORM.position.y, + })} + /> + </div> + </SectionField> - <div className="flex items-center gap-2"> - <NumberField - icon={<HugeiconsIcon icon={RotateClockwiseIcon} />} - className="flex-1" - 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, - }, - }, - }, - ], - }) - } - isDefault={element.transform.rotate === DEFAULT_TRANSFORM.rotate} - /> - </div> - </div> + <SectionField + label="Rotation" + beforeLabel={ + <KeyframeToggle + isActive={rotation.isKeyframedAtTime} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle rotation keyframe" + onToggle={rotation.toggleKeyframe} + /> + } + > + <div className="flex items-center gap-2"> + <NumberField + icon={<HugeiconsIcon icon={RotateClockwiseIcon} />} + className="flex-none" + value={rotation.displayValue} + onFocus={rotation.onFocus} + onChange={rotation.onChange} + onBlur={rotation.onBlur} + dragSensitivity="slow" + onScrub={rotation.scrubTo} + onScrubEnd={rotation.commitScrub} + onReset={() => + rotation.commitValue({ value: 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 cfca52dc..1a17c26a 100644 --- a/apps/web/src/components/editor/panels/properties/text-properties.tsx +++ b/apps/web/src/components/editor/panels/properties/text-properties.tsx @@ -1,31 +1,49 @@ import { Textarea } from "@/components/ui/textarea"; import { FontPicker } from "@/components/ui/font-picker"; import type { TextElement } from "@/types/timeline"; -import { Slider } from "@/components/ui/slider"; import { NumberField } from "@/components/ui/number-field"; import { useRef } from "react"; -import { Section, SectionContent, SectionHeader } from "./section"; +import { Section, SectionContent, SectionField, SectionFields, SectionHeader } from "./section"; import { ColorPicker } from "@/components/ui/color-picker"; import { uppercase } from "@/utils/string"; import { clamp } from "@/utils/math"; import { useEditor } from "@/hooks/use-editor"; import { DEFAULT_COLOR } from "@/constants/project-constants"; import { + DEFAULT_LETTER_SPACING, + DEFAULT_LINE_HEIGHT, + DEFAULT_TEXT_BACKGROUND, DEFAULT_TEXT_ELEMENT, MAX_FONT_SIZE, MIN_FONT_SIZE, } from "@/constants/text-constants"; import { usePropertyDraft } from "./hooks/use-property-draft"; import { TransformSection, BlendingSection } from "./sections"; -import { - Select, - SelectTrigger, - SelectValue, - SelectContent, - SelectItem, -} from "@/components/ui/select"; -import { OcFontWeightIcon } from "@opencut/ui/icons"; -import { Label } from "@/components/ui/label"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { TextFontIcon } 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 }); export function TextProperties({ element, @@ -39,8 +57,9 @@ export function TextProperties({ <ContentSection element={element} trackId={trackId} /> <TransformSection element={element} trackId={trackId} /> <BlendingSection element={element} trackId={trackId} /> - <FontSection element={element} trackId={trackId} /> - <ColorSection element={element} trackId={trackId} /> + <TypographySection element={element} trackId={trackId} /> + <SpacingSection element={element} trackId={trackId} /> + <BackgroundSection element={element} trackId={trackId} /> </div> ); } @@ -83,7 +102,7 @@ function ContentSection({ ); } -function FontSection({ +function TypographySection({ element, trackId, }: { @@ -109,10 +128,11 @@ function FontSection({ }); return ( - <Section collapsible sectionKey="text:font"> - <SectionHeader title="Font" /> - <SectionContent> - <div className="flex flex-col gap-2"> + <Section collapsible sectionKey="text:typography"> + <SectionHeader title="Typography" /> + <SectionContent> + <SectionFields> + <SectionField label="Font"> <FontPicker defaultValue={element.fontFamily} onValueChange={(value) => @@ -127,52 +147,409 @@ function FontSection({ }) } /> - <Select value={element.fontWeight}> - <SelectTrigger className="w-full" icon={<OcFontWeightIcon />}> - <SelectValue placeholder="Select weight" /> - </SelectTrigger> - <SelectContent> - <SelectItem value="100">Thin</SelectItem> - <SelectItem value="200">Extra Light</SelectItem> - <SelectItem value="300">Light</SelectItem> - <SelectItem value="400">Normal</SelectItem> - <SelectItem value="500">Medium</SelectItem> - <SelectItem value="600">Semi Bold</SelectItem> - <SelectItem value="700">Bold</SelectItem> - <SelectItem value="800">Extra Bold</SelectItem> - <SelectItem value="900">Black</SelectItem> - </SelectContent> - </Select> + </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}` }, + }, + ], + }) + } + onChangeEnd={() => editor.timeline.commitPreview()} + /> + </SectionField> + </SectionFields> + </SectionContent> + </Section> + ); +} + +function SpacingSection({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + const editor = useEditor(); + + const letterSpacing = usePropertyDraft({ + 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 } }], + }), + onCommit: () => editor.timeline.commitPreview(), + }); + + const lineHeight = usePropertyDraft({ + displayValue: lineHeightConverter.toDisplay(element.lineHeight ?? DEFAULT_LINE_HEIGHT).toString(), + parse: (input) => { + const parsed = parseFloat(input); + return Number.isNaN(parsed) ? null : lineHeightConverter.fromDisplay(Math.round(parsed)); + }, + onPreview: (value) => + editor.timeline.previewElements({ + 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> + ); +} - <Label>Font size</Label> - <div className="flex items-center gap-2"> - <Slider - value={[element.fontSize]} - min={MIN_FONT_SIZE} - max={MAX_FONT_SIZE} - step={1} - onValueChange={([value]) => +function BackgroundSection({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + const editor = useEditor(); + const lastSelectedColor = useRef(DEFAULT_COLOR); + + const cornerRadius = usePropertyDraft({ + displayValue: Math.round(element.background.cornerRadius ?? 0).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(), + }); + + const paddingX = usePropertyDraft({ + displayValue: paddingXConverter.toDisplay(element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX).toString(), + parse: (input) => { + const parsed = parseFloat(input); + return Number.isNaN(parsed) ? null : paddingXConverter.fromDisplay(Math.round(parsed)); + }, + onPreview: (value) => + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { background: { ...element.background, paddingX: value } }, + }, + ], + }), + onCommit: () => editor.timeline.commitPreview(), + }); + + const paddingY = usePropertyDraft({ + displayValue: paddingYConverter.toDisplay(element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY).toString(), + parse: (input) => { + const parsed = parseFloat(input); + return Number.isNaN(parsed) ? null : paddingYConverter.fromDisplay(Math.round(parsed)); + }, + onPreview: (value) => + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { background: { ...element.background, paddingY: value } }, + }, + ], + }), + onCommit: () => editor.timeline.commitPreview(), + }); + + const offsetX = usePropertyDraft({ + displayValue: Math.round(element.background.offsetX ?? 0).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(), + }); + + const offsetY = usePropertyDraft({ + displayValue: Math.round(element.background.offsetY ?? 0).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, offsetY: value } }, + }, + ], + }), + onCommit: () => editor.timeline.commitPreview(), + }); + + return ( + <Section collapsible sectionKey="text:background"> + <SectionHeader title="Background" /> + <SectionContent> + <SectionFields> + <SectionField label="Color"> + <ColorPicker + value={ + element.background.color === "transparent" + ? lastSelectedColor.current.replace("#", "") + : element.background.color.replace("#", "") + } + onChange={(color) => { + const hexColor = `#${color}`; + if (color !== "transparent") { + lastSelectedColor.current = hexColor; + } editor.timeline.previewElements({ updates: [ { trackId, elementId: element.id, - updates: { fontSize: value }, + updates: { + background: { ...element.background, color: hexColor }, + }, }, ], - }) + }); + }} + onChangeEnd={() => editor.timeline.commitPreview()} + className={ + element.background.color === "transparent" + ? "pointer-events-none opacity-50" + : "" } - onValueCommit={() => editor.timeline.commitPreview()} - className="w-full" /> + </SectionField> + <div className="flex items-start gap-2"> + <SectionField label="Width" className="w-1/2"> <NumberField - className="w-18 shrink-0" - value={fontSize.displayValue} - min={MIN_FONT_SIZE} - max={MAX_FONT_SIZE} - onFocus={fontSize.onFocus} - onChange={fontSize.onChange} - onBlur={fontSize.onBlur} + 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 + } + /> + </SectionField> + <SectionField label="Height" className="w-1/2"> + <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 + } + /> + </SectionField> + </div> + <div className="flex items-start gap-2"> + <SectionField label="X-offset" className="w-1/2"> + <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} + /> + </SectionField> + <SectionField label="Y-offset" className="w-1/2"> + <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} + /> + </SectionField> + </div> + <SectionField label="Corner Radius"> + <NumberField + icon="R" + value={cornerRadius.displayValue} + min={0} + onFocus={cornerRadius.onFocus} + onChange={cornerRadius.onChange} + onBlur={cornerRadius.onBlur} + onScrub={cornerRadius.scrubTo} + onScrubEnd={cornerRadius.commitScrub} onReset={() => editor.timeline.updateElements({ updates: [ @@ -180,82 +557,19 @@ function FontSection({ trackId, elementId: element.id, updates: { - fontSize: DEFAULT_TEXT_ELEMENT.fontSize, + background: { + ...element.background, + cornerRadius: 0, + }, }, }, ], }) } - isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize} + isDefault={(element.background.cornerRadius ?? 0) === 0} /> - </div> - </div> - </SectionContent> - </Section> - ); -} - -function ColorSection({ - element, - trackId, -}: { - element: TextElement; - trackId: string; -}) { - const editor = useEditor(); - const lastSelectedColor = useRef(DEFAULT_COLOR); - - return ( - <Section collapsible sectionKey="text:color" hasBorderBottom={false}> - <SectionHeader title="Color" /> - <SectionContent> - <div className="flex flex-col gap-6"> - <ColorPicker - value={uppercase({ - string: (element.color || "FFFFFF").replace("#", ""), - })} - onChange={(color) => - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { color: `#${color}` }, - }, - ], - }) - } - onChangeEnd={() => editor.timeline.commitPreview()} - /> - <ColorPicker - value={ - element.backgroundColor === "transparent" - ? lastSelectedColor.current.replace("#", "") - : element.backgroundColor.replace("#", "") - } - onChange={(color) => { - const hexColor = `#${color}`; - if (color !== "transparent") { - lastSelectedColor.current = hexColor; - } - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { backgroundColor: hexColor }, - }, - ], - }); - }} - onChangeEnd={() => editor.timeline.commitPreview()} - className={ - element.backgroundColor === "transparent" - ? "pointer-events-none opacity-50" - : "" - } - /> - </div> + </SectionField> + </SectionFields> </SectionContent> </Section> ); 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 bf6d92d8..3588cbb7 100644 --- a/apps/web/src/components/editor/panels/properties/video-properties.tsx +++ b/apps/web/src/components/editor/panels/properties/video-properties.tsx @@ -1,9 +1,17 @@ -import type { ImageElement, VideoElement } from "@/types/timeline"; +import type { ImageElement, StickerElement, VideoElement } from "@/types/timeline"; +import { BlendingSection, TransformSection } from "./sections"; export function VideoProperties({ - _element, + element, + trackId, }: { - _element: VideoElement | ImageElement; + element: VideoElement | ImageElement | StickerElement; + trackId: string; }) { - return <div className="space-y-4 p-5">Video properties</div>; + return ( + <div className="flex h-full flex-col"> + <TransformSection element={element} trackId={trackId} /> + <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/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx index 7e6ff326..d89d270d 100644 --- a/apps/web/src/components/editor/panels/timeline/index.tsx +++ b/apps/web/src/components/editor/panels/timeline/index.tsx @@ -23,11 +23,11 @@ import { TimelinePlayhead } from "./timeline-playhead"; import { SelectionBox } from "../../selection-box"; import { useSelectionBox } from "@/hooks/timeline/use-selection-box"; import { SnapIndicator } from "./snap-indicator"; -import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping"; +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 { @@ -161,6 +161,7 @@ export function Timeline() { shouldIgnoreClick, } = useSelectionBox({ containerRef: tracksContainerRef, + headerRef: timelineHeaderRef, onSelectionComplete: (elements) => { setElementSelection({ elements }); }, @@ -326,7 +327,7 @@ export function Timeline() { <DragLine dropTarget={dropTarget} tracks={timeline.getTracks()} - isVisible={isDragOver} + isVisible={isDragOver && !dropTarget?.targetElement} headerHeight={timelineHeaderHeight} /> <DragLine @@ -453,6 +454,11 @@ export function Timeline() { }} onTrackClick={handleTracksClick} shouldIgnoreClick={shouldIgnoreClick} + targetElementId={ + isDragOver + ? dropTarget?.targetElement?.elementId ?? null + : null + } /> </div> </ContextMenuTrigger> @@ -523,7 +529,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 e70f0cd7..927d336a 100644 --- a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx +++ b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx @@ -1,7 +1,11 @@ "use client"; import { useSnapIndicatorPosition } from "@/hooks/timeline/use-snap-indicator-position"; -import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping"; +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 628855f6..38cf953b 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,17 @@ 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 type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection"; +import type { SnapPoint } from "@/lib/timeline/snap-utils"; +import { getElementKeyframes } from "@/lib/animation"; import { getTrackClasses, getTrackHeight, canElementHaveAudio, canElementBeHidden, hasMediaId, + timelineTimeToPixels, + timelineTimeToSnappedPixels, } from "@/lib/timeline"; import { ContextMenu, @@ -19,7 +22,7 @@ import { ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, -} from "../../../ui/context-menu"; +} from "@/components/ui/context-menu"; import type { TimelineElement as TimelineElementType, TimelineTrack, @@ -42,10 +45,105 @@ 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"; + +const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40; + +interface KeyframeIndicator { + time: number; + offsetPx: number; + keyframes: SelectedKeyframeRef[]; +} + +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 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); +} function getDisplayShortcut(action: TAction) { const { defaultShortcuts } = getActionDefinition(action); @@ -71,6 +169,7 @@ interface TimelineElementProps { ) => void; onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void; dragState: ElementDragState; + isDropTarget?: boolean; } export function TimelineElement({ @@ -83,6 +182,7 @@ export function TimelineElement({ onElementMouseDown, onElementClick, dragState, + isDropTarget = false, }: TimelineElementProps) { const editor = useEditor(); const { selectedElements } = useElementSelection(); @@ -123,10 +223,25 @@ 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 handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => { event.stopPropagation(); if (hasMediaId(element)) { @@ -160,7 +275,9 @@ export function TimelineElement({ onElementClick={onElementClick} onElementMouseDown={onElementMouseDown} handleResizeStart={handleResizeStart} + isDropTarget={isDropTarget} /> + {isSelected && <KeyframeIndicators indicators={keyframeIndicators} />} </div> </ContextMenuTrigger> <ContextMenuContent className="w-64"> @@ -197,7 +314,9 @@ export function TimelineElement({ <> <ContextMenuItem icon={<HugeiconsIcon icon={Search01Icon} />} - onClick={(event) => handleRevealInMedia({ event })} + onClick={(event: React.MouseEvent) => + handleRevealInMedia({ event }) + } > Reveal media </ContextMenuItem> @@ -231,6 +350,7 @@ function ElementInner({ onElementClick, onElementMouseDown, handleResizeStart, + isDropTarget = false, }: { element: TimelineElementType; track: TimelineTrack; @@ -248,14 +368,20 @@ function ElementInner({ elementId: string; side: "left" | "right"; }) => void; + isDropTarget?: boolean; }) { + const opacityClass = + (canElementBeHidden(element) && element.hidden) || isDropTarget + ? "opacity-50" + : ""; + return ( <div className={`relative h-full cursor-pointer overflow-hidden rounded-[0.5rem] ${getTrackClasses( { type: track.type, }, - )} ${canElementBeHidden(element) && element.hidden ? "opacity-50" : ""}`} + )} ${opacityClass}`} > <button type="button" @@ -271,7 +397,6 @@ function ElementInner({ mediaAssets={mediaAssets} /> </div> - {(hasAudio ? isMuted : canElementBeHidden(element) && element.hidden) && ( @@ -335,52 +460,142 @@ function ResizeHandle({ ); } -function ElementContent({ - element, - track, - isSelected, - mediaAssets, +function KeyframeIndicators({ + indicators, }: { + indicators: KeyframeIndicator[]; +}) { + const { isKeyframeSelected, toggleKeyframeSelection, selectKeyframeRange } = + useKeyframeSelection(); + const orderedKeyframes = indicators.flatMap( + (indicator) => indicator.keyframes, + ); + + const handleKeyframeMouseDown = ({ event }: { event: React.MouseEvent }) => { + event.preventDefault(); + event.stopPropagation(); + }; + + const handleKeyframeClick = ({ + event, + keyframes, + }: { + event: React.MouseEvent; + keyframes: SelectedKeyframeRef[]; + }) => { + event.stopPropagation(); + if (event.shiftKey) { + selectKeyframeRange({ + orderedKeyframes, + targetKeyframes: keyframes, + isAdditive: event.metaKey || event.ctrlKey, + }); + return; + } + + toggleKeyframeSelection({ + keyframes, + isMultiKey: event.metaKey || event.ctrlKey, + }); + }; + + return indicators.map((indicator) => { + const isIndicatorSelected = indicator.keyframes.some((keyframe) => + isKeyframeSelected({ keyframe }), + ); + + return ( + <button + key={indicator.time} + type="button" + className="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-pointer" + style={{ left: indicator.offsetPx }} + onMouseDown={(event) => handleKeyframeMouseDown({ event })} + onClick={(event) => + handleKeyframeClick({ event, keyframes: indicator.keyframes }) + } + aria-label="Select keyframe" + > + <HugeiconsIcon + icon={KeyframeIcon} + className={cn( + "size-3.5 text-black", + isIndicatorSelected ? "fill-primary" : "fill-white", + )} + strokeWidth={1.5} + /> + </button> + ); + }); +} + +interface ElementContentProps { element: TimelineElementType; track: TimelineTrack; isSelected: boolean; mediaAssets: MediaAsset[]; -}) { - if (element.type === "text") { +} + +type ElementContentRenderer = (props: ElementContentProps) => ReactNode; + +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> + <div className="flex size-full items-center justify-start pl-3"> + <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">{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,28 +614,78 @@ function ElementContent({ return ( <span className="text-foreground/80 truncate text-xs"> - {element.name} + {audioElement.name} </span> ); - } + }, + video: ({ element, track, isSelected, mediaAssets }) => { + const videoElement = element as Extract< + TimelineElementType, + { type: "video" } + >; + const mediaAsset = mediaAssets.find( + (asset) => asset.id === videoElement.mediaId, + ); + + if (!mediaAsset) { + return ( + <span className="text-foreground/80 truncate text-xs"> + {videoElement.name} + </span> + ); + } + + if (mediaAsset.thumbnailUrl) { + const trackHeight = getTrackHeight({ type: track.type }); + const tileWidth = trackHeight * (16 / 9); + + 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: `url(${mediaAsset.thumbnailUrl})`, + 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> + ); + } - const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId); - if (!mediaAsset) { return ( <span className="text-foreground/80 truncate text-xs"> - {element.name} + {videoElement.name} </span> ); - } + }, + image: ({ element, track, isSelected, mediaAssets }) => { + const imageElement = element as Extract< + TimelineElementType, + { type: "image" } + >; + const mediaAsset = mediaAssets.find( + (asset) => asset.id === imageElement.mediaId, + ); + + if (!mediaAsset?.url) { + return ( + <span className="text-foreground/80 truncate text-xs"> + {imageElement.name} + </span> + ); + } - 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"> @@ -430,7 +695,7 @@ function ElementContent({ <div className="absolute right-0 left-0" style={{ - backgroundImage: imageUrl ? `url(${imageUrl})` : "none", + backgroundImage: `url(${mediaAsset.url})`, backgroundRepeat: "repeat-x", backgroundSize: `${tileWidth}px ${trackHeight}px`, backgroundPosition: "left center", @@ -442,11 +707,12 @@ function ElementContent({ </div> </div> ); - } + }, +}; - return ( - <span className="text-foreground/80 truncate text-xs">{element.name}</span> - ); +function ElementContent(props: ElementContentProps) { + const renderer = ELEMENT_CONTENT_RENDERERS[props.element.type]; + return <>{renderer(props)}</>; } function CopyMenuItem() { @@ -549,10 +815,11 @@ 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); }} 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-track.tsx b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx index b7b06d5b..fbc7cb3f 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx @@ -4,7 +4,7 @@ import { useElementSelection } from "@/hooks/timeline/element/use-element-select import { TimelineElement } from "./timeline-element"; import type { TimelineTrack } from "@/types/timeline"; import type { TimelineElement as TimelineElementType } from "@/types/timeline"; -import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping"; +import type { SnapPoint } from "@/lib/timeline/snap-utils"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll"; import type { ElementDragState } from "@/types/timeline"; @@ -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,6 +49,7 @@ export function TimelineTrackContent({ onTrackMouseDown, onTrackClick, shouldIgnoreClick, + targetElementId = null, }: TimelineTrackContentProps) { const editor = useEditor(); const { isElementSelected, clearElementSelection } = useElementSelection(); @@ -102,6 +104,7 @@ export function TimelineTrackContent({ onElementClick({ event, element, track }) } dragState={dragState} + isDropTarget={element.id === targetElementId} /> ); }) diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx index 6f11ad7c..a8f3f70d 100644 --- a/apps/web/src/components/landing/hero.tsx +++ b/apps/web/src/components/landing/hero.tsx @@ -14,7 +14,7 @@ export function Hero() { src="/landing-page-dark.png" height={1903.5} width={1269} - alt="landing-page.bg" + alt="OpenCut video editor landing page background" /> <div className="mx-auto flex w-full max-w-3xl flex-1 flex-col justify-center"> <div className="inline-block text-4xl font-bold tracking-tighter md:text-[4rem]"> diff --git a/apps/web/src/components/ui/color-picker.tsx b/apps/web/src/components/ui/color-picker.tsx index d58b6144..fbf9bbd7 100644 --- a/apps/web/src/components/ui/color-picker.tsx +++ b/apps/web/src/components/ui/color-picker.tsx @@ -280,13 +280,21 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( )} {...props} > - <PopoverTrigger asChild> - <button - className="size-4.5 cursor-pointer border rounded-sm hover:ring-1 hover:ring-foreground/20" + <PopoverTrigger asChild> + <button + className="size-4.5 cursor-pointer border rounded-sm hover:ring-1 hover:ring-foreground/20 overflow-hidden relative" + type="button" + > + <span + className="absolute inset-0" + style={checkerboardStyle} + /> + <span + className="absolute inset-0" style={{ backgroundColor: `#${value}` }} - type="button" /> - </PopoverTrigger> + </button> + </PopoverTrigger> <div className="flex flex-1 items-center"> <Input className={cn( diff --git a/apps/web/src/components/ui/font-picker.tsx b/apps/web/src/components/ui/font-picker.tsx index d5d56255..09ba0a9a 100644 --- a/apps/web/src/components/ui/font-picker.tsx +++ b/apps/web/src/components/ui/font-picker.tsx @@ -26,7 +26,7 @@ import type { FontAtlas, FontAtlasEntry } from "@/types/fonts"; import { cn } from "@/utils/ui"; import { ChevronDown, Search, Upload } from "lucide-react"; import { HugeiconsIcon } from "@hugeicons/react"; -import { TextFontIcon } from "@hugeicons/core-free-icons"; +import { TextIcon } from "@hugeicons/core-free-icons"; const FONT_TABS = [ { key: "all", label: "All fonts" }, @@ -139,7 +139,7 @@ export function FontPicker({ > <div className="flex min-w-0 items-center gap-1.5"> <span className="text-muted-foreground [&_svg]:size-3.5 shrink-0"> - <HugeiconsIcon icon={TextFontIcon} /> + <HugeiconsIcon icon={TextIcon} /> </span> <span className="truncate" style={{ fontFamily: defaultValue }}> {defaultValue ?? "Select a font"} diff --git a/apps/web/src/components/ui/number-field.tsx b/apps/web/src/components/ui/number-field.tsx index 35cce81b..48297dc0 100644 --- a/apps/web/src/components/ui/number-field.tsx +++ b/apps/web/src/components/ui/number-field.tsx @@ -71,9 +71,12 @@ function NumberField({ return; } cumulativeDeltaRef.current += moveEvent.movementX; + const sensitivity = + typeof dragSensitivity === "number" + ? dragSensitivity + : DRAG_SENSITIVITIES[dragSensitivity]; const newValue = - startValueRef.current + - cumulativeDeltaRef.current * DRAG_SENSITIVITIES[dragSensitivity]; + startValueRef.current + cumulativeDeltaRef.current * sensitivity; onScrub(newValue); }; 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 f0b2143d..6b688ed1 100644 --- a/apps/web/src/constants/text-constants.ts +++ b/apps/web/src/constants/text-constants.ts @@ -17,6 +17,15 @@ export const FONT_SIZE_SCALE_REFERENCE = 90; export const DEFAULT_LETTER_SPACING = 0; export const DEFAULT_LINE_HEIGHT = 1.2; +export const DEFAULT_TEXT_BACKGROUND = { + color: "#000000", + cornerRadius: 0, + paddingX: 30, + paddingY: 42, + offsetX: 0, + offsetY: 0, +}; + export const DEFAULT_TEXT_ELEMENT: Omit<TextElement, "id"> = { type: "text", name: "Text", @@ -24,7 +33,7 @@ export const DEFAULT_TEXT_ELEMENT: Omit<TextElement, "id"> = { fontSize: 15, fontFamily: "Arial", color: "#ffffff", - backgroundColor: "#000000", + background: DEFAULT_TEXT_BACKGROUND, textAlign: "center", fontWeight: "normal", fontStyle: "normal", 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/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 cf906aee..f6a1744a 100644 --- a/apps/web/src/core/managers/timeline-manager.ts +++ b/apps/web/src/core/managers/timeline-manager.ts @@ -5,6 +5,11 @@ import type { TimelineElement, ClipboardItem, } from "@/types/timeline"; +import type { + AnimationInterpolation, + AnimationPropertyPath, + AnimationValue, +} from "@/types/animation"; import { calculateTotalDuration } from "@/lib/timeline"; import { AddTrackCommand, @@ -24,6 +29,9 @@ import { UpdateElementStartTimeCommand, MoveElementCommand, TracksSnapshotCommand, + UpsertKeyframeCommand, + RemoveKeyframeCommand, + RetimeKeyframeCommand, } from "@/lib/commands/timeline"; import { BatchCommand, PreviewTracker } from "@/lib/commands"; import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element"; @@ -61,7 +69,11 @@ export class TimelineManager { trimEnd: number; pushHistory?: boolean; }): void { - const command = new UpdateElementTrimCommand(elementId, trimStart, trimEnd); + const command = new UpdateElementTrimCommand({ + elementId, + trimStart, + trimEnd, + }); if (pushHistory) { this.editor.command.execute({ command }); } else { @@ -80,11 +92,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 +111,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 }); } @@ -116,13 +131,13 @@ export class TimelineManager { newStartTime: number; createTrack?: { type: TrackType; index: number }; }): void { - const command = new MoveElementCommand( + const command = new MoveElementCommand({ sourceTrackId, targetTrackId, elementId, newStartTime, createTrack, - ); + }); this.editor.command.execute({ command }); } @@ -147,12 +162,12 @@ export class TimelineManager { retainSide?: "both" | "left" | "right"; rippleEnabled?: boolean; }): { trackId: string; elementId: string }[] { - const command = new SplitElementsCommand( + const command = new SplitElementsCommand({ elements, splitTime, retainSide, rippleEnabled, - ); + }); this.editor.command.execute({ command }); return command.getRightSideElements(); } @@ -206,7 +221,7 @@ export class TimelineManager { elements: { trackId: string; elementId: string }[]; rippleEnabled?: boolean; }): void { - const command = new DeleteElementsCommand(elements, rippleEnabled); + const command = new DeleteElementsCommand({ elements, rippleEnabled }); this.editor.command.execute({ command }); } @@ -217,13 +232,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); @@ -234,6 +253,99 @@ export class TimelineManager { } } + 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 }); + } + isPreviewActive(): boolean { return this.previewTracker.isActive(); } @@ -244,7 +356,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/timeline/element/use-element-interaction.ts b/apps/web/src/hooks/timeline/element/use-element-interaction.ts index 79f1272c..3831a86f 100644 --- a/apps/web/src/hooks/timeline/element/use-element-interaction.ts +++ b/apps/web/src/hooks/timeline/element/use-element-interaction.ts @@ -17,14 +17,16 @@ import { snapTimeToFrame } from "@/lib/time"; import { computeDropTarget } from "@/lib/timeline/drop-utils"; import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils"; import { generateUUID } from "@/utils/id"; -import { useTimelineSnapping } from "@/hooks/timeline/use-timeline-snapping"; +import { + snapElementEdge, + type SnapPoint, +} from "@/lib/timeline/snap-utils"; import type { DropTarget, ElementDragState, TimelineElement, TimelineTrack, } from "@/types/timeline"; -import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping"; interface UseElementInteractionProps { zoomLevel: number; @@ -162,7 +164,6 @@ export function useElementInteraction({ const editor = useEditor(); const isShiftHeldRef = useShiftKey(); const tracks = editor.timeline.getTracks(); - const { snapElementEdge } = useTimelineSnapping(); const { isElementSelected, selectElement, @@ -255,14 +256,7 @@ export function useElementInteraction({ snapPoint: snapResult.snapPoint, }; }, - [ - snappingEnabled, - editor.playback, - snapElementEdge, - tracks, - zoomLevel, - isShiftHeldRef, - ], + [snappingEnabled, editor.playback, tracks, zoomLevel, isShiftHeldRef], ); useEffect(() => { @@ -598,9 +592,12 @@ export function useElementInteraction({ }); 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 14693fff..2340c9b2 100644 --- a/apps/web/src/hooks/timeline/element/use-element-resize.ts +++ b/apps/web/src/hooks/timeline/element/use-element-resize.ts @@ -5,9 +5,10 @@ import type { TimelineElement, TimelineTrack } from "@/types/timeline"; import { useEditor } from "@/hooks/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; import { - useTimelineSnapping, + findSnapPoints, + snapToNearestPoint, type SnapPoint, -} from "@/hooks/timeline/use-timeline-snapping"; +} from "@/lib/timeline/snap-utils"; import { useTimelineStore } from "@/stores/timeline-store"; export interface ResizeState { @@ -39,7 +40,7 @@ export function useTimelineElementResize({ const activeProject = editor.project.getActive(); const isShiftHeldRef = useShiftKey(); const snappingEnabled = useTimelineStore((state) => state.snappingEnabled); - const { findSnapPoints, snapToNearestPoint } = useTimelineSnapping(); + const [resizing, setResizing] = useState<ResizeState | null>(null); const [currentTrimStart, setCurrentTrimStart] = useState(element.trimStart); @@ -85,12 +86,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 }) => { @@ -269,8 +266,6 @@ export function useTimelineElementResize({ activeProject.settings.fps, snappingEnabled, editor, - findSnapPoints, - snapToNearestPoint, element.id, onSnapPointChange, canExtendElementDuration, 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..50def392 --- /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]; + if (!isMultiKey) { + setKeyframeSelection({ keyframes, anchorKeyframe }); + return; + } + + const areAllKeyframesSelected = keyframes.every((keyframe) => + isKeyframeSelected({ keyframe }), + ); + 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-bookmark-drag.ts b/apps/web/src/hooks/timeline/use-bookmark-drag.ts index faf28e40..0e7fabd5 100644 --- a/apps/web/src/hooks/timeline/use-bookmark-drag.ts +++ b/apps/web/src/hooks/timeline/use-bookmark-drag.ts @@ -10,9 +10,12 @@ import { useShiftKey } from "@/hooks/use-shift-key"; import { DRAG_THRESHOLD_PX } from "@/constants/timeline-constants"; import { snapTimeToFrame } from "@/lib/time"; import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils"; -import { useTimelineSnapping } from "@/hooks/timeline/use-timeline-snapping"; +import { + findSnapPoints, + snapToNearestPoint, + type SnapPoint, +} from "@/lib/timeline/snap-utils"; import type { Bookmark } from "@/types/timeline"; -import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping"; export interface BookmarkDragState { isDragging: boolean; @@ -47,8 +50,6 @@ export function useBookmarkDrag({ const playheadTime = editor.playback.getCurrentTime(); const duration = editor.timeline.getTotalDuration(); - const { findSnapPoints, snapToNearestPoint } = useTimelineSnapping(); - const [dragState, setDragState] = useState<BookmarkDragState>({ isDragging: false, bookmarkTime: null, @@ -112,16 +113,7 @@ export function useBookmarkDrag({ snapPoint: result.snapPoint, }; }, - [ - snappingEnabled, - findSnapPoints, - snapToNearestPoint, - tracks, - playheadTime, - bookmarks, - zoomLevel, - isShiftHeldRef, - ], + [snappingEnabled, tracks, playheadTime, bookmarks, zoomLevel, isShiftHeldRef], ); useEffect(() => { 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..ee739298 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,7 +17,11 @@ 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>; @@ -54,6 +59,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 +76,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) { @@ -124,6 +134,13 @@ export function useTimelineDragDrop({ const mouseX = e.clientX - rect.left; const mouseY = Math.max(0, e.clientY - rect.top - headerHeight); + const targetElementTypes = + dragData?.type === "effect" + ? (dragData as EffectDragData).targetElementTypes + : dragData?.type === "media" + ? (dragData as MediaDragData).targetElementTypes + : undefined; + const target = computeDropTarget({ elementType, mouseX, @@ -134,6 +151,7 @@ export function useTimelineDragDrop({ elementDuration: duration, pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND, zoomLevel, + targetElementTypes, }); target.xPosition = getSnappedTime({ time: target.xPosition }); @@ -248,6 +266,11 @@ export function useTimelineDragDrop({ 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; @@ -284,6 +307,42 @@ export function useTimelineDragDrop({ [editor.timeline, mediaAssets, tracks], ); + const executeEffectDrop = useCallback( + ({ target, dragData }: { target: DropTarget; dragData: EffectDragData }) => { + const effectTrack = tracks.find((t) => t.type === "effect"); + let trackId: string; + + if (effectTrack && !target.targetElement) { + trackId = effectTrack.id; + } else if (target.targetElement) { + trackId = effectTrack?.id ?? editor.timeline.addTrack({ + type: "effect", + index: 0, + }); + } else if (target.isNewTrack) { + trackId = editor.timeline.addTrack({ + type: "effect", + index: target.trackIndex, + }); + } 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, tracks], + ); + const executeFileDrop = useCallback( async ({ files, @@ -384,6 +443,11 @@ 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 }); } @@ -410,6 +474,7 @@ export function useTimelineDragDrop({ executeTextDrop, executeStickerDrop, executeMediaDrop, + executeEffectDrop, executeFileDrop, containerRef, headerRef, diff --git a/apps/web/src/hooks/timeline/use-timeline-playhead.ts b/apps/web/src/hooks/timeline/use-timeline-playhead.ts index 44e12ab1..1426a003 100644 --- a/apps/web/src/hooks/timeline/use-timeline-playhead.ts +++ b/apps/web/src/hooks/timeline/use-timeline-playhead.ts @@ -4,9 +4,9 @@ import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll"; import { useEditor } from "../use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; import { - useTimelineSnapping, - type SnapPoint, -} from "@/hooks/timeline/use-timeline-snapping"; + findSnapPoints, + snapToNearestPoint, +} from "@/lib/timeline/snap-utils"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; interface UseTimelinePlayheadProps { @@ -31,10 +31,6 @@ export function useTimelinePlayhead({ const isPlaying = editor.playback.getIsPlaying(); const isScrubbing = editor.playback.getIsScrubbing(); const isShiftHeldRef = useShiftKey(); - const { snapToNearestPoint } = useTimelineSnapping({ - enableElementSnapping: false, - enablePlayheadSnapping: false, - }); const seek = useCallback( ({ time }: { time: number }) => editor.playback.seek({ time }), @@ -51,7 +47,13 @@ export function useTimelinePlayhead({ isScrubbing && scrubTime !== null ? scrubTime : currentTime; const handleScrub = useCallback( - ({ event }: { event: MouseEvent | React.MouseEvent }) => { + ({ + event, + snappingEnabled = true, + }: { + event: MouseEvent | React.MouseEvent; + snappingEnabled?: boolean; + }) => { const ruler = rulerRef.current; if (!ruler) return; const rulerRect = ruler.getBoundingClientRect(); @@ -80,21 +82,25 @@ export function useTimelinePlayhead({ fps: framesPerSecond, }); - const bookmarks = editor.scenes.getActiveScene()?.bookmarks ?? []; - const bookmarkSnapPoints: SnapPoint[] = bookmarks.map((bookmark) => ({ - time: bookmark.time, - type: "bookmark", - })); - const shouldSnapToBookmark = - !isShiftHeldRef.current && bookmarkSnapPoints.length > 0; - const snapResult = shouldSnapToBookmark - ? snapToNearestPoint({ - targetTime: frameTime, - snapPoints: bookmarkSnapPoints, - zoomLevel, - }) - : null; - const time = snapResult?.snapPoint ? snapResult.snappedTime : frameTime; + const shouldSnap = snappingEnabled && !isShiftHeldRef.current; + const time = (() => { + if (!shouldSnap) return frameTime; + const tracks = editor.timeline.getTracks(); + const bookmarks = + editor.scenes.getActiveScene()?.bookmarks ?? []; + const snapPoints = findSnapPoints({ + tracks, + playheadTime: frameTime, + bookmarks, + enablePlayheadSnapping: false, + }); + const snapResult = snapToNearestPoint({ + targetTime: frameTime, + snapPoints, + zoomLevel, + }); + return snapResult.snapPoint ? snapResult.snappedTime : frameTime; + })(); setScrubTime(time); seek({ time }); @@ -109,7 +115,7 @@ export function useTimelinePlayhead({ activeProject.settings.fps, isShiftHeldRef, editor.scenes, - snapToNearestPoint, + editor.timeline, ], ); @@ -133,10 +139,10 @@ export function useTimelinePlayhead({ setIsDraggingRuler(true); setHasDraggedRuler(false); - editor.playback.setScrubbing({ isScrubbing: true }); - handleScrub({ event }); - }, - [handleScrub, playheadRef, editor.playback], + editor.playback.setScrubbing({ isScrubbing: true }); + handleScrub({ event, snappingEnabled: false }); + }, + [handleScrub, playheadRef, editor.playback], ); const handlePlayheadMouseDownEvent = useCallback( @@ -184,7 +190,7 @@ export function useTimelinePlayhead({ if (isDraggingRuler) { setIsDraggingRuler(false); if (!hasDraggedRuler) { - handleScrub({ event }); + handleScrub({ event, snappingEnabled: false }); } setHasDraggedRuler(false); } diff --git a/apps/web/src/hooks/timeline/use-timeline-snapping.ts b/apps/web/src/hooks/timeline/use-timeline-snapping.ts deleted file mode 100644 index 5de75448..00000000 --- a/apps/web/src/hooks/timeline/use-timeline-snapping.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { useCallback } from "react"; -import type { Bookmark, TimelineTrack } from "@/types/timeline"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; -import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks"; - -export interface SnapPoint { - time: number; - type: "element-start" | "element-end" | "playhead" | "bookmark"; - elementId?: string; - trackId?: string; -} - -export interface SnapResult { - snappedTime: number; - snapPoint: SnapPoint | null; - snapDistance: number; -} - -export interface UseTimelineSnappingOptions { - snapThreshold?: number; - enableElementSnapping?: boolean; - enablePlayheadSnapping?: boolean; - enableBookmarkSnapping?: boolean; -} - -export function useTimelineSnapping({ - snapThreshold = 10, - enableElementSnapping = true, - enablePlayheadSnapping = true, - enableBookmarkSnapping = true, -}: UseTimelineSnappingOptions = {}) { - const findSnapPoints = useCallback( - ({ - tracks, - playheadTime, - excludeElementId, - bookmarks = [], - excludeBookmarkTime, - }: { - tracks: Array<TimelineTrack>; - playheadTime: number; - excludeElementId?: string; - bookmarks?: Array<Bookmark>; - excludeBookmarkTime?: number; - }): SnapPoint[] => { - const snapPoints: SnapPoint[] = []; - - if (enableElementSnapping) { - for (const track of tracks) { - for (const element of track.elements) { - if (element.id === excludeElementId) continue; - - const elementStart = element.startTime; - const elementEnd = element.startTime + element.duration; - - snapPoints.push( - { - time: elementStart, - type: "element-start", - elementId: element.id, - trackId: track.id, - }, - { - time: elementEnd, - type: "element-end", - elementId: element.id, - trackId: track.id, - }, - ); - } - } - } - - if (enablePlayheadSnapping) { - snapPoints.push({ - time: playheadTime, - type: "playhead", - }); - } - - if (enableBookmarkSnapping) { - for (const bookmark of bookmarks) { - if ( - excludeBookmarkTime != null && - Math.abs(bookmark.time - excludeBookmarkTime) < - BOOKMARK_TIME_EPSILON - ) { - continue; - } - snapPoints.push({ - time: bookmark.time, - type: "bookmark", - }); - } - } - - return snapPoints; - }, - [enableElementSnapping, enablePlayheadSnapping, enableBookmarkSnapping], - ); - - const snapToNearestPoint = useCallback( - ({ - targetTime, - snapPoints, - zoomLevel, - }: { - targetTime: number; - snapPoints: Array<SnapPoint>; - zoomLevel: number; - }): SnapResult => { - const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; - const thresholdInSeconds = snapThreshold / pixelsPerSecond; - - let closestSnapPoint: SnapPoint | null = null; - let closestDistance = Infinity; - - for (const snapPoint of snapPoints) { - const distance = Math.abs(targetTime - snapPoint.time); - if (distance < thresholdInSeconds && distance < closestDistance) { - closestDistance = distance; - closestSnapPoint = snapPoint; - } - } - - return { - snappedTime: closestSnapPoint ? closestSnapPoint.time : targetTime, - snapPoint: closestSnapPoint, - snapDistance: closestDistance, - }; - }, - [snapThreshold], - ); - - const snapElementEdge = useCallback( - ({ - targetTime, - elementDuration, - tracks, - playheadTime, - zoomLevel, - excludeElementId, - snapToStart = true, - bookmarks = [], - }: { - targetTime: number; - elementDuration: number; - tracks: Array<TimelineTrack>; - playheadTime: number; - zoomLevel: number; - excludeElementId?: string; - snapToStart?: boolean; - bookmarks?: Array<Bookmark>; - }): SnapResult => { - const snapPoints = findSnapPoints({ - tracks, - playheadTime, - excludeElementId, - bookmarks, - }); - - const effectiveTargetTime = snapToStart - ? targetTime - : targetTime + elementDuration; - const snapResult = snapToNearestPoint({ - targetTime: effectiveTargetTime, - snapPoints, - zoomLevel, - }); - - if (!snapToStart && snapResult.snapPoint) { - snapResult.snappedTime = snapResult.snappedTime - elementDuration; - } - - return snapResult; - }, - [findSnapPoints, snapToNearestPoint], - ); - - return { - snapElementEdge, - findSnapPoints, - snapToNearestPoint, - }; -} 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-preview-interaction.ts b/apps/web/src/hooks/use-preview-interaction.ts index 2bb33f21..d29f2a4b 100644 --- a/apps/web/src/hooks/use-preview-interaction.ts +++ b/apps/web/src/hooks/use-preview-interaction.ts @@ -4,9 +4,16 @@ import { useShiftKey } from "@/hooks/use-shift-key"; import type { TextElement, Transform } from "@/types/timeline"; import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds"; import { hitTest } from "@/lib/preview/hit-test"; -import { screenToCanvas } from "@/lib/preview/preview-coords"; +import { + screenPixelsToLogicalThreshold, + screenToCanvas, +} from "@/lib/preview/preview-coords"; import { isVisualElement } from "@/lib/timeline/element-utils"; -import { snapPosition, type SnapLine } from "@/lib/preview/preview-snap"; +import { + SNAP_THRESHOLD_SCREEN_PIXELS, + snapPosition, + type SnapLine, +} from "@/lib/preview/preview-snap"; const MIN_DRAG_DISTANCE = 0.5; @@ -230,11 +237,16 @@ export function usePreviewInteraction({ }; const shouldSnap = !isShiftHeldRef.current; + const snapThreshold = screenPixelsToLogicalThreshold({ + canvas: canvasRef.current, + screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, + }); const { snappedPosition, activeLines } = shouldSnap ? snapPosition({ proposedPosition, canvasSize, elementSize: dragStateRef.current.bounds, + snapThreshold, }) : { snappedPosition: proposedPosition, diff --git a/apps/web/src/hooks/use-transform-handles.ts b/apps/web/src/hooks/use-transform-handles.ts index 37ef6d5f..8be6420c 100644 --- a/apps/web/src/hooks/use-transform-handles.ts +++ b/apps/web/src/hooks/use-transform-handles.ts @@ -6,9 +6,13 @@ import { getVisibleElementsWithBounds, type ElementWithBounds, } from "@/lib/preview/element-bounds"; -import { screenToCanvas } from "@/lib/preview/preview-coords"; +import { + screenPixelsToLogicalThreshold, + screenToCanvas, +} from "@/lib/preview/preview-coords"; import { MIN_SCALE, + SNAP_THRESHOLD_SCREEN_PIXELS, snapRotation, snapScale, type SnapLine, @@ -228,6 +232,10 @@ export function useTransformHandles({ ); const canvasSize = editor.project.getActive().settings.canvasSize; + const snapThreshold = screenPixelsToLogicalThreshold({ + canvas: canvasRef.current, + screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, + }); const shouldSnap = !isShiftHeldRef.current; const { snappedScale, activeLines } = shouldSnap ? snapScale({ @@ -236,6 +244,7 @@ export function useTransformHandles({ baseWidth, baseHeight, canvasSize, + snapThreshold, }) : { snappedScale: proposedScale, activeLines: [] as SnapLine[] }; 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/index.ts b/apps/web/src/lib/animation/index.ts new file mode 100644 index 00000000..c41c1a7e --- /dev/null +++ b/apps/web/src/lib/animation/index.ts @@ -0,0 +1,39 @@ +export { + getChannelValueAtTime, + getNumberChannelValueAtTime, + normalizeChannel, +} from "./interpolation"; + +export { + clampAnimationsToDuration, + cloneAnimations, + getChannel, + removeElementKeyframe, + retimeElementKeyframe, + setChannel, + splitAnimationsAtTime, + upsertElementKeyframe, +} from "./keyframes"; + +export { + getElementLocalTime, + 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..d3f68010 --- /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, + }); + }, + }); +} + +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..56f156a4 --- /dev/null +++ b/apps/web/src/lib/animation/keyframes.ts @@ -0,0 +1,593 @@ +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..44f5d638 --- /dev/null +++ b/apps/web/src/lib/animation/property-registry.ts @@ -0,0 +1,230 @@ +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 { 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 }) => number | null; + setValue: ({ + element, + value, + }: { + element: TimelineElement; + value: number; + }) => 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 }, + }, + } + : 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 }, + }, + } + : 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 } } + : 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 } } + : 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 } : 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 } : 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 || typeof coercedValue !== "number") { + 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..58f292b1 --- /dev/null +++ b/apps/web/src/lib/animation/resolve.ts @@ -0,0 +1,111 @@ +import type { ElementAnimations } from "@/types/animation"; +import type { Transform } from "@/types/timeline"; +import { getNumberChannelValueAtTime } from "./interpolation"; +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 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 9e6ed8cd..7fb46c36 100644 --- a/apps/web/src/lib/commands/timeline/element/delete-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/delete-elements.ts @@ -5,12 +5,19 @@ 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 }[], - private rippleEnabled = false, - ) { + constructor({ + elements, + rippleEnabled = false, + }: { + elements: { trackId: string; elementId: string }[]; + rippleEnabled?: boolean; + }) { super(); + this.elements = elements; + this.rippleEnabled = rippleEnabled; } execute(): void { @@ -19,29 +26,29 @@ export class DeleteElementsCommand extends Command { const updatedTracks = this.savedState .map((track) => { - const elementsToDeleteOnTrack = this.elements.filter( - (target) => target.trackId === track.id, - ); - const hasElementsToDelete = elementsToDeleteOnTrack.length > 0; + const elementsToDeleteOnTrack = this.elements.filter( + (target) => target.trackId === track.id, + ); + const hasElementsToDelete = elementsToDeleteOnTrack.length > 0; - if (!hasElementsToDelete) { - return track; - } + if (!hasElementsToDelete) { + return 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 })); + 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, - ), - ); + 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( 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/index.ts b/apps/web/src/lib/commands/timeline/element/index.ts index 3f10baa4..69d94c24 100644 --- a/apps/web/src/lib/commands/timeline/element/index.ts +++ b/apps/web/src/lib/commands/timeline/element/index.ts @@ -9,3 +9,4 @@ 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"; 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..f221e33a --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/keyframes/index.ts @@ -0,0 +1,3 @@ +export * from "./remove-keyframe"; +export * from "./retime-keyframe"; +export * from "./upsert-keyframe"; 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-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..6aadcf16 100644 --- a/apps/web/src/lib/commands/timeline/element/move-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/move-elements.ts @@ -14,15 +14,31 @@ import { 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; - 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, + }: { + sourceTrackId: string; + targetTrackId: string; + elementId: string; + newStartTime: number; + createTrack?: { type: TrackType; index: number }; + }) { super(); + this.sourceTrackId = sourceTrackId; + this.targetTrackId = targetTrackId; + this.elementId = elementId; + this.newStartTime = newStartTime; + this.createTrack = createTrack; } execute(): void { @@ -30,10 +46,10 @@ 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) { @@ -41,7 +57,7 @@ export class MoveElementCommand extends Command { return; } - 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({ @@ -74,6 +90,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,12 +98,12 @@ 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, ), }; } @@ -94,7 +111,9 @@ export class MoveElementCommand extends Command { if (track.id === this.sourceTrackId) { return { ...track, - elements: track.elements.filter((el) => el.id !== this.elementId), + elements: track.elements.filter( + (trackElement) => trackElement.id !== this.elementId, + ), }; } @@ -105,8 +124,8 @@ export class MoveElementCommand extends Command { }; } - return track; - }) as TimelineTrack[]; + return track; + }); 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 05a23fff..37ccbb12 100644 --- a/apps/web/src/lib/commands/timeline/element/split-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/split-elements.ts @@ -3,19 +3,33 @@ 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", - private rippleEnabled = false, - ) { + 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 }[] { @@ -61,6 +75,11 @@ export class SplitElementsCommand extends Command { 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 [ @@ -69,6 +88,7 @@ export class SplitElementsCommand extends Command { duration: leftVisibleDuration, trimEnd: element.trimEnd + rightVisibleDuration, name: `${element.name} (left)`, + animations: leftAnimations, }, ]; } @@ -90,11 +110,13 @@ export class SplitElementsCommand extends Command { duration: rightVisibleDuration, trimStart: element.trimStart + leftVisibleDuration, name: `${element.name} (right)`, + animations: rightAnimations, }, ]; } - const secondElementId = generateUUID(); + // "both" - split into two pieces + const secondElementId = generateUUID(); this.rightSideElements.push({ trackId: track.id, elementId: secondElementId, @@ -106,6 +128,7 @@ export class SplitElementsCommand extends Command { duration: leftVisibleDuration, trimEnd: element.trimEnd + rightVisibleDuration, name: `${element.name} (left)`, + animations: leftAnimations, }, { ...element, @@ -114,14 +137,12 @@ export class SplitElementsCommand extends Command { duration: rightVisibleDuration, trimStart: element.trimStart + leftVisibleDuration, name: `${element.name} (right)`, + animations: rightAnimations, }, ]; }); - if ( - this.rippleEnabled && - leftVisibleDurationForRipple !== null - ) { + if (this.rippleEnabled && leftVisibleDurationForRipple !== null) { elements = rippleShiftElements({ elements, afterTime: this.splitTime, 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..6afcb9d0 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,35 @@ import { Command } from "@/lib/commands/base-command"; import type { TimelineTrack } from "@/types/timeline"; import { EditorCore } from "@/core"; +import { clampAnimationsToDuration } from "@/lib/animation"; 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; - constructor( - private elementId: string, - private trimStart: number, - private trimEnd: number, - private startTime?: number, - private duration?: number, - ) { + constructor({ + elementId, + trimStart, + trimEnd, + startTime, + duration, + }: { + elementId: string; + trimStart: number; + trimEnd: number; + startTime?: number; + duration?: number; + }) { super(); + this.elementId = elementId; + this.trimStart = trimStart; + this.trimEnd = trimEnd; + this.startTime = startTime; + this.duration = duration; } execute(): void { @@ -25,12 +42,17 @@ export class UpdateElementTrimCommand extends Command { return element; } + const nextDuration = this.duration ?? element.duration; return { ...element, trimStart: this.trimStart, trimEnd: this.trimEnd, startTime: this.startTime ?? element.startTime, - duration: this.duration ?? element.duration, + duration: nextDuration, + animations: clampAnimationsToDuration({ + animations: element.animations, + duration: nextDuration, + }), }; }); return { ...track, elements: newElements } as typeof track; 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/media/audio.ts b/apps/web/src/lib/media/audio.ts index 0b71cbaa..6f789301 100644 --- a/apps/web/src/lib/media/audio.ts +++ b/apps/web/src/lib/media/audio.ts @@ -8,19 +8,23 @@ import type { MediaAsset } from "@/types/assets"; import { canElementHaveAudio } from "@/lib/timeline/element-utils"; import { canTracktHaveAudio } from "@/lib/timeline"; import { mediaSupportsAudio } from "@/lib/media/media-utils"; +import { Input, ALL_FORMATS, BlobSource, AudioBufferSink } from "mediabunny"; + +const MAX_AUDIO_CHANNELS = 2; +const EXPORT_SAMPLE_RATE = 44100; export type CollectedAudioElement = Omit< AudioElement, "type" | "mediaId" | "volume" | "id" | "name" | "sourceType" | "sourceUrl" > & { buffer: AudioBuffer }; -export function createAudioContext(): AudioContext { +export function createAudioContext({ sampleRate }: { sampleRate?: number } = {}): AudioContext { const AudioContextConstructor = window.AudioContext || (window as typeof window & { webkitAudioContext?: typeof AudioContext }) .webkitAudioContext; - return new AudioContextConstructor(); + return new AudioContextConstructor(sampleRate ? { sampleRate } : undefined); } export interface DecodedAudio { @@ -170,12 +174,64 @@ async function resolveAudioBufferForVideoElement({ mediaAsset: MediaAsset; audioContext: AudioContext; }): Promise<AudioBuffer | null> { + const input = new Input({ + source: new BlobSource(mediaAsset.file), + formats: ALL_FORMATS, + }); + try { - const arrayBuffer = await mediaAsset.file.arrayBuffer(); - return await audioContext.decodeAudioData(arrayBuffer.slice(0)); + const audioTrack = await input.getPrimaryAudioTrack(); + if (!audioTrack) return null; + + const sink = new AudioBufferSink(audioTrack); + const targetSampleRate = audioContext.sampleRate; + + const chunks: AudioBuffer[] = []; + let totalSamples = 0; + + for await (const { buffer } of sink.buffers(0)) { + chunks.push(buffer); + totalSamples += buffer.length; + } + + if (chunks.length === 0) return null; + + const nativeSampleRate = chunks[0].sampleRate; + const numChannels = Math.min(MAX_AUDIO_CHANNELS, chunks[0].numberOfChannels); + + const nativeChannels = Array.from( + { length: numChannels }, + () => new Float32Array(totalSamples), + ); + let offset = 0; + for (const chunk of chunks) { + for (let channel = 0; channel < numChannels; channel++) { + const sourceData = chunk.getChannelData(Math.min(channel, chunk.numberOfChannels - 1)); + nativeChannels[channel].set(sourceData, offset); + } + offset += chunk.length; + } + + // use OfflineAudioContext for high-quality resampling to target rate + const outputSamples = Math.ceil(totalSamples * (targetSampleRate / nativeSampleRate)); + const offlineContext = new OfflineAudioContext(numChannels, outputSamples, targetSampleRate); + + const nativeBuffer = audioContext.createBuffer(numChannels, totalSamples, nativeSampleRate); + for (let ch = 0; ch < numChannels; ch++) { + nativeBuffer.copyToChannel(nativeChannels[ch], ch); + } + + const sourceNode = offlineContext.createBufferSource(); + sourceNode.buffer = nativeBuffer; + sourceNode.connect(offlineContext.destination); + sourceNode.start(0); + + return await offlineContext.startRendering(); } catch (error) { console.warn("Failed to decode video audio:", error); return null; + } finally { + input.dispose(); } } @@ -422,7 +478,7 @@ export async function createTimelineAudioBuffer({ tracks, mediaAssets, duration, - sampleRate = 44100, + sampleRate = EXPORT_SAMPLE_RATE, audioContext, }: { tracks: TimelineTrack[]; @@ -431,7 +487,7 @@ export async function createTimelineAudioBuffer({ sampleRate?: number; audioContext?: AudioContext; }): Promise<AudioBuffer | null> { - const context = audioContext ?? createAudioContext(); + const context = audioContext ?? createAudioContext({ sampleRate }); const audioElements = await collectAudioElements({ tracks, diff --git a/apps/web/src/lib/preview/element-bounds.ts b/apps/web/src/lib/preview/element-bounds.ts index ec26c14d..5b1f301d 100644 --- a/apps/web/src/lib/preview/element-bounds.ts +++ b/apps/web/src/lib/preview/element-bounds.ts @@ -2,9 +2,12 @@ import type { TimelineTrack, TimelineElement } from "@/types/timeline"; import type { MediaAsset } from "@/types/assets"; import { isMainTrack } from "@/lib/timeline"; import { + DEFAULT_TEXT_ELEMENT, DEFAULT_LINE_HEIGHT, FONT_SIZE_SCALE_REFERENCE, } from "@/constants/text-constants"; +import { getTextVisualRect, measureTextBlock } from "@/lib/text/layout"; +import { getElementLocalTime, resolveTransformAtTime } from "@/lib/animation"; export interface ElementBounds { cx: number; @@ -60,17 +63,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({ @@ -78,21 +88,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; @@ -121,37 +141,46 @@ export function getElementBounds({ const lines = element.content.split("\n"); const lineMetrics = lines.map((line) => ctx.measureText(line)); - - let top = Number.POSITIVE_INFINITY; - let bottom = Number.NEGATIVE_INFINITY; - let maxWidth = 0; - - for (let i = 0; i < lineMetrics.length; i++) { - const metrics = lineMetrics[i]; - const y = i * lineHeightPx; - top = Math.min( - top, - y - (metrics.actualBoundingBoxAscent ?? scaledFontSize * 0.8), - ); - bottom = Math.max( - bottom, - y + (metrics.actualBoundingBoxDescent ?? scaledFontSize * 0.2), - ); - maxWidth = Math.max(maxWidth, metrics.width); - } - - measuredWidth = maxWidth; - measuredHeight = bottom - top; + const block = measureTextBlock({ + lineMetrics, + lineHeightPx, + fallbackFontSize: scaledFontSize, + }); + const fontSizeRatio = element.fontSize / DEFAULT_TEXT_ELEMENT.fontSize; + const visualRect = getTextVisualRect({ + textAlign: element.textAlign, + block, + background: element.background, + fontSizeRatio, + }); + measuredWidth = visualRect.width; + measuredHeight = visualRect.height; + const localCenterX = visualRect.left + visualRect.width / 2; + const localCenterY = visualRect.top + visualRect.height / 2; + 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 + 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, }; } @@ -195,6 +224,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) @@ -203,6 +237,7 @@ export function getVisibleElementsWithBounds({ element, canvasSize, mediaAsset, + localTime, }); if (bounds) { result.push({ diff --git a/apps/web/src/lib/preview/preview-coords.ts b/apps/web/src/lib/preview/preview-coords.ts index 08c16735..dcc87282 100644 --- a/apps/web/src/lib/preview/preview-coords.ts +++ b/apps/web/src/lib/preview/preview-coords.ts @@ -74,3 +74,17 @@ export function getDisplayScale({ y: canvasRect.height / canvasSize.height, }; } + +export function screenPixelsToLogicalThreshold({ + canvas, + screenPixels, +}: { + canvas: HTMLCanvasElement; + screenPixels: number; +}): { x: number; y: number } { + const canvasRect = canvas.getBoundingClientRect(); + return { + x: screenPixels * (canvas.width / canvasRect.width), + y: screenPixels * (canvas.height / canvasRect.height), + }; +} diff --git a/apps/web/src/lib/preview/preview-snap.ts b/apps/web/src/lib/preview/preview-snap.ts index bd53f0ca..36efc3ce 100644 --- a/apps/web/src/lib/preview/preview-snap.ts +++ b/apps/web/src/lib/preview/preview-snap.ts @@ -3,10 +3,10 @@ export interface SnapLine { position: number; } -const SNAP_THRESHOLD = 10; const ROTATION_SNAP_STEP_DEGREES = 90; const ROTATION_SNAP_THRESHOLD_DEGREES = 5; export const MIN_SCALE = 0.01; +export const SNAP_THRESHOLD_SCREEN_PIXELS = 8; export interface SnapResult { snappedPosition: { x: number; y: number }; @@ -17,10 +17,12 @@ export function snapPosition({ proposedPosition, canvasSize, elementSize, + snapThreshold, }: { proposedPosition: { x: number; y: number }; canvasSize: { width: number; height: number }; elementSize: { width: number; height: number }; + snapThreshold: { x: number; y: number }; }): SnapResult { const centerX = 0; const centerY = 0; @@ -41,11 +43,13 @@ export function snapPosition({ function getClosestAxisSnap({ candidates, + threshold, }: { candidates: AxisSnapCandidate[]; + threshold: number; }): AxisSnapCandidate | null { const snapCandidatesWithinThreshold = candidates.filter( - (candidate) => candidate.distance <= SNAP_THRESHOLD, + (candidate) => candidate.distance <= threshold, ); if (snapCandidatesWithinThreshold.length === 0) { return null; @@ -95,8 +99,14 @@ export function snapPosition({ }); } - const closestX = getClosestAxisSnap({ candidates: xCandidates }); - const closestY = getClosestAxisSnap({ candidates: yCandidates }); + const closestX = getClosestAxisSnap({ + candidates: xCandidates, + threshold: snapThreshold.x, + }); + const closestY = getClosestAxisSnap({ + candidates: yCandidates, + threshold: snapThreshold.y, + }); const x = closestX?.snappedPosition ?? proposedPosition.x; const y = closestY?.snappedPosition ?? proposedPosition.y; @@ -124,12 +134,14 @@ export function snapScale({ baseWidth, baseHeight, canvasSize, + snapThreshold, }: { proposedScale: number; position: { x: number; y: number }; baseWidth: number; baseHeight: number; canvasSize: { width: number; height: number }; + snapThreshold: { x: number; y: number }; }): ScaleSnapResult { const centerX = 0; const centerY = 0; @@ -162,7 +174,7 @@ export function snapScale({ for (const target of verticalTargets) { const distanceLeft = Math.abs(leftEdge - target.position); - if (distanceLeft <= SNAP_THRESHOLD) { + if (distanceLeft <= snapThreshold.x) { const scale = (2 * (position.x - target.position)) / baseWidth; if (scale > MIN_SCALE) { candidates.push({ @@ -173,7 +185,7 @@ export function snapScale({ } } const distanceRight = Math.abs(rightEdge - target.position); - if (distanceRight <= SNAP_THRESHOLD) { + if (distanceRight <= snapThreshold.x) { const scale = (2 * (target.position - position.x)) / baseWidth; if (scale > MIN_SCALE) { candidates.push({ @@ -199,7 +211,7 @@ export function snapScale({ for (const target of horizontalTargets) { const distanceTop = Math.abs(topEdge - target.position); - if (distanceTop <= SNAP_THRESHOLD) { + if (distanceTop <= snapThreshold.y) { const scale = (2 * (position.y - target.position)) / baseHeight; if (scale > MIN_SCALE) { candidates.push({ @@ -210,7 +222,7 @@ export function snapScale({ } } const distanceBottom = Math.abs(bottomEdge - target.position); - if (distanceBottom <= SNAP_THRESHOLD) { + if (distanceBottom <= snapThreshold.y) { const scale = (2 * (target.position - position.y)) / baseHeight; if (scale > MIN_SCALE) { candidates.push({ diff --git a/apps/web/src/lib/text/layout.ts b/apps/web/src/lib/text/layout.ts new file mode 100644 index 00000000..8c38e9aa --- /dev/null +++ b/apps/web/src/lib/text/layout.ts @@ -0,0 +1,167 @@ +import { DEFAULT_TEXT_BACKGROUND } from "@/constants/text-constants"; +import type { TextElement } from "@/types/timeline"; + +type TextRect = { + left: number; + top: number; + width: number; + height: number; +}; + +export interface TextBlockMeasurement { + visualCenterOffset: number; + height: number; + maxWidth: number; +} + +export function getMetricAscent({ + metrics, + fallbackFontSize, +}: { + metrics: TextMetrics; + fallbackFontSize: number; +}): number { + return metrics.actualBoundingBoxAscent ?? fallbackFontSize * 0.8; +} + +export function getMetricDescent({ + metrics, + fallbackFontSize, +}: { + metrics: TextMetrics; + fallbackFontSize: number; +}): number { + return metrics.actualBoundingBoxDescent ?? fallbackFontSize * 0.2; +} + +export function measureTextBlock({ + lineMetrics, + lineHeightPx, + fallbackFontSize, +}: { + lineMetrics: TextMetrics[]; + lineHeightPx: number; + fallbackFontSize: number; +}): TextBlockMeasurement { + let top = Number.POSITIVE_INFINITY; + let bottom = Number.NEGATIVE_INFINITY; + let maxWidth = 0; + + for (let index = 0; index < lineMetrics.length; index++) { + const metrics = lineMetrics[index]; + const lineY = index * lineHeightPx; + top = Math.min( + top, + lineY - getMetricAscent({ metrics, fallbackFontSize }), + ); + bottom = Math.max( + bottom, + lineY + getMetricDescent({ metrics, fallbackFontSize }), + ); + maxWidth = Math.max(maxWidth, metrics.width); + } + + const height = bottom - top; + const visualCenterOffset = (top + bottom) / 2; + + return { visualCenterOffset, height, maxWidth }; +} + +function getTextRect({ + textAlign, + block, +}: { + textAlign: TextElement["textAlign"]; + block: TextBlockMeasurement; +}): TextRect { + const left = + textAlign === "left" ? 0 : textAlign === "right" ? -block.maxWidth : -block.maxWidth / 2; + + return { + left, + top: -block.height / 2, + width: block.maxWidth, + height: block.height, + }; +} + +function isTextBackgroundVisible({ + background, +}: { + background: TextElement["background"]; +}): boolean { + return Boolean(background.color) && background.color !== "transparent"; +} + +export function getTextBackgroundRect({ + textAlign, + block, + background, + fontSizeRatio = 1, +}: { + textAlign: TextElement["textAlign"]; + block: TextBlockMeasurement; + background: TextElement["background"]; + fontSizeRatio?: number; +}): TextRect | null { + if (!isTextBackgroundVisible({ background })) { + return null; + } + + const textRect = getTextRect({ textAlign, block }); + const paddingX = + (background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX) * fontSizeRatio; + const paddingY = + (background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY) * fontSizeRatio; + const offsetX = background.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX; + const offsetY = background.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY; + + return { + left: textRect.left - paddingX + offsetX, + top: textRect.top - paddingY + offsetY, + width: textRect.width + paddingX * 2, + height: textRect.height + paddingY * 2, + }; +} + +export function getTextVisualRect({ + textAlign, + block, + background, + fontSizeRatio = 1, +}: { + textAlign: TextElement["textAlign"]; + block: TextBlockMeasurement; + background: TextElement["background"]; + fontSizeRatio?: number; +}): TextRect { + const textRect = getTextRect({ textAlign, block }); + const backgroundRect = getTextBackgroundRect({ + textAlign, + block, + background, + fontSizeRatio, + }); + + if (!backgroundRect) { + return textRect; + } + + const left = Math.min(textRect.left, backgroundRect.left); + const top = Math.min(textRect.top, backgroundRect.top); + const right = Math.max( + textRect.left + textRect.width, + backgroundRect.left + backgroundRect.width, + ); + const bottom = Math.max( + textRect.top + textRect.height, + backgroundRect.top + backgroundRect.height, + ); + + return { + left, + top, + width: right - left, + height: bottom - top, + }; +} 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 fa74b14e..8c02c097 100644 --- a/apps/web/src/lib/timeline/element-utils.ts +++ b/apps/web/src/lib/timeline/element-utils.ts @@ -6,6 +6,7 @@ import { TIMELINE_CONSTANTS, } from "@/constants/timeline-constants"; import type { + CreateEffectElement, CreateTimelineElement, CreateVideoElement, CreateImageElement, @@ -18,10 +19,12 @@ import type { 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 +34,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,7 +45,7 @@ export function isVisualElement( export function canElementBeHidden( element: TimelineElement, -): element is VideoElement | ImageElement | TextElement | StickerElement { +): element is VisualElement { return element.type !== "audio"; } @@ -154,7 +157,14 @@ export function buildTextElement({ : DEFAULT_TEXT_ELEMENT.fontSize, fontFamily: t.fontFamily ?? DEFAULT_TEXT_ELEMENT.fontFamily, color: t.color ?? DEFAULT_TEXT_ELEMENT.color, - backgroundColor: t.backgroundColor ?? DEFAULT_TEXT_ELEMENT.backgroundColor, + 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, + }, textAlign: t.textAlign ?? DEFAULT_TEXT_ELEMENT.textAlign, fontWeight: t.fontWeight ?? DEFAULT_TEXT_ELEMENT.fontWeight, fontStyle: t.fontStyle ?? DEFAULT_TEXT_ELEMENT.fontStyle, @@ -167,6 +177,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, @@ -211,6 +243,7 @@ export function buildVideoElement({ startTime, trimStart: 0, trimEnd: 0, + sourceDuration: duration, muted: false, hidden: false, transform: { ...DEFAULT_TRANSFORM }, @@ -267,6 +300,7 @@ export function buildUploadAudioElement({ startTime, trimStart: 0, trimEnd: 0, + sourceDuration: duration, volume: 1, muted: false, }; @@ -329,6 +363,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 b5e74a45..887ff7ab 100644 --- a/apps/web/src/lib/timeline/index.ts +++ b/apps/web/src/lib/timeline/index.ts @@ -1,10 +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/snap-utils.ts b/apps/web/src/lib/timeline/snap-utils.ts new file mode 100644 index 00000000..c7fac090 --- /dev/null +++ b/apps/web/src/lib/timeline/snap-utils.ts @@ -0,0 +1,172 @@ +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" | "keyframe"; + elementId?: string; + trackId?: string; +} + +export interface SnapResult { + snappedTime: number; + snapPoint: SnapPoint | null; + snapDistance: number; +} + +const DEFAULT_SNAP_THRESHOLD_PX = 10; + +export function findSnapPoints({ + tracks, + playheadTime, + excludeElementId, + bookmarks = [], + excludeBookmarkTime, + enableElementSnapping = true, + enablePlayheadSnapping = true, + enableBookmarkSnapping = true, + enableKeyframeSnapping = true, +}: { + tracks: Array<TimelineTrack>; + playheadTime: number; + excludeElementId?: string; + bookmarks?: Array<Bookmark>; + excludeBookmarkTime?: number; + enableElementSnapping?: boolean; + enablePlayheadSnapping?: boolean; + enableBookmarkSnapping?: boolean; + enableKeyframeSnapping?: boolean; +}): SnapPoint[] { + const snapPoints: SnapPoint[] = []; + + for (const track of tracks) { + for (const element of track.elements) { + if (element.id === excludeElementId) continue; + + if (enableElementSnapping) { + snapPoints.push( + { + time: element.startTime, + type: "element-start", + elementId: element.id, + trackId: track.id, + }, + { + time: element.startTime + element.duration, + type: "element-end", + elementId: element.id, + trackId: track.id, + }, + ); + } + + 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, + }); + } + } + } + } + + if (enablePlayheadSnapping) { + snapPoints.push({ time: playheadTime, type: "playhead" }); + } + + if (enableBookmarkSnapping) { + for (const bookmark of bookmarks) { + if ( + excludeBookmarkTime != null && + Math.abs(bookmark.time - excludeBookmarkTime) < BOOKMARK_TIME_EPSILON + ) { + continue; + } + snapPoints.push({ time: bookmark.time, type: "bookmark" }); + } + } + + return snapPoints; +} + +export function snapToNearestPoint({ + targetTime, + snapPoints, + zoomLevel, + snapThreshold = DEFAULT_SNAP_THRESHOLD_PX, +}: { + targetTime: number; + snapPoints: Array<SnapPoint>; + zoomLevel: number; + snapThreshold?: number; +}): SnapResult { + const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const thresholdInSeconds = snapThreshold / pixelsPerSecond; + + let closestSnapPoint: SnapPoint | null = null; + let closestDistance = Infinity; + + for (const snapPoint of snapPoints) { + const distance = Math.abs(targetTime - snapPoint.time); + if (distance < thresholdInSeconds && distance < closestDistance) { + closestDistance = distance; + closestSnapPoint = snapPoint; + } + } + + return { + snappedTime: closestSnapPoint ? closestSnapPoint.time : targetTime, + snapPoint: closestSnapPoint, + snapDistance: closestDistance, + }; +} + +export function snapElementEdge({ + targetTime, + elementDuration, + tracks, + playheadTime, + zoomLevel, + excludeElementId, + snapToStart = true, + bookmarks = [], +}: { + targetTime: number; + elementDuration: number; + tracks: Array<TimelineTrack>; + playheadTime: number; + zoomLevel: number; + excludeElementId?: string; + snapToStart?: boolean; + bookmarks?: Array<Bookmark>; +}): SnapResult { + const snapPoints = findSnapPoints({ + tracks, + playheadTime, + excludeElementId, + bookmarks, + }); + + const effectiveTargetTime = snapToStart + ? targetTime + : targetTime + elementDuration; + + const snapResult = snapToNearestPoint({ + targetTime: effectiveTargetTime, + snapPoints, + zoomLevel, + }); + + if (!snapToStart && snapResult.snapPoint) { + snapResult.snappedTime = snapResult.snappedTime - elementDuration; + } + + return snapResult; +} 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 49e84ebe..6720a5e5 100644 --- a/apps/web/src/services/renderer/nodes/image-node.ts +++ b/apps/web/src/services/renderer/nodes/image-node.ts @@ -3,49 +3,88 @@ import { VisualNode, type VisualNodeParams } from "./visual-node"; export interface ImageNodeParams extends VisualNodeParams { url: string; + maxSourceSize?: number; } -export class ImageNode extends VisualNode<ImageNodeParams> { - private image?: HTMLImageElement; - private readyPromise: Promise<void>; +interface CachedImageSource { + source: HTMLImageElement | OffscreenCanvas; + width: number; + height: number; +} - constructor(params: ImageNodeParams) { - super(params); - this.readyPromise = this.load(); - } +const imageSourceCache = new Map<string, Promise<CachedImageSource>>(); + +function loadImageSource( + url: string, + maxSourceSize?: number, +): Promise<CachedImageSource> { + const cacheKey = `${url}::${maxSourceSize ?? "full"}`; - private async load() { + const cached = imageSourceCache.get(cacheKey); + if (cached) return cached; + + const promise = (async (): Promise<CachedImageSource> => { const image = new Image(); - this.image = image; await new Promise<void>((resolve, reject) => { image.onload = () => resolve(); image.onerror = () => reject(new Error("Image load failed")); - image.src = this.params.url; + image.src = url; }); - } - async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) { - await super.render({ renderer, time }); + const naturalWidth = image.naturalWidth; + const naturalHeight = image.naturalHeight; + const exceedsLimit = + maxSourceSize && + (naturalWidth > maxSourceSize || naturalHeight > maxSourceSize); - if (!this.isInRange(time)) { - return; + if (exceedsLimit) { + const scale = Math.min( + maxSourceSize / naturalWidth, + maxSourceSize / naturalHeight, + ); + const scaledWidth = Math.round(naturalWidth * scale); + const scaledHeight = Math.round(naturalHeight * scale); + + const offscreen = new OffscreenCanvas(scaledWidth, scaledHeight); + const ctx = offscreen.getContext("2d"); + + if (ctx) { + ctx.drawImage(image, 0, 0, scaledWidth, scaledHeight); + return { source: offscreen, width: scaledWidth, height: scaledHeight }; + } } - await this.readyPromise; + return { source: image, width: naturalWidth, height: naturalHeight }; + })(); + + imageSourceCache.set(cacheKey, promise); + return promise; +} + +export class ImageNode extends VisualNode<ImageNodeParams> { + private cachedSource: Promise<CachedImageSource>; + + constructor(params: ImageNodeParams) { + super(params); + this.cachedSource = loadImageSource(params.url, params.maxSourceSize); + } + + async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) { + await super.render({ renderer, time }); - if (!this.image) { + if (!this.isInRange({ time })) { return; } - const mediaW = this.image.naturalWidth || renderer.width; - const mediaH = this.image.naturalHeight || renderer.height; + const { source, width, height } = await this.cachedSource; this.renderVisual({ renderer, - source: this.image, - sourceWidth: mediaW, - sourceHeight: mediaH, + 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 a2fb0170..da271685 100644 --- a/apps/web/src/services/renderer/nodes/sticker-node.ts +++ b/apps/web/src/services/renderer/nodes/sticker-node.ts @@ -6,49 +6,63 @@ export interface StickerNodeParams extends VisualNodeParams { stickerId: string; } -export class StickerNode extends VisualNode<StickerNodeParams> { - private image?: HTMLImageElement; - private readyPromise: Promise<void>; +interface CachedStickerSource { + source: HTMLImageElement; + width: number; + height: number; +} - constructor(params: StickerNodeParams) { - super(params); - this.readyPromise = this.load(); - } +const stickerSourceCache = new Map<string, Promise<CachedStickerSource>>(); - private async load() { - const image = new Image(); - this.image = image; +function loadStickerSource(stickerId: string): Promise<CachedStickerSource> { + const cached = stickerSourceCache.get(stickerId); + if (cached) return cached; + + const promise = (async (): Promise<CachedStickerSource> => { const url = resolveStickerId({ - stickerId: this.params.stickerId, + stickerId, options: { width: 200, height: 200 }, }); + const image = new Image(); + await new Promise<void>((resolve, reject) => { image.onload = () => resolve(); image.onerror = () => - reject(new Error(`Failed to load sticker: ${this.params.stickerId}`)); + reject(new Error(`Failed to load sticker: ${stickerId}`)); image.src = url; }); + + return { source: image, width: 200, height: 200 }; + })(); + + stickerSourceCache.set(stickerId, promise); + return promise; +} + +export class StickerNode extends VisualNode<StickerNodeParams> { + private cachedSource: Promise<CachedStickerSource>; + + constructor(params: StickerNodeParams) { + super(params); + this.cachedSource = loadStickerSource(params.stickerId); } async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) { await super.render({ renderer, time }); - if (!this.isInRange(time)) { + if (!this.isInRange({ time })) { return; } - await this.readyPromise; - - if (!this.image) { - return; - } + const { source, width, height } = await this.cachedSource; this.renderVisual({ renderer, - source: this.image, - sourceWidth: 200, - sourceHeight: 200, + 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 b7e36d64..0ddb4eae 100644 --- a/apps/web/src/services/renderer/nodes/text-node.ts +++ b/apps/web/src/services/renderer/nodes/text-node.ts @@ -2,9 +2,21 @@ import type { CanvasRenderer } from "../canvas-renderer"; import { BaseNode } from "./base-node"; import type { TextElement } from "@/types/timeline"; import { + DEFAULT_TEXT_ELEMENT, DEFAULT_LINE_HEIGHT, FONT_SIZE_SCALE_REFERENCE, } from "@/constants/text-constants"; +import { + getMetricAscent, + getMetricDescent, + getTextBackgroundRect, + measureTextBlock, +} from "@/lib/text/layout"; +import { + getElementLocalTime, + resolveOpacityAtTime, + resolveTransformAtTime, +} from "@/lib/animation"; function scaleFontSize({ fontSize, @@ -20,65 +32,6 @@ function quoteFontFamily({ fontFamily }: { fontFamily: string }): string { return `"${fontFamily.replace(/"/g, '\\"')}"`; } -function getMetricAscent({ - metrics, - fallback, -}: { - metrics: TextMetrics; - fallback: number; -}): number { - return metrics.actualBoundingBoxAscent ?? fallback * 0.8; -} - -function getMetricDescent({ - metrics, - fallback, -}: { - metrics: TextMetrics; - fallback: number; -}): number { - return metrics.actualBoundingBoxDescent ?? fallback * 0.2; -} - -interface TextBlockMeasurement { - visualCenterOffset: number; - height: number; - maxWidth: number; -} - -function measureTextBlock({ - lineMetrics, - lineHeightPx, - fallbackFontSize, -}: { - lineMetrics: TextMetrics[]; - lineHeightPx: number; - fallbackFontSize: number; -}): TextBlockMeasurement { - let top = Number.POSITIVE_INFINITY; - let bottom = Number.NEGATIVE_INFINITY; - let maxWidth = 0; - - for (let i = 0; i < lineMetrics.length; i++) { - const metrics = lineMetrics[i]; - const y = i * lineHeightPx; - top = Math.min( - top, - y - getMetricAscent({ metrics, fallback: fallbackFontSize }), - ); - bottom = Math.max( - bottom, - y + getMetricDescent({ metrics, fallback: fallbackFontSize }), - ); - maxWidth = Math.max(maxWidth, metrics.width); - } - - const height = bottom - top; - const visualCenterOffset = (top + bottom) / 2; - - return { visualCenterOffset, height, maxWidth }; -} - function drawTextDecoration({ ctx, textDecoration, @@ -99,8 +52,8 @@ function drawTextDecoration({ if (textDecoration === "none" || !textDecoration) return; const thickness = Math.max(1, scaledFontSize * 0.07); - const ascent = getMetricAscent({ metrics, fallback: scaledFontSize }); - const descent = getMetricDescent({ metrics, fallback: scaledFontSize }); + const ascent = getMetricAscent({ metrics, fallbackFontSize: scaledFontSize }); + const descent = getMetricDescent({ metrics, fallbackFontSize: scaledFontSize }); let xStart = -lineWidth / 2; if (textAlign === "left") xStart = 0; @@ -138,16 +91,28 @@ export class TextNode extends BaseNode<TextNodeParams> { 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, + }); + const x = transform.position.x + this.params.canvasCenter.x; + const y = transform.position.y + this.params.canvasCenter.y; 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); + renderer.context.scale(transform.scale, transform.scale); + if (transform.rotate) { + renderer.context.rotate((transform.rotate * Math.PI) / 180); } const fontWeight = this.params.fontWeight === "bold" ? "bold" : "normal"; @@ -171,6 +136,7 @@ export class TextNode extends BaseNode<TextNodeParams> { 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"; renderer.context.textBaseline = baseline; @@ -189,24 +155,33 @@ export class TextNode extends BaseNode<TextNodeParams> { ? this.params.blendMode : "source-over" ) as GlobalCompositeOperation; - renderer.context.globalAlpha = this.params.opacity; + renderer.context.globalAlpha = opacity; - if (this.params.backgroundColor && lineCount > 0) { - const padX = 8; - const padY = 4; - renderer.context.fillStyle = this.params.backgroundColor; - let bgLeft = -block.maxWidth / 2; - if (renderer.context.textAlign === "left") bgLeft = 0; - if (renderer.context.textAlign === "right") bgLeft = -block.maxWidth; - - renderer.context.fillRect( - bgLeft - padX, - -block.height / 2 - padY, - block.maxWidth + padX * 2, - block.height + padY * 2, - ); - - renderer.context.fillStyle = this.params.color; + 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; + } } for (let i = 0; i < lineCount; i++) { 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..15a7b081 100644 --- a/apps/web/src/services/renderer/nodes/visual-node.ts +++ b/apps/web/src/services/renderer/nodes/visual-node.ts @@ -1,9 +1,18 @@ 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 { 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 +20,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 +54,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 +99,58 @@ 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 definition = getEffect({ effectType: effect.type }); + const passes = definition.renderer.passes.map((pass) => ({ + fragmentShader: pass.fragmentShader, + uniforms: pass.uniforms({ + effectParams: effect.params, + 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 ee7f3d6c..695e077f 100644 --- a/apps/web/src/services/renderer/scene-builder.ts +++ b/apps/web/src/services/renderer/scene-builder.ts @@ -6,48 +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"; -export type BuildSceneParams = { - canvasSize: TCanvasSize; - tracks: TimelineTrack[]; - mediaAssets: MediaAsset[]; - duration: number; - background: TBackground; -}; - -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])); - - const visibleTracks = tracks.filter( - (track) => !("hidden" in track && track.hidden), - ); +const PREVIEW_MAX_IMAGE_SIZE = 2048; +const BLUR_BACKGROUND_ZOOM_SCALE = 1.4; - const orderedTracksTopToBottom = [ - ...visibleTracks.filter((track) => !isMainTrack(track)), - ...visibleTracks.filter((track) => isMainTrack(track)), - ]; - - const orderedTracksBottomToTop = orderedTracksTopToBottom.slice().reverse(); +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); + }); +} - const contentNodes = []; +function buildTrackNodes({ + tracks, + mediaMap, + canvasSize, + isPreview, +}: { + tracks: TimelineTrack[]; + mediaMap: Map<string, MediaAsset>; + canvasSize: TCanvasSize; + isPreview?: boolean; +}): BaseNode[] { + const nodes: BaseNode[] = []; - 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) { @@ -55,7 +66,7 @@ export function buildScene(params: BuildSceneParams) { } if (mediaAsset.type === "video") { - contentNodes.push( + nodes.push( new VideoNode({ mediaId: mediaAsset.id, url: mediaAsset.url, @@ -65,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, @@ -79,26 +92,32 @@ 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, + ...(isPreview && { + maxSourceSize: PREVIEW_MAX_IMAGE_SIZE, + }), }), ); } } 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, @@ -106,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/scene-exporter.ts b/apps/web/src/services/renderer/scene-exporter.ts index 39e94e55..a715efe7 100644 --- a/apps/web/src/services/renderer/scene-exporter.ts +++ b/apps/web/src/services/renderer/scene-exporter.ts @@ -13,11 +13,9 @@ import { QUALITY_VERY_HIGH, } from "mediabunny"; import type { RootNode } from "./nodes/root-node"; +import type { ExportFormat, ExportQuality } from "@/types/export"; import { CanvasRenderer } from "./canvas-renderer"; -export type ExportFormat = "mp4" | "webm"; -export type ExportQuality = "low" | "medium" | "high" | "very_high"; - type ExportParams = { width: number; height: number; 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/index.ts b/apps/web/src/services/storage/migrations/index.ts index 00c009c7..86a202b2 100644 --- a/apps/web/src/services/storage/migrations/index.ts +++ b/apps/web/src/services/storage/migrations/index.ts @@ -5,10 +5,12 @@ import { V2toV3Migration } from "./v2-to-v3"; import { V3toV4Migration } from "./v3-to-v4"; import { V4toV5Migration } from "./v4-to-v5"; import { V5toV6Migration } from "./v5-to-v6"; +import { V6toV7Migration } from "./v6-to-v7"; +import { V7toV8Migration } from "./v7-to-v8"; export { runStorageMigrations } from "./runner"; export type { MigrationProgress } from "./runner"; -export const CURRENT_PROJECT_VERSION = 6; +export const CURRENT_PROJECT_VERSION = 8; export const migrations = [ new V0toV1Migration(), @@ -17,4 +19,6 @@ export const migrations = [ new V3toV4Migration(), new V4toV5Migration(), new V5toV6Migration(), + new V6toV7Migration(), + new V7toV8Migration(), ]; 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..d16106d9 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 @@ -450,10 +450,17 @@ function transformTextTrack({ value: textElement.color, fallback: "#000000", }), - backgroundColor: getStringValue({ + background: { + color: getStringValue({ value: textElement.backgroundColor, - fallback: "#FFFFFF", + fallback: "transparent", }), + cornerRadius: 0, + paddingX: 8, + paddingY: 4, + offsetX: 0, + offsetY: 0, + }, textAlign: (getStringValue({ value: textElement.textAlign, fallback: "left", diff --git a/apps/web/src/services/storage/migrations/transformers/v6-to-v7.ts b/apps/web/src/services/storage/migrations/transformers/v6-to-v7.ts new file mode 100644 index 00000000..6f4848d4 --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v6-to-v7.ts @@ -0,0 +1,106 @@ +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +export function transformProjectV6ToV7({ + project, +}: { + project: ProjectRecord; +}): MigrationResult<ProjectRecord> { + const projectId = getProjectId({ project }); + if (!projectId) { + return { project, skipped: true, reason: "no project id" }; + } + + if (isV7Project({ project })) { + return { project, skipped: true, reason: "already v7" }; + } + + const migratedProject = migrateProjectTextElements({ project }); + + return { + project: { ...migratedProject, version: 7 }, + 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; + + 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; + if (isRecord(element.background)) return element; + + const backgroundColor = + typeof element.backgroundColor === "string" + ? element.backgroundColor + : "transparent"; + + const { backgroundColor: _removed, ...rest } = element; + + return { + ...rest, + background: { + color: backgroundColor, + cornerRadius: 0, + paddingX: 8, + paddingY: 4, + offsetX: 0, + offsetY: 0, + }, + }; +} + +function isV7Project({ project }: { project: ProjectRecord }): boolean { + return typeof project.version === "number" && project.version >= 7; +} 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/v6-to-v7.ts b/apps/web/src/services/storage/migrations/v6-to-v7.ts new file mode 100644 index 00000000..0556b4d5 --- /dev/null +++ b/apps/web/src/services/storage/migrations/v6-to-v7.ts @@ -0,0 +1,16 @@ +import { StorageMigration } from "./base"; +import type { ProjectRecord } from "./transformers/types"; +import { transformProjectV6ToV7 } from "./transformers/v6-to-v7"; + +export class V6toV7Migration extends StorageMigration { + from = 6; + to = 7; + + async transform(project: ProjectRecord): Promise<{ + project: ProjectRecord; + skipped: boolean; + reason?: string; + }> { + return transformProjectV6ToV7({ project }); + } +} 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/types/animation.ts b/apps/web/src/types/animation.ts new file mode 100644 index 00000000..b9ca91af --- /dev/null +++ b/apps/web/src/types/animation.ts @@ -0,0 +1,89 @@ +export const ANIMATION_PROPERTY_PATHS = [ + "transform.position.x", + "transform.position.y", + "transform.scale", + "transform.rotate", + "opacity", + "volume", +] 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 NumberKeyframeInterpolation = "linear" | "hold"; +export type ColorKeyframeInterpolation = "linear" | "hold"; +export type DiscreteKeyframeInterpolation = "hold"; +export type AnimationInterpolation = + | NumberKeyframeInterpolation + | ColorKeyframeInterpolation + | 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, NumberKeyframeInterpolation> {} + +export interface ColorKeyframe + extends BaseAnimationKeyframe<string, ColorKeyframeInterpolation> {} + +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..cc369450 --- /dev/null +++ b/apps/web/src/types/effects.ts @@ -0,0 +1,45 @@ +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>; + +export interface EffectParamDefinition { + key: string; + label: string; + type: EffectParamType; + default: number | string | boolean; + min?: number; + max?: number; + step?: number; + options?: Array<{ value: string; label: string }>; +} + +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/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/rendering.ts b/apps/web/src/types/rendering.ts index f5e18502..8c9763df 100644 --- a/apps/web/src/types/rendering.ts +++ b/apps/web/src/types/rendering.ts @@ -1,3 +1,12 @@ +export interface Transform { + scale: number; + position: { + x: number; + y: number; + }; + rotate: number; +} + export type BlendMode = | "normal" | "darken" diff --git a/apps/web/src/types/timeline.ts b/apps/web/src/types/timeline.ts index da96c7de..0c138f56 100644 --- a/apps/web/src/types/timeline.ts +++ b/apps/web/src/types/timeline.ts @@ -1,4 +1,6 @@ -import type { BlendMode } from "./rendering"; +import type { ElementAnimations } from "./animation"; +import type { Effect, EffectParamValues } from "./effects"; +import type { BlendMode, Transform } from "./rendering"; export interface Bookmark { time: number; @@ -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,17 +52,21 @@ export interface StickerTrack extends BaseTrack { hidden: boolean; } -export type TimelineTrack = VideoTrack | TextTrack | AudioTrack | StickerTrack; - -export interface Transform { - scale: number; - position: { - x: number; - y: number; - }; - rotate: number; +export interface EffectTrack extends BaseTrack { + type: "effect"; + elements: EffectElement[]; + hidden: boolean; } +export type TimelineTrack = + | VideoTrack + | TextTrack + | AudioTrack + | StickerTrack + | EffectTrack; + +export type { Transform } from "./rendering"; + interface BaseAudioElement extends BaseTimelineElement { type: "audio"; volume: number; @@ -87,6 +93,8 @@ interface BaseTimelineElement { startTime: number; trimStart: number; trimEnd: number; + sourceDuration?: number; + animations?: ElementAnimations; } export interface VideoElement extends BaseTimelineElement { @@ -97,6 +105,7 @@ export interface VideoElement extends BaseTimelineElement { transform: Transform; opacity: number; blendMode?: BlendMode; + effects?: Effect[]; } export interface ImageElement extends BaseTimelineElement { @@ -106,6 +115,7 @@ export interface ImageElement extends BaseTimelineElement { transform: Transform; opacity: number; blendMode?: BlendMode; + effects?: Effect[]; } export interface TextElement extends BaseTimelineElement { @@ -114,7 +124,14 @@ export interface TextElement extends BaseTimelineElement { fontSize: number; fontFamily: string; color: string; - backgroundColor: string; + background: { + color: string; + cornerRadius?: number; + paddingX?: number; + paddingY?: number; + offsetX?: number; + offsetY?: number; + }; textAlign: "left" | "center" | "right"; fontWeight: "normal" | "bold"; fontStyle: "normal" | "italic"; @@ -125,6 +142,7 @@ export interface TextElement extends BaseTimelineElement { transform: Transform; opacity: number; blendMode?: BlendMode; + effects?: Effect[]; } export interface StickerElement extends BaseTimelineElement { @@ -134,14 +152,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 +191,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 +217,7 @@ export interface DropTarget { isNewTrack: boolean; insertPosition: "above" | "below" | null; xPosition: number; + targetElement: { elementId: string; trackId: string } | null; } export interface ComputeDropTargetParams { @@ -193,6 +233,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, }: { diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index a8b420f7..3573338a 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -19,7 +19,8 @@ } ], "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "content-collections": ["./.content-collections/generated"] }, "forceConsistentCasingInFileNames": true }, |
