diff --git a/.cursor/commands/review.md b/.cursor/commands/review.md index ab36f7c4..4361b397 100644 --- a/.cursor/commands/review.md +++ b/.cursor/commands/review.md @@ -37,6 +37,7 @@ Review every point below carefully to ensure files follow consistent code style - [ ] Each file has one single purpose/responsibility - Example: `timeline/index.tsx` should not define `validateElementTrackCompatibility` — that belongs in a lib file - Example: `lib/timeline-utils.ts` should not declare `TRACK_COLORS` — that belongs in `constants/` +- [ ] File name accurately reflects what the file contains — a misleading name is a bug waiting to happen - [ ] Business logic lives in either `src/lib`, `src/core` or `src/services` folder ## Comments @@ -133,8 +134,8 @@ Do NOT review by reading the file top-to-bottom and noting what jumps out. Inste 3. Only move to the next section after you've exhausted the current one 4. After all sections are checked, do a final pass: re-read every checklist item and confirm you didn't skip it -Before outputting the table, list each checklist section and confirm you checked it: -`Signatures ✓ | TypeScript ✓ | JSX ✓ | Organization ✓ | Comments ✓ | Naming ✓ | Tailwind ✓ | State ✓ | Quality ✓ | Keywords ✓` +Before outputting the review, list each checklist section and confirm you checked it: +`Signatures ✓ | TypeScript ✓ | JSX ✓ | Organization ✓ | File Names ✓ | Comments ✓ | Naming ✓ | Tailwind ✓ | State ✓ | Quality ✓ | Keywords ✓` --- @@ -142,7 +143,22 @@ Before outputting the table, list each checklist section and confirm you checked - **ONLY** flag issues that are explicitly covered by a checklist item above. - Do **NOT** invent your own rules, suggestions, or "nice to haves" as primary issues. -- The review output must be a table of issues, each one mapping to a specific checklist item. +- The review output must be a list of issues, each one mapping to a specific checklist item. - If something looks off but isn't covered by the checklist, you can mention it as a brief side note at the end — but keep it clearly separate from the actual review. Always default to fixing the issues covered by the checklist above, unless the user says otherwise. > You WILL miss things if you try to review the whole file in one pass. Iterate rule by rule. + +--- + +## Think Bigger + +After the checklist review, step back and ask the hard questions. The biggest architectural problems get solved by the biggest questions. + +- Does this abstraction actually need to exist? Could it be deleted entirely? +- Is this the right layer for this logic? (wrong layer = future pain) +- Is this solving a real problem, or a problem we invented? +- Would a simpler data model make this whole file unnecessary? +- Are we adding complexity to work around a bad decision made earlier? +- Could this field be derived from other existing fields? Redundant data in a model is a source of bugs. + +Don't be shy about flagging these. A "why does this exist?" question is often worth more than 10 style fixes. diff --git a/.cursor/rules/ultracite.mdc b/.cursor/rules/ultracite.mdc index 977f4363..3dcaabaa 100644 --- a/.cursor/rules/ultracite.mdc +++ b/.cursor/rules/ultracite.mdc @@ -28,7 +28,6 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c ### Accessibility (a11y) - Always include a `title` element for icons unless there's text beside the icon. -- Always include a `type` attribute for button elements. - Accompany `onClick` with at least one of: `onKeyUp`, `onKeyDown`, or `onKeyPress`. - Accompany `onMouseOver`/`onMouseOut` with `onFocus`/`onBlur`. diff --git a/.cursor/skills/design/SKILL.md b/.cursor/skills/design/SKILL.md new file mode 100644 index 00000000..d5a05e27 --- /dev/null +++ b/.cursor/skills/design/SKILL.md @@ -0,0 +1,45 @@ +--- +name: frontend-design +description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics. +license: Complete terms in LICENSE.txt +--- + +This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices. + +The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints. + +## Design Thinking + +Before coding, understand the context and commit to a BOLD aesthetic direction: + +- **Purpose**: What problem does this interface solve? Who uses it? +- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction. +- **Constraints**: Technical requirements (framework, performance, accessibility). +- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember? + +**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity. + +Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is: + +- Production-grade and functional +- Visually striking and memorable +- Cohesive with a clear aesthetic point-of-view +- Meticulously refined in every detail + +## Frontend Aesthetics Guidelines + +Focus on: + +- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font. +- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes. +- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise. +- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density. +- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays. + +NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character. + +Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations. + +**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well. + +Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..25735115 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +node_modules +.next +.git +.gitignore +*.md +.env* +!.env.example +.cursor +.vscode +diffs +docs +agent-transcripts +mcps +terminals +apps/web/.font-cache diff --git a/.gitignore b/.gitignore index e2b17bce..fa3b5f68 100644 --- a/.gitignore +++ b/.gitignore @@ -13,5 +13,8 @@ node_modules # cursor bun.lockb +# content-collections +.content-collections + # Twiggy .cursor/rules/file-structure.mdc diff --git a/README.md b/README.md index 673e38a7..cacc4555 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,18 @@ +## Sponsors + +Thanks to [Vercel](https://vercel.com?utm_source=github-opencut&utm_campaign=oss) and [fal.ai](https://fal.ai?utm_source=github-opencut&utm_campaign=oss) for their support of open-source software. + + + Vercel OSS Program + + + + Powered by fal.ai + + ## Why? - **Privacy**: Your videos stay on your device @@ -38,110 +50,52 @@ ### Prerequisites -Before you begin, ensure you have the following installed on your system: - -- [Node.js](https://nodejs.org/en/) (v18 or later) - [Bun](https://bun.sh/docs/installation) - (for `npm` alternative) - [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/) -> **Note:** Docker is optional, but it's essential for running the local database and Redis services. If you're planning to run the frontend or want to contribute to frontend features, you can skip the Docker setup. If you have followed the steps below in [Setup](#setup), you're all set to go! +> **Note:** Docker is optional but recommended for running the local database and Redis. If you only want to work on frontend features, you can skip it. ### Setup -1. Fork the repository -2. Clone your fork locally -3. Navigate to the web app directory: `cd apps/web` -4. Copy `.env.example` to `.env.local`: +1. Fork and clone the repository + +2. Copy the environment file: ```bash # Unix/Linux/Mac - cp .env.example .env.local - - # Windows Command Prompt - copy .env.example .env.local + cp apps/web/.env.example apps/web/.env.local # Windows PowerShell - Copy-Item .env.example .env.local + Copy-Item apps/web/.env.example apps/web/.env.local ``` -5. Install dependencies: `bun install` -6. Start the development server: `bun dev` - -## Development Setup - -### Local Development - -1. Start the database and Redis services: +3. Start the database and Redis: ```bash - # From project root - docker-compose up -d + docker compose up -d db redis serverless-redis-http ``` -2. Navigate to the web app directory: +4. Install dependencies and start the dev server: ```bash - cd apps/web + bun install + bun dev:web ``` -3. Copy `.env.example` to `.env.local`: - - ```bash - # Unix/Linux/Mac - cp .env.example .env.local - - # Windows Command Prompt - copy .env.example .env.local - - # Windows PowerShell - Copy-Item .env.example .env.local - ``` - -4. Configure required environment variables in `.env.local`: - - **Required Variables:** - - ```bash - # Database (matches docker-compose.yaml) - DATABASE_URL="postgresql://opencut:opencut@localhost:5432/opencut" - - # Generate a secure secret for Better Auth - BETTER_AUTH_SECRET="your-generated-secret-here" - BETTER_AUTH_URL="http://localhost:3000" - - # Redis (matches docker-compose.yaml) - UPSTASH_REDIS_REST_URL="http://localhost:8079" - UPSTASH_REDIS_REST_TOKEN="example_token" - - # Marble Blog - MARBLE_WORKSPACE_KEY=cm6ytuq9x0000i803v0isidst # example organization key - NEXT_PUBLIC_MARBLE_API_URL=https://api.marblecms.com - - # Development - NODE_ENV="development" - ``` - - **Generate BETTER_AUTH_SECRET:** - - ```bash - # Unix/Linux/Mac - openssl rand -base64 32 - - # Windows PowerShell (simple method) - [System.Web.Security.Membership]::GeneratePassword(32, 0) - - # Cross-platform (using Node.js) - node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" - - # Or use an online generator: https://generate-secret.vercel.app/32 - ``` - -5. Run database migrations: `bun run db:migrate` from (inside apps/web) -6. Start the development server: `bun run dev` from (inside apps/web) - The application will be available at [http://localhost:3000](http://localhost:3000). +The `.env.example` has sensible defaults that match the Docker Compose config — it should work out of the box. + +### Self-Hosting with Docker + +To run everything (including a production build of the app) in Docker: + +```bash +docker compose up -d +``` + +The app will be available at [http://localhost:3100](http://localhost:3100). + ## Contributing We welcome contributions! While we're actively developing and refactoring certain areas, there are plenty of opportunities to contribute effectively. @@ -158,22 +112,6 @@ See our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instruc - Follow the setup instructions in CONTRIBUTING.md - Create a feature branch and submit a PR -## Sponsors - -Thanks to [Vercel](https://vercel.com?utm_source=github-opencut&utm_campaign=oss) and [fal.ai](https://fal.ai?utm_source=github-opencut&utm_campaign=oss) for their support of open-source software. - - - Vercel OSS Program - - - - Powered by fal.ai - - ---- - -[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FOpenCut-app%2FOpenCut&project-name=opencut&repository-name=opencut) - ## License [MIT LICENSE](LICENSE) 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 new file mode 100644 index 00000000..6519189c Binary files /dev/null and b/apps/web/public/effects/preview.jpg differ 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 = { + 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 ( + +
+
+
+ +
+ {releases.map((release, releaseIndex) => ( +
+ + {releaseIndex < releases.length - 1 && ( + // ml-1.5 aligns with the center of the 11px timeline dot + + )} +
+ ))} +
+
+
+ + ); +} + +type Change = { type: string; text: string }; +type Release = (typeof allChangelogs)[number]; + +function groupAndOrderChanges({ changes }: { changes: Change[] }) { + const grouped = changes.reduce>((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 ( +
+
+
+
+ +
+
+ + {release.version} - {release.date} + +
+ +
+

{release.title}

+ {release.description && ( +

+ {release.description} +

+ )} +
+ +
+ {orderedTypes.map((type) => ( +
+

+ {getSectionTitle(type)}: +

+
    + {groupedChanges[type].map((change) => ( +
  • + {change.text} +
  • + ))} +
+
+ ))} +
+
+
+ ); +} 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() { 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 (
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: , text: , stickers: , - effects: ( -
- Effects view coming soon... -
- ), + effects: , transitions: (
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 ( + + + + ); +} + +function EffectsGrid({ effects }: { effects: EffectDefinition[] }) { + return ( +
+ {effects.map((effect) => ( + + ))} +
+ ); +} + +function EffectPreviewCanvas({ effectType }: { effectType: string }) { + const canvasRef = useRef(null); + + useEffect(() => { + const render = () => { + if (canvasRef.current) { + effectPreviewService.renderPreview({ + effectType, + params: {}, + targetCanvas: canvasRef.current, + }); + } + }; + + render(); + return onPreviewImageReady({ callback: render }); + }, [effectType]); + + return ; +} + +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 = ; + + return ( + + ); +} 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 (
+ 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 ( + +
+ updateParam(value)} + onValueCommit={commitParam} + /> + +
+
+ ); +} + +export function EffectProperties({ + element, + trackId, +}: { + element: EffectElement; + trackId: string; +}) { + const definition = getEffect({ effectType: element.effectType }); + + return ( +
+ + + + {definition.params.map((param) => ( + + ))} + + +
+ ); +} 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 ; + } + if (element.type === "audio") { + return ; + } + if ( + element.type === "video" || + element.type === "image" || + element.type === "sticker" + ) { + return ; + } + if (element.type === "effect") { + return ; + } + return null; +} export function PropertiesPanel() { const editor = useEditor(); @@ -16,30 +44,19 @@ export function PropertiesPanel() { elements: selectedElements, }); + const hasSelection = selectedElements.length > 0; + return (
- {selectedElements.length > 0 ? ( - - {elementsWithTracks.map(({ track, element }) => { - if (element.type === "text") { - return ( -
- -
- ); - } - if (element.type === "audio") { - return ; - } - if (element.type === "video" || element.type === "image") { - return ( -
- -
- ); - } - return null; - })} + {hasSelection ? ( + + {elementsWithTracks.map(({ track, element }) => ( + + ))} ) : ( 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 ( + + ); +} 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(); @@ -131,6 +132,40 @@ export function SectionHeader({ return
{content}
; } +export function SectionFields({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( +
{children}
+ ); +} + +export function SectionField({ + label, + beforeLabel, + children, + className, +}: { + label: string; + beforeLabel?: React.ReactNode; + children: React.ReactNode; + className?: string; +}) { + return ( +
+
+ {beforeLabel} + +
+ {children} +
+ ); +} + 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 (
- -
-
- +
+ + + } + 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" + /> + + + - } - className="w-full" - > - - - - {BLEND_MODE_GROUPS.map((group, groupIndex) => ( - - {group.map((option) => ( - - previewBlendMode(option.value as BlendMode) - } - > - {option.label} - - ))} - {groupIndex < BLEND_MODE_GROUPS.length - 1 ? ( - - ) : null} - - ))} - - -
-
- + + + + {BLEND_MODE_GROUPS.map((group, groupIndex) => ( + + {group.map((option) => ( + + previewBlendMode(option.value as BlendMode) + } + > + {option.label} + + ))} + {groupIndex < BLEND_MODE_GROUPS.length - 1 ? ( + + ) : null} + + ))} + + + +
+
); } 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) => { - 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 (
-
-
- {isScaleLocked ? ( - <> - - - - ) : ( - } - {...scaleFieldProps} - className="flex-1" + + - )} - -
-
- - 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 - } - /> - - 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 - } - /> -
+ } + > +
+ {isScaleLocked ? ( + <> + + + + ) : ( + } + {...scaleFieldProps} + className="flex-1" + /> + )} + +
+ + + } + > +
+ + 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, + })} + /> + + 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, + })} + /> +
+
-
- } - 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} - /> -
-
+ + } + > +
+ } + 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, + })} + /> +
+
+
); 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({ - - + + +
); } @@ -83,7 +102,7 @@ function ContentSection({ ); } -function FontSection({ +function TypographySection({ element, trackId, }: { @@ -109,10 +128,11 @@ function FontSection({ }); return ( -
- - -
+
+ + + + @@ -127,89 +147,33 @@ function FontSection({ }) } /> - - - -
- - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { fontSize: value }, - }, - ], - }) - } - onValueCommit={() => editor.timeline.commitPreview()} - className="w-full" - /> - - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - fontSize: DEFAULT_TEXT_ELEMENT.fontSize, - }, - }, - ], - }) - } - isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize} - /> -
-
-
-
- ); -} - -function ColorSection({ - element, - trackId, -}: { - element: TextElement; - trackId: string; -}) { - const editor = useEditor(); - const lastSelectedColor = useRef(DEFAULT_COLOR); - - return ( -
- - -
+ + + + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { fontSize: DEFAULT_TEXT_ELEMENT.fontSize }, + }, + ], + }) + } + isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize} + icon={} + /> + + editor.timeline.commitPreview()} /> - { - 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" - : "" + + + +
+ ); +} + +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 ( +
+ + +
+ + + editor.timeline.updateElements({ + updates: [{ trackId, elementId: element.id, updates: { letterSpacing: DEFAULT_LETTER_SPACING } }], + }) } + isDefault={(element.letterSpacing ?? DEFAULT_LETTER_SPACING) === DEFAULT_LETTER_SPACING} + icon={} /> -
+ + + + editor.timeline.updateElements({ + updates: [{ trackId, elementId: element.id, updates: { lineHeight: DEFAULT_LINE_HEIGHT } }], + }) + } + isDefault={(element.lineHeight ?? DEFAULT_LINE_HEIGHT) === DEFAULT_LINE_HEIGHT} + icon={} + /> + +
+ + + ); +} + +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 ( +
+ + + + + { + const hexColor = `#${color}`; + if (color !== "transparent") { + lastSelectedColor.current = hexColor; + } + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + background: { ...element.background, color: hexColor }, + }, + }, + ], + }); + }} + onChangeEnd={() => editor.timeline.commitPreview()} + className={ + element.background.color === "transparent" + ? "pointer-events-none opacity-50" + : "" + } + /> + +
+ + + 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 + } + /> + + + + 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 + } + /> + +
+
+ + + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + background: { ...element.background, offsetX: 0 }, + }, + }, + ], + }) + } + isDefault={(element.background.offsetX ?? 0) === 0} + /> + + + + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + background: { ...element.background, offsetY: 0 }, + }, + }, + ], + }) + } + isDefault={(element.background.offsetY ?? 0) === 0} + /> + +
+ + + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + background: { + ...element.background, + cornerRadius: 0, + }, + }, + }, + ], + }) + } + isDefault={(element.background.cornerRadius ?? 0) === 0} + /> + +
); 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
Video properties
; + return ( +
+ + +
+ ); } 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() {
@@ -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({
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(); + 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 && }
@@ -197,7 +314,9 @@ export function TimelineElement({ <> } - onClick={(event) => handleRevealInMedia({ event })} + onClick={(event: React.MouseEvent) => + handleRevealInMedia({ event }) + } > Reveal media @@ -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 (
- {(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 ( + + ); + }); +} + +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 ( -
- {element.content} +
+ + {textElement.content} +
); - } - - if (element.type === "sticker") { + }, + effect: ({ element }) => ( +
+ + {element.name} +
+ ), + sticker: ({ element }) => { + const stickerElement = element as Extract< + TimelineElementType, + { type: "sticker" } + >; return (
{element.name} - {element.name} + + {stickerElement.name} +
); - } - - 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 ( - {element.name} + {audioElement.name} ); - } + }, + 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 ( + + {videoElement.name} + + ); + } + + if (mediaAsset.thumbnailUrl) { + const trackHeight = getTrackHeight({ type: track.type }); + const tileWidth = trackHeight * (16 / 9); + + return ( +
+
+
+
+
+ ); + } - const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId); - if (!mediaAsset) { return ( - {element.name} + {videoElement.name} ); - } + }, + 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 ( + + {imageElement.name} + + ); + } - 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 (
@@ -430,7 +695,7 @@ function ElementContent({
); - } + }, +}; - return ( - {element.name} - ); +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, "onClick" | "textRight"> & { action: TAction; + children: ReactNode; }) { return ( { + 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, @@ -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" />
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( )} {...props} > - - +
- + {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, - React.ComponentPropsWithoutRef + React.ComponentPropsWithoutRef & { + className?: string; + } >(({ className, ...props }, ref) => ( = { type: "text", name: "Text", @@ -24,7 +33,7 @@ export const DEFAULT_TEXT_ELEMENT: Omit = { 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 = { +export const TRACK_CONFIG: Record< + TrackType, + { + background: string; + height: number; + defaultName: string; + icon: React.ReactNode; + } +> = { video: { background: "transparent", + height: 60, + defaultName: "Video track", + icon: , }, text: { background: "bg-[#5DBAA0]", + height: 25, + defaultName: "Text track", + icon: ( + + ), }, audio: { - background: "bg-[#915DBE]", + background: "bg-[#8F5DBA]", + height: 50, + defaultName: "Audio track", + icon: ( + + ), }, sticker: { - background: "bg-amber-500", + background: "bg-[#BA5D7A]", + height: 50, + defaultName: "Sticker track", + icon: ( + + ), + }, + effect: { + background: "bg-[#5d93ba]", + height: 25, + defaultName: "Effect track", + icon: ( + + ), }, -} as const; - -export const TRACK_HEIGHTS: Record = { - 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 = { - video: , - text: ( - - ), - audio: ( - - ), - sticker: ( - - ), -} 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>; + updates: Partial; }>; 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>; + updates: Partial; }>; }): 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(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(); + 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({ 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; + headerRef: React.RefObject; 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; @@ -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; - playheadTime: number; - excludeElementId?: string; - bookmarks?: Array; - 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; - 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; - playheadTime: number; - zoomLevel: number; - excludeElementId?: string; - snapToStart?: boolean; - bookmarks?: Array; - }): 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; + isActive: boolean; +}): void { + const requestRef = useRef(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({ + channel, +}: { + channel: TChannel; +}): TChannel { + return { + ...channel, + keyframes: [...channel.keyframes].sort((leftKeyframe, rightKeyframe) => + byTimeAscending({ + leftTime: leftKeyframe.time, + rightTime: rightKeyframe.time, + }), + ), + } as TChannel; +} + +function evaluateChannelValueAtTime({ + 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 => 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 => 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; - constructor( - private trackId: string, - private elementId: string, - private updates: Partial>, - ) { + constructor({ + trackId, + elementId, + updates, + }: { + trackId: string; + elementId: string; + updates: Partial; + }) { 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(); + +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 { + 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 { - 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; + playheadTime: number; + excludeElementId?: string; + bookmarks?: Array; + 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; + 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; + playheadTime: number; + zoomLevel: number; + excludeElementId?: string; + snapToStart?: boolean; + bookmarks?: Array; +}): 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(); +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 { - 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 { - 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 { + async render({ + renderer, + time, + }: { + renderer: CanvasRenderer; + time: number; + }): Promise { + 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 { + async render({ + renderer, + time, + }: { + renderer: CanvasRenderer; + time: number; + }): Promise { + 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 { - private image?: HTMLImageElement; - private readyPromise: Promise; +interface CachedImageSource { + source: HTMLImageElement | OffscreenCanvas; + width: number; + height: number; +} - constructor(params: ImageNodeParams) { - super(params); - this.readyPromise = this.load(); - } +const imageSourceCache = new Map>(); - private async load() { +function loadImageSource( + url: string, + maxSourceSize?: number, +): Promise { + const cacheKey = `${url}::${maxSourceSize ?? "full"}`; + + const cached = imageSourceCache.get(cacheKey); + if (cached) return cached; + + const promise = (async (): Promise => { const image = new Image(); - this.image = image; await new Promise((resolve, reject) => { image.onload = () => resolve(); image.onerror = () => reject(new Error("Image load failed")); - image.src = this.params.url; + image.src = url; }); + + const naturalWidth = image.naturalWidth; + const naturalHeight = image.naturalHeight; + const exceedsLimit = + maxSourceSize && + (naturalWidth > maxSourceSize || naturalHeight > maxSourceSize); + + 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 }; + } + } + + return { source: image, width: naturalWidth, height: naturalHeight }; + })(); + + imageSourceCache.set(cacheKey, promise); + return promise; +} + +export class ImageNode extends VisualNode { + private cachedSource: Promise; + + 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.isInRange(time)) { + if (!this.isInRange({ time })) { return; } - await this.readyPromise; - - if (!this.image) { - 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 { - private image?: HTMLImageElement; - private readyPromise: Promise; +interface CachedStickerSource { + source: HTMLImageElement; + width: number; + height: number; +} - constructor(params: StickerNodeParams) { - super(params); - this.readyPromise = this.load(); - } +const stickerSourceCache = new Map>(); - private async load() { - const image = new Image(); - this.image = image; +function loadStickerSource(stickerId: string): Promise { + const cached = stickerSourceCache.get(stickerId); + if (cached) return cached; + + const promise = (async (): Promise => { const url = resolveStickerId({ - stickerId: this.params.stickerId, + stickerId, options: { width: 200, height: 200 }, }); + const image = new Image(); + await new Promise((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 { + private cachedSource: Promise; + + 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 { 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 { 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 { ? 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 { 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 { 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 { - 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,22 +6,155 @@ import { ImageNode } from "./nodes/image-node"; import { TextNode } from "./nodes/text-node"; import { StickerNode } from "./nodes/sticker-node"; import { ColorNode } from "./nodes/color-node"; -import { BlurBackgroundNode } from "./nodes/blur-background-node"; +import { CompositeEffectNode } from "./nodes/composite-effect-node"; +import { EffectLayerNode } from "./nodes/effect-layer-node"; +import type { BaseNode } from "./nodes/base-node"; import type { TBackground, TCanvasSize } from "@/types/project"; import { DEFAULT_BLUR_INTENSITY } from "@/constants/project-constants"; import { isMainTrack } from "@/lib/timeline"; +const PREVIEW_MAX_IMAGE_SIZE = 2048; +const BLUR_BACKGROUND_ZOOM_SCALE = 1.4; + +function getVisibleSortedElements({ + track, +}: { + track: TimelineTrack; +}) { + return track.elements + .filter((element) => !("hidden" in element && element.hidden)) + .slice() + .sort((a, b) => { + if (a.startTime !== b.startTime) return a.startTime - b.startTime; + return a.id.localeCompare(b.id); + }); +} + +function buildTrackNodes({ + tracks, + mediaMap, + canvasSize, + isPreview, +}: { + tracks: TimelineTrack[]; + mediaMap: Map; + canvasSize: TCanvasSize; + isPreview?: boolean; +}): BaseNode[] { + const nodes: BaseNode[] = []; + + 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) { + continue; + } + + if (mediaAsset.type === "video") { + nodes.push( + new VideoNode({ + mediaId: mediaAsset.id, + url: mediaAsset.url, + file: mediaAsset.file, + duration: element.duration, + timeOffset: element.startTime, + 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") { + nodes.push( + new ImageNode({ + url: mediaAsset.url, + duration: element.duration, + timeOffset: element.startTime, + 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") { + 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") { + nodes.push( + new StickerNode({ + stickerId: element.stickerId, + duration: element.duration, + timeOffset: element.startTime, + 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(params: BuildSceneParams) { - const { tracks, mediaAssets, duration, canvasSize, background } = params; - +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])); @@ -36,101 +169,33 @@ export function buildScene(params: BuildSceneParams) { const orderedTracksBottomToTop = orderedTracksTopToBottom.slice().reverse(); - const contentNodes = []; - - for (const track of orderedTracksBottomToTop) { - const elements = track.elements - .filter((element) => !("hidden" in element && element.hidden)) - .slice() - .sort((a, b) => { - if (a.startTime !== b.startTime) return a.startTime - b.startTime; - return a.id.localeCompare(b.id); - }); - - for (const element of elements) { - if (element.type === "video" || element.type === "image") { - const mediaAsset = mediaMap.get(element.mediaId); - if (!mediaAsset?.file || !mediaAsset?.url) { - continue; - } - - if (mediaAsset.type === "video") { - contentNodes.push( - new VideoNode({ - mediaId: mediaAsset.id, - url: mediaAsset.url, - file: mediaAsset.file, - duration: element.duration, - timeOffset: element.startTime, - trimStart: element.trimStart, - trimEnd: element.trimEnd, - transform: element.transform, - opacity: element.opacity, - blendMode: element.blendMode, - }), - ); - } - if (mediaAsset.type === "image") { - contentNodes.push( - new ImageNode({ - url: mediaAsset.url, - duration: element.duration, - timeOffset: element.startTime, - trimStart: element.trimStart, - trimEnd: element.trimEnd, - transform: element.transform, - opacity: element.opacity, - blendMode: element.blendMode, - }), - ); - } - } - - if (element.type === "text") { - contentNodes.push( - new TextNode({ - ...element, - canvasCenter: { x: canvasSize.width / 2, y: canvasSize.height / 2 }, - canvasHeight: canvasSize.height, - textBaseline: "middle", - }), - ); - } - - if (element.type === "sticker") { - contentNodes.push( - new StickerNode({ - stickerId: element.stickerId, - duration: element.duration, - timeOffset: element.startTime, - trimStart: element.trimStart, - trimEnd: element.trimEnd, - transform: element.transform, - opacity: element.opacity, - blendMode: element.blendMode, - }), - ); - } - } - } + 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(); + +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; +} + +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; +}): 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; +}): 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; +}): 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 { + 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 { + 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 {} + +export interface ColorKeyframe + extends BaseAnimationKeyframe {} + +export interface DiscreteKeyframe + extends BaseAnimationKeyframe {} + +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; + +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; +} + +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; export type CreateImageElement = Omit; export type CreateTextElement = Omit; export type CreateStickerElement = Omit; +export type CreateEffectElement = Omit; 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 }, diff --git a/bun.lock b/bun.lock index 6c46fcb9..5e76ed74 100644 --- a/bun.lock +++ b/bun.lock @@ -89,6 +89,8 @@ "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", @@ -101,6 +103,7 @@ "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", @@ -152,6 +155,12 @@ "@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="], + "@content-collections/core": ["@content-collections/core@0.14.1", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "camelcase": "^8.0.0", "chokidar": "^4.0.3", "esbuild": "^0.25.11", "gray-matter": "^4.0.3", "p-limit": "^6.1.0", "picomatch": "^4.0.2", "pluralize": "^8.0.0", "serialize-javascript": "^6.0.2", "tinyglobby": "^0.2.5", "yaml": "^2.4.5" }, "peerDependencies": { "typescript": "^5.0.2" } }, "sha512-mfviKnONzVcr1D3uasZFEk0E9N4a7liCra06imQrmKF07nGDuvrRKUFoUwUc2IGrW3EYqKF/KV06zfJRzNe3sg=="], + + "@content-collections/integrations": ["@content-collections/integrations@0.5.0", "", { "peerDependencies": { "@content-collections/core": "0.x" } }, "sha512-1en7r518sct0Y8CQ5IsuuBN4uAmtNLaWuxmseW43OxeXyj43Uu2aPBfbopjL4b5xH8WZBdDrrPmikgOl42U46A=="], + + "@content-collections/next": ["@content-collections/next@0.2.11", "", { "dependencies": { "@content-collections/integrations": "0.5.0" }, "peerDependencies": { "@content-collections/core": "0.x", "next": "^12 || ^13 || ^14 || ^15 || ^16" } }, "sha512-/JV+QEXjsWRD5Qn9uBiMUB/2ml66wCRllG6PuJUpOH7PMT4cy/Qk5UnIEpfjdINF6rxfXjUtv8z9Q8QWxR6ZQg=="], + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], @@ -160,57 +169,57 @@ "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.8", "", { "os": "aix", "cpu": "ppc64" }, "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], - "@esbuild/android-arm": ["@esbuild/android-arm@0.25.8", "", { "os": "android", "cpu": "arm" }, "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw=="], + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.8", "", { "os": "android", "cpu": "arm64" }, "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w=="], + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], - "@esbuild/android-x64": ["@esbuild/android-x64@0.25.8", "", { "os": "android", "cpu": "x64" }, "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA=="], + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw=="], + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg=="], + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.8", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA=="], + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.8", "", { "os": "freebsd", "cpu": "x64" }, "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw=="], + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.8", "", { "os": "linux", "cpu": "arm" }, "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg=="], + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w=="], + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.8", "", { "os": "linux", "cpu": "ia32" }, "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg=="], + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.8", "", { "os": "linux", "cpu": "none" }, "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ=="], + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.8", "", { "os": "linux", "cpu": "none" }, "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw=="], + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.8", "", { "os": "linux", "cpu": "ppc64" }, "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ=="], + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.8", "", { "os": "linux", "cpu": "none" }, "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg=="], + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.8", "", { "os": "linux", "cpu": "s390x" }, "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg=="], + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.8", "", { "os": "linux", "cpu": "x64" }, "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.8", "", { "os": "none", "cpu": "arm64" }, "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw=="], + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.8", "", { "os": "none", "cpu": "x64" }, "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg=="], + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.8", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ=="], + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.8", "", { "os": "openbsd", "cpu": "x64" }, "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ=="], + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.8", "", { "os": "none", "cpu": "arm64" }, "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg=="], + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.8", "", { "os": "sunos", "cpu": "x64" }, "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w=="], + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ=="], + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.8", "", { "os": "win32", "cpu": "ia32" }, "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg=="], + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.8", "", { "os": "win32", "cpu": "x64" }, "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw=="], + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], "@ffmpeg/core": ["@ffmpeg/core@0.12.10", "", {}, "sha512-dzNplnn2Nxle2c2i2rrDhqcB19q9cglCkWnoMTDN9Q9l3PvdjZWd1HfSPjCNWc/p8Q3CT+Es9fWOR0UhAeYQZA=="], @@ -300,6 +309,8 @@ "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + "@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.4", "", {}, "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="], @@ -560,12 +571,18 @@ "@types/dom-webcodecs": ["@types/dom-webcodecs@0.1.13", "", {}, "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ=="], + "@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="], + + "@types/eslint-scope": ["@types/eslint-scope@3.7.7", "", { "dependencies": { "@types/eslint": "*", "@types/estree": "*" } }, "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], @@ -592,6 +609,52 @@ "@vercel/analytics": ["@vercel/analytics@1.5.0", "", { "peerDependencies": { "@remix-run/react": "^2", "@sveltejs/kit": "^1 || ^2", "next": ">= 13", "react": "^18 || ^19 || ^19.0.0-rc", "svelte": ">= 4", "vue": "^3", "vue-router": "^4" }, "optionalPeers": ["@remix-run/react", "@sveltejs/kit", "next", "react", "svelte", "vue", "vue-router"] }, "sha512-MYsBzfPki4gthY5HnYN7jgInhAZ7Ac1cYDoRWFomwGHWEX7odTEzbtg9kf/QSo7XEsEAqlQugA6gJ2WS2DEa3g=="], + "@webassemblyjs/ast": ["@webassemblyjs/ast@1.14.1", "", { "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ=="], + + "@webassemblyjs/floating-point-hex-parser": ["@webassemblyjs/floating-point-hex-parser@1.13.2", "", {}, "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA=="], + + "@webassemblyjs/helper-api-error": ["@webassemblyjs/helper-api-error@1.13.2", "", {}, "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ=="], + + "@webassemblyjs/helper-buffer": ["@webassemblyjs/helper-buffer@1.14.1", "", {}, "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA=="], + + "@webassemblyjs/helper-numbers": ["@webassemblyjs/helper-numbers@1.13.2", "", { "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2", "@xtuc/long": "4.2.2" } }, "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA=="], + + "@webassemblyjs/helper-wasm-bytecode": ["@webassemblyjs/helper-wasm-bytecode@1.13.2", "", {}, "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA=="], + + "@webassemblyjs/helper-wasm-section": ["@webassemblyjs/helper-wasm-section@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/wasm-gen": "1.14.1" } }, "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw=="], + + "@webassemblyjs/ieee754": ["@webassemblyjs/ieee754@1.13.2", "", { "dependencies": { "@xtuc/ieee754": "^1.2.0" } }, "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw=="], + + "@webassemblyjs/leb128": ["@webassemblyjs/leb128@1.13.2", "", { "dependencies": { "@xtuc/long": "4.2.2" } }, "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw=="], + + "@webassemblyjs/utf8": ["@webassemblyjs/utf8@1.13.2", "", {}, "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ=="], + + "@webassemblyjs/wasm-edit": ["@webassemblyjs/wasm-edit@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/helper-wasm-section": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", "@webassemblyjs/wasm-opt": "1.14.1", "@webassemblyjs/wasm-parser": "1.14.1", "@webassemblyjs/wast-printer": "1.14.1" } }, "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ=="], + + "@webassemblyjs/wasm-gen": ["@webassemblyjs/wasm-gen@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/ieee754": "1.13.2", "@webassemblyjs/leb128": "1.13.2", "@webassemblyjs/utf8": "1.13.2" } }, "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg=="], + + "@webassemblyjs/wasm-opt": ["@webassemblyjs/wasm-opt@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", "@webassemblyjs/wasm-parser": "1.14.1" } }, "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw=="], + + "@webassemblyjs/wasm-parser": ["@webassemblyjs/wasm-parser@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-api-error": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/ieee754": "1.13.2", "@webassemblyjs/leb128": "1.13.2", "@webassemblyjs/utf8": "1.13.2" } }, "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ=="], + + "@webassemblyjs/wast-printer": ["@webassemblyjs/wast-printer@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" } }, "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw=="], + + "@xtuc/ieee754": ["@xtuc/ieee754@1.2.0", "", {}, "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA=="], + + "@xtuc/long": ["@xtuc/long@4.2.2", "", {}, "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="], + + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "acorn-import-phases": ["acorn-import-phases@1.0.4", "", { "peerDependencies": { "acorn": "^8.14.0" } }, "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ=="], + + "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], + + "ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], + + "ajv-keywords": ["ajv-keywords@3.5.2", "", { "peerDependencies": { "ajv": "^6.9.1" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="], + + "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], "aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="], @@ -604,14 +667,20 @@ "better-call": ["better-call@1.1.8", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.7.10", "set-cookie-parser": "^2.7.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw=="], + "big.js": ["big.js@5.2.2", "", {}, "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ=="], + "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], "botid": ["botid@1.4.2", "", { "peerDependencies": { "next": "*", "react": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["next"] }, "sha512-yiRWEdxXa5QhxzJW4lTk0lRZkbqPsVWdGrhnHLLihZf0xBEtsTUGtxLqK++IY80FX/Ye/rNMnGqBp2pl4yYU8w=="], + "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + "camelcase": ["camelcase@8.0.0", "", {}, "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA=="], + "caniuse-lite": ["caniuse-lite@1.0.30001727", "", {}, "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], @@ -624,8 +693,12 @@ "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + "chrome-trace-event": ["chrome-trace-event@1.0.4", "", {}, "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ=="], + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], "classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="], @@ -638,6 +711,8 @@ "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + "commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + "country-flag-icons": ["country-flag-icons@1.5.19", "", {}, "sha512-D/ZkRyj+ywJC6b2IrAN3/tpbReMUqmuRLlcKFoY/o0+EPQN9Ev/e8tV+D3+9scvu/tarxwLErNwS73C3yzxs/g=="], "cross-env": ["cross-env@7.0.3", "", { "dependencies": { "cross-spawn": "^7.0.1" }, "bin": { "cross-env": "src/bin/cross-env.js", "cross-env-shell": "src/bin/cross-env-shell.js" } }, "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw=="], @@ -708,12 +783,16 @@ "drizzle-orm": ["drizzle-orm@0.44.3", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-8nIiYQxOpgUicEL04YFojJmvC4DNO4KoyXsEIqN44+g6gNBr6hmVpWk3uyAt4CaTiRGDwoU+alfqNNeonLAFOQ=="], + "electron-to-chromium": ["electron-to-chromium@1.5.302", "", {}, "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg=="], + "embla-carousel": ["embla-carousel@8.6.0", "", {}, "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA=="], "embla-carousel-react": ["embla-carousel-react@8.6.0", "", { "dependencies": { "embla-carousel": "8.6.0", "embla-carousel-reactive-utils": "8.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA=="], "embla-carousel-reactive-utils": ["embla-carousel-reactive-utils@8.6.0", "", { "peerDependencies": { "embla-carousel": "8.6.0" } }, "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A=="], + "emojis-list": ["emojis-list@3.0.0", "", {}, "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q=="], + "enhanced-resolve": ["enhanced-resolve@5.18.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ=="], "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], @@ -722,22 +801,46 @@ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + "es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="], + "es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="], - "esbuild": ["esbuild@0.25.8", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.8", "@esbuild/android-arm": "0.25.8", "@esbuild/android-arm64": "0.25.8", "@esbuild/android-x64": "0.25.8", "@esbuild/darwin-arm64": "0.25.8", "@esbuild/darwin-x64": "0.25.8", "@esbuild/freebsd-arm64": "0.25.8", "@esbuild/freebsd-x64": "0.25.8", "@esbuild/linux-arm": "0.25.8", "@esbuild/linux-arm64": "0.25.8", "@esbuild/linux-ia32": "0.25.8", "@esbuild/linux-loong64": "0.25.8", "@esbuild/linux-mips64el": "0.25.8", "@esbuild/linux-ppc64": "0.25.8", "@esbuild/linux-riscv64": "0.25.8", "@esbuild/linux-s390x": "0.25.8", "@esbuild/linux-x64": "0.25.8", "@esbuild/netbsd-arm64": "0.25.8", "@esbuild/netbsd-x64": "0.25.8", "@esbuild/openbsd-arm64": "0.25.8", "@esbuild/openbsd-x64": "0.25.8", "@esbuild/openharmony-arm64": "0.25.8", "@esbuild/sunos-x64": "0.25.8", "@esbuild/win32-arm64": "0.25.8", "@esbuild/win32-ia32": "0.25.8", "@esbuild/win32-x64": "0.25.8" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q=="], + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + "eslint-scope": ["eslint-scope@5.1.1", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" } }, "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw=="], + + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@4.3.0", "", {}, "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="], + "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-equals": ["fast-equals@5.2.2", "", {}, "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw=="], + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "feed": ["feed@5.1.0", "", { "dependencies": { "xml-js": "^1.6.11" } }, "sha512-qGNhgYygnefSkAHHrNHqC7p3R8J0/xQDS/cYUud8er/qD9EFGWyCdUDfULHTJQN1d3H3WprzVwMc9MfB4J50Wg=="], "flatbuffers": ["flatbuffers@25.9.23", "", {}, "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ=="], @@ -752,6 +855,8 @@ "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], + "glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="], + "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="], "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], @@ -760,8 +865,12 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="], + "guid-typescript": ["guid-typescript@1.0.9", "", {}, "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], "hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="], @@ -804,20 +913,34 @@ "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], + "is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], + "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "jest-worker": ["jest-worker@27.5.1", "", { "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg=="], + "jiti": ["jiti@2.5.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w=="], "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], + + "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], + "kysely": ["kysely@0.28.9", "", {}, "sha512-3BeXMoiOhpOwu62CiVpO6lxfq4eS6KMYfQdMsN/2kUCRNuF2YiEr7u0HLHaQU+O4Xu8YXE3bHVkwaQ85i72EuA=="], "libphonenumber-js": ["libphonenumber-js@1.12.10", "", {}, "sha512-E91vHJD61jekHHR/RF/E83T/CMoaLXT7cwYA75T4gim4FZjnM6hbJjVIGg7chqlSqRsSvQ3izGmOjHy1SQzcGQ=="], @@ -844,6 +967,10 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="], + "loader-runner": ["loader-runner@4.3.1", "", {}, "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q=="], + + "loader-utils": ["loader-utils@2.0.4", "", { "dependencies": { "big.js": "^5.2.2", "emojis-list": "^3.0.0", "json5": "^2.1.2" } }, "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw=="], + "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], "lodash.castarray": ["lodash.castarray@4.4.0", "", {}, "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q=="], @@ -882,6 +1009,8 @@ "mediabunny": ["mediabunny@1.29.1", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-RrlKs69MxRGa/l9cMGeI4hzuTSgchOGFHk9lIAuu9EcbSdJ05gDbRsTY67dojAiyUP3ic4PExij5OqDxqSv82Q=="], + "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], + "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], @@ -924,6 +1053,10 @@ "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], "minizlib": ["minizlib@3.0.2", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA=="], @@ -942,10 +1075,14 @@ "nanostores": ["nanostores@1.1.0", "", {}, "sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA=="], + "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], + "next": ["next@16.1.3", "", { "dependencies": { "@next/env": "16.1.3", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.3", "@next/swc-darwin-x64": "16.1.3", "@next/swc-linux-arm64-gnu": "16.1.3", "@next/swc-linux-arm64-musl": "16.1.3", "@next/swc-linux-x64-gnu": "16.1.3", "@next/swc-linux-x64-musl": "16.1.3", "@next/swc-win32-arm64-msvc": "16.1.3", "@next/swc-win32-x64-msvc": "16.1.3", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-gthG3TRD+E3/mA0uDQb9lqBmx1zVosq5kIwxNN6+MRNd085GzD+9VXMPUs+GGZCbZ+GDZdODUq4Pm7CTXK6ipw=="], "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], + "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], @@ -956,6 +1093,8 @@ "onnxruntime-web": ["onnxruntime-web@1.22.0-dev.20250409-89f8206ba4", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.22.0-dev.20250409-89f8206ba4", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ=="], + "p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="], + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], @@ -980,8 +1119,12 @@ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="], + "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], "postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="], @@ -1002,10 +1145,16 @@ "protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "radix-ui": ["radix-ui@1.4.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.8", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-menubar": "1.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-one-time-password-field": "0.1.8", "@radix-ui/react-password-toggle-field": "0.1.3", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toolbar": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA=="], "raf-schd": ["raf-schd@4.0.3", "", {}, "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ=="], + "randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="], + + "raw-loader": ["raw-loader@4.0.2", "", { "dependencies": { "loader-utils": "^2.0.0", "schema-utils": "^3.0.0" }, "peerDependencies": { "webpack": "^4.0.0 || ^5.0.0" } }, "sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA=="], + "react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="], "react-day-picker": ["react-day-picker@8.10.1", "", { "peerDependencies": { "date-fns": "^2.28.0 || ^3.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA=="], @@ -1038,6 +1187,8 @@ "react-window": ["react-window@2.2.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-SH5nvfUQwGHYyriDUAOt7wfPsfG9Qxd6OdzQxl5oQ4dsSsUicqQvjV7dR+NqZ4coY0fUn3w1jnC5PwzIUWEg5w=="], + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + "recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="], "recharts-scale": ["recharts-scale@0.4.5", "", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="], @@ -1058,22 +1209,32 @@ "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="], "rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "sax": ["sax@1.4.1", "", {}, "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "schema-utils": ["schema-utils@3.3.0", "", { "dependencies": { "@types/json-schema": "^7.0.8", "ajv": "^6.12.5", "ajv-keywords": "^3.5.2" } }, "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg=="], + + "section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="], + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="], + "serialize-javascript": ["serialize-javascript@6.0.2", "", { "dependencies": { "randombytes": "^2.1.0" } }, "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g=="], + "set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="], "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], @@ -1094,28 +1255,38 @@ "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], - "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], + "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + "strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="], + "style-to-js": ["style-to-js@1.1.17", "", { "dependencies": { "style-to-object": "1.0.9" } }, "sha512-xQcBGDxJb6jjFCTzvQtfiPn6YvvP2O8U1MDIPNfJQlWMYfktPy+iGsHE7cssjs7y84d9fQaK4UF3RIJaAHSoYA=="], "style-to-object": ["style-to-object@1.0.9", "", { "dependencies": { "inline-style-parser": "0.2.4" } }, "sha512-G4qppLgKu/k6FwRpHiGiKPaPTFcG3g4wNVX/Qsfu+RqQM30E7Tyu/TEgxcL9PNLF5pdRLwQdE3YKKf+KF2Dzlw=="], "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], + "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + "tailwind-merge": ["tailwind-merge@2.6.0", "", {}, "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA=="], "tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="], "tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="], - "tapable": ["tapable@2.2.2", "", {}, "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg=="], + "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], "tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="], + "terser": ["terser@5.46.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg=="], + + "terser-webpack-plugin": ["terser-webpack-plugin@5.3.16", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", "serialize-javascript": "^6.0.2", "terser": "^5.31.1" }, "peerDependencies": { "webpack": "^5.1.0" } }, "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q=="], + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], @@ -1158,6 +1329,10 @@ "unist-util-visit-parents": ["unist-util-visit-parents@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], "use-deep-compare-effect": ["use-deep-compare-effect@1.8.1", "", { "dependencies": { "@babel/runtime": "^7.12.5", "dequal": "^2.0.2" }, "peerDependencies": { "react": ">=16.13" } }, "sha512-kbeNVZ9Zkc0RFGpfMN3MNfaKNvcLNyxOAAd9O4CBZ+kCBXXscn9s/4I+8ytUER4RDpEYs5+O6Rs4PqiZ+rHr5Q=="], @@ -1178,10 +1353,16 @@ "victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="], + "watchpack": ["watchpack@2.5.1", "", { "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" } }, "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg=="], + "wavesurfer.js": ["wavesurfer.js@7.10.0", "", {}, "sha512-GiyAHdorqGtUYG5fe4BfTf5lmtSLhrXoHeNlMsR80JOiOZxOrIOv9QaIR8RnqlleJ6D8R9cqvZKR9lfJcWcapg=="], "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], + "webpack": ["webpack@5.105.3", "", { "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.16.0", "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.19.0", "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", "terser-webpack-plugin": "^5.3.16", "watchpack": "^2.5.1", "webpack-sources": "^3.3.4" }, "bin": { "webpack": "bin/webpack.js" } }, "sha512-LLBBA4oLmT7sZdHiYE/PeVuifOxYyE2uL/V+9VQP7YSYdJU7bSf7H8bZRRxW8kEPMkmVjnrXmoR3oejIdX0xbg=="], + + "webpack-sources": ["webpack-sources@3.3.4", "", {}, "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "xml-js": ["xml-js@1.6.11", "", { "dependencies": { "sax": "^1.2.4" }, "bin": { "xml-js": "./bin/cli.js" } }, "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g=="], @@ -1190,6 +1371,10 @@ "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], + + "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], + "zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], "zustand": ["zustand@5.0.6", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-ihAqNeUVhe0MAD+X8M5UzqyZ9k3FFZLBTtqo6JLPwV53cbRB/mJwBI0PxcIgqhBBHlEs8G45OTDTMq3gNcLq3A=="], @@ -1310,12 +1495,22 @@ "@upstash/core-analytics/@upstash/redis": ["@upstash/redis@1.35.1", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-sIMuAMU9IYbE2bkgDby8KLoQKRiBMXn0moXxqLvUmQ7VUu2CvulZLtK8O0x3WQZFvvZhU5sRC2/lOVZdGfudkA=="], + "ajv-formats/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "browserslist/caniuse-lite": ["caniuse-lite@1.0.30001774", "", {}, "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA=="], + "cmdk/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw=="], "cmdk/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], "dom-helpers/csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + "drizzle-kit/esbuild": ["esbuild@0.25.8", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.8", "@esbuild/android-arm": "0.25.8", "@esbuild/android-arm64": "0.25.8", "@esbuild/android-x64": "0.25.8", "@esbuild/darwin-arm64": "0.25.8", "@esbuild/darwin-x64": "0.25.8", "@esbuild/freebsd-arm64": "0.25.8", "@esbuild/freebsd-x64": "0.25.8", "@esbuild/linux-arm": "0.25.8", "@esbuild/linux-arm64": "0.25.8", "@esbuild/linux-ia32": "0.25.8", "@esbuild/linux-loong64": "0.25.8", "@esbuild/linux-mips64el": "0.25.8", "@esbuild/linux-ppc64": "0.25.8", "@esbuild/linux-riscv64": "0.25.8", "@esbuild/linux-s390x": "0.25.8", "@esbuild/linux-x64": "0.25.8", "@esbuild/netbsd-arm64": "0.25.8", "@esbuild/netbsd-x64": "0.25.8", "@esbuild/openbsd-arm64": "0.25.8", "@esbuild/openbsd-x64": "0.25.8", "@esbuild/openharmony-arm64": "0.25.8", "@esbuild/sunos-x64": "0.25.8", "@esbuild/win32-arm64": "0.25.8", "@esbuild/win32-ia32": "0.25.8", "@esbuild/win32-x64": "0.25.8" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q=="], + + "enhanced-resolve/tapable": ["tapable@2.2.2", "", {}, "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg=="], + + "esrecurse/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + "lightningcss/detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="], "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], @@ -1338,8 +1533,18 @@ "recharts/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + "roarr/sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], + + "terser-webpack-plugin/schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="], + + "tsx/esbuild": ["esbuild@0.25.8", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.8", "@esbuild/android-arm": "0.25.8", "@esbuild/android-arm64": "0.25.8", "@esbuild/android-x64": "0.25.8", "@esbuild/darwin-arm64": "0.25.8", "@esbuild/darwin-x64": "0.25.8", "@esbuild/freebsd-arm64": "0.25.8", "@esbuild/freebsd-x64": "0.25.8", "@esbuild/linux-arm": "0.25.8", "@esbuild/linux-arm64": "0.25.8", "@esbuild/linux-ia32": "0.25.8", "@esbuild/linux-loong64": "0.25.8", "@esbuild/linux-mips64el": "0.25.8", "@esbuild/linux-ppc64": "0.25.8", "@esbuild/linux-riscv64": "0.25.8", "@esbuild/linux-s390x": "0.25.8", "@esbuild/linux-x64": "0.25.8", "@esbuild/netbsd-arm64": "0.25.8", "@esbuild/netbsd-x64": "0.25.8", "@esbuild/openbsd-arm64": "0.25.8", "@esbuild/openbsd-x64": "0.25.8", "@esbuild/openharmony-arm64": "0.25.8", "@esbuild/sunos-x64": "0.25.8", "@esbuild/win32-arm64": "0.25.8", "@esbuild/win32-ia32": "0.25.8", "@esbuild/win32-x64": "0.25.8" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q=="], + "vaul/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw=="], + "webpack/enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="], + + "webpack/schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="], + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], @@ -1450,6 +1655,8 @@ "@types/pg/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "cmdk/@radix-ui/react-dialog/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], "cmdk/@radix-ui/react-dialog/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="], @@ -1462,8 +1669,116 @@ "cmdk/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "drizzle-kit/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.8", "", { "os": "aix", "cpu": "ppc64" }, "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA=="], + + "drizzle-kit/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.8", "", { "os": "android", "cpu": "arm" }, "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw=="], + + "drizzle-kit/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.8", "", { "os": "android", "cpu": "arm64" }, "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w=="], + + "drizzle-kit/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.8", "", { "os": "android", "cpu": "x64" }, "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA=="], + + "drizzle-kit/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw=="], + + "drizzle-kit/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg=="], + + "drizzle-kit/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.8", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA=="], + + "drizzle-kit/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.8", "", { "os": "freebsd", "cpu": "x64" }, "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw=="], + + "drizzle-kit/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.8", "", { "os": "linux", "cpu": "arm" }, "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg=="], + + "drizzle-kit/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w=="], + + "drizzle-kit/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.8", "", { "os": "linux", "cpu": "ia32" }, "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg=="], + + "drizzle-kit/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.8", "", { "os": "linux", "cpu": "none" }, "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ=="], + + "drizzle-kit/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.8", "", { "os": "linux", "cpu": "none" }, "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw=="], + + "drizzle-kit/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.8", "", { "os": "linux", "cpu": "ppc64" }, "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ=="], + + "drizzle-kit/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.8", "", { "os": "linux", "cpu": "none" }, "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg=="], + + "drizzle-kit/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.8", "", { "os": "linux", "cpu": "s390x" }, "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg=="], + + "drizzle-kit/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.8", "", { "os": "linux", "cpu": "x64" }, "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ=="], + + "drizzle-kit/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.8", "", { "os": "none", "cpu": "arm64" }, "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw=="], + + "drizzle-kit/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.8", "", { "os": "none", "cpu": "x64" }, "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg=="], + + "drizzle-kit/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.8", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ=="], + + "drizzle-kit/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.8", "", { "os": "openbsd", "cpu": "x64" }, "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ=="], + + "drizzle-kit/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.8", "", { "os": "none", "cpu": "arm64" }, "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg=="], + + "drizzle-kit/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.8", "", { "os": "sunos", "cpu": "x64" }, "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w=="], + + "drizzle-kit/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ=="], + + "drizzle-kit/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.8", "", { "os": "win32", "cpu": "ia32" }, "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg=="], + + "drizzle-kit/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.8", "", { "os": "win32", "cpu": "x64" }, "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw=="], + "next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "terser-webpack-plugin/schema-utils/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "terser-webpack-plugin/schema-utils/ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "ajv": "^8.8.2" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="], + + "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.8", "", { "os": "aix", "cpu": "ppc64" }, "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA=="], + + "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.8", "", { "os": "android", "cpu": "arm" }, "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw=="], + + "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.8", "", { "os": "android", "cpu": "arm64" }, "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w=="], + + "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.8", "", { "os": "android", "cpu": "x64" }, "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA=="], + + "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw=="], + + "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg=="], + + "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.8", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA=="], + + "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.8", "", { "os": "freebsd", "cpu": "x64" }, "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw=="], + + "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.8", "", { "os": "linux", "cpu": "arm" }, "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg=="], + + "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w=="], + + "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.8", "", { "os": "linux", "cpu": "ia32" }, "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg=="], + + "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.8", "", { "os": "linux", "cpu": "none" }, "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ=="], + + "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.8", "", { "os": "linux", "cpu": "none" }, "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw=="], + + "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.8", "", { "os": "linux", "cpu": "ppc64" }, "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ=="], + + "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.8", "", { "os": "linux", "cpu": "none" }, "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg=="], + + "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.8", "", { "os": "linux", "cpu": "s390x" }, "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg=="], + + "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.8", "", { "os": "linux", "cpu": "x64" }, "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ=="], + + "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.8", "", { "os": "none", "cpu": "arm64" }, "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw=="], + + "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.8", "", { "os": "none", "cpu": "x64" }, "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg=="], + + "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.8", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ=="], + + "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.8", "", { "os": "openbsd", "cpu": "x64" }, "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ=="], + + "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.8", "", { "os": "none", "cpu": "arm64" }, "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg=="], + + "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.8", "", { "os": "sunos", "cpu": "x64" }, "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w=="], + + "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ=="], + + "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.8", "", { "os": "win32", "cpu": "ia32" }, "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg=="], + + "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.8", "", { "os": "win32", "cpu": "x64" }, "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw=="], + "vaul/@radix-ui/react-dialog/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], "vaul/@radix-ui/react-dialog/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="], @@ -1475,5 +1790,13 @@ "vaul/@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], "vaul/@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "webpack/schema-utils/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "webpack/schema-utils/ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "ajv": "^8.8.2" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="], + + "terser-webpack-plugin/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "webpack/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], } } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..ae75391e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,94 @@ +services: + db: + image: postgres:17 + restart: unless-stopped + environment: + POSTGRES_USER: opencut + POSTGRES_PASSWORD: opencut + POSTGRES_DB: opencut + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U opencut"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 10s + + redis: + image: redis:7-alpine + restart: unless-stopped + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 10s + + serverless-redis-http: + image: hiett/serverless-redis-http:latest + ports: + - "8079:80" + environment: + SRH_MODE: env + SRH_TOKEN: example_token + SRH_CONNECTION_STRING: "redis://redis:6379" + depends_on: + redis: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget --spider -q http://127.0.0.1:80 || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 10s + + web: + build: + context: . + dockerfile: ./apps/web/Dockerfile + args: + - FREESOUND_CLIENT_ID=${FREESOUND_CLIENT_ID} + - FREESOUND_API_KEY=${FREESOUND_API_KEY} + restart: unless-stopped + ports: + - "3100:3000" + environment: + - NODE_ENV=production + - DATABASE_URL=postgresql://opencut:opencut@db:5432/opencut + - BETTER_AUTH_SECRET=your-production-secret-key-here + - UPSTASH_REDIS_REST_URL=http://serverless-redis-http:80 + - UPSTASH_REDIS_REST_TOKEN=example_token + - NEXT_PUBLIC_SITE_URL=http://localhost:3100 + - NEXT_PUBLIC_MARBLE_API_URL=https://api.marblecms.com + - MARBLE_WORKSPACE_KEY=${MARBLE_WORKSPACE_KEY:-placeholder} + - FREESOUND_CLIENT_ID=${FREESOUND_CLIENT_ID} + - FREESOUND_API_KEY=${FREESOUND_API_KEY} + # Transcription (Optional - leave blank to disable auto-captions) + - CLOUDFLARE_ACCOUNT_ID=${CLOUDFLARE_ACCOUNT_ID:-placeholder} + - R2_ACCESS_KEY_ID=${R2_ACCESS_KEY_ID:-placeholder} + - R2_SECRET_ACCESS_KEY=${R2_SECRET_ACCESS_KEY:-placeholder} + - R2_BUCKET_NAME=${R2_BUCKET_NAME:-opencut-transcription} + - MODAL_TRANSCRIPTION_URL=${MODAL_TRANSCRIPTION_URL:-http://localhost:0} + depends_on: + db: + condition: service_healthy + serverless-redis-http: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:3000/api/health || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s + +volumes: + postgres_data: + +networks: + default: + name: opencut-network diff --git a/docs/effects-renderer.md b/docs/effects-renderer.md new file mode 100644 index 00000000..fd41282b --- /dev/null +++ b/docs/effects-renderer.md @@ -0,0 +1,64 @@ +# Effects & WebGL Renderer + +## How to add a new effect + +1. Create a new file in `apps/web/src/lib/effects/definitions/` (e.g. `brightness.ts`) +2. Export an `EffectDefinition` — see `blur.ts` as a reference +3. Register it in `apps/web/src/lib/effects/definitions/index.ts` + +An effect definition has: +- `type` — unique string identifier +- `name` — display name +- `keywords` — for search +- `params` — user-facing controls (sliders, toggles, etc.) +- `renderer` — always `webgl` + +All effects use WebGL. Even simple single-value effects like brightness or contrast are trivial shaders — there's no reason to leave the GPU pipeline for them. + +## Single-pass vs multi-pass + +The `webgl` renderer supports a `passes` array. Single-pass effects (e.g. color grading) just have one entry. Multi-pass is needed when an effect has to process its own output — blur (H then V), bloom (extract → blur → composite), glow, etc. + +```typescript +renderer: { + type: "webgl", + passes: [ + { fragmentShader: myShader, uniforms: ({ effectParams }) => ({ ... }) }, + ], +} +``` + +All WebGL rendering — both the main renderer and the effect preview — goes through `applyMultiPassEffect` in `apps/web/src/services/renderer/webgl-utils.ts`. Don't add a new rendering path somewhere else; update that function if needed. + +## Writing fragment shaders + +Shaders live in `apps/web/src/lib/effects/definitions/`. The shared vertex shader (`effect.vert.glsl`) maps clip space to UV coordinates — don't replace it unless you have a specific reason. + +Available uniforms (automatically injected, no need to pass them manually): +- `u_texture` — the input texture (sampler2D) +- `u_resolution` — canvas size in pixels (vec2) + +Any additional uniforms come from the `uniforms()` function in the pass definition. + +**Sampling density — the most common mistake** + +Always use a step of 1 texel when sampling neighbors. Do not scale the step size with the blur radius or intensity — it creates visible discrete artifacts (ghosting/glow look) because there are large gaps between samples that the GPU fills with linear interpolation instead of your intended curve. + +```glsl +// correct — step is always 1 texel, loop count controls radius +for (int i = -30; i <= 30; i++) { + color += texture2D(u_texture, v_texCoord + texelSize * u_direction * float(i)) * weight; +} + +// wrong — stepping 6 texels at a time looks ghosty at high intensity +vec2 offset = texelSize * u_direction * u_radius; +color += texture2D(u_texture, v_texCoord + offset * 2.0) * someWeight; +``` + +If you need a large radius with a fixed kernel size, increase the number of samples rather than the step. + +## Y-flip and coordinate systems + +Source textures (uploaded from canvas) are Y-flipped via `UNPACK_FLIP_Y_WEBGL`. Intermediate FBO textures (rendered by WebGL between passes) are not. In practice this cancels out correctly as long as you use the shared vertex shader — it maps clip space Y consistently so both texture types sample correctly. + +If you write a custom vertex shader or do manual coordinate math, be aware that canvas and WebGL have opposite Y origins (canvas: top-left, WebGL: bottom-left). Getting this wrong produces an upside-down result with no obvious error. diff --git a/package.json b/package.json index 213b6870..521b90b6 100644 --- a/package.json +++ b/package.json @@ -1,33 +1,33 @@ { - "name": "opencut", - "packageManager": "bun@1.2.18", - "workspaces": [ - "apps/*", - "packages/*" - ], - "scripts": { - "dev:web": "turbo run dev --filter=@opencut/web", - "build:web": "turbo run build --filter=@opencut/web", - "dev:tools": "turbo run dev --filter=@opencut/tools", - "build:tools": "turbo run build --filter=@opencut/tools", - "start:tools": "turbo run start --filter=@opencut/tools", - "lint:web": "biome lint apps/web/src --max-diagnostics=1000", - "lint:web:fix": "biome lint apps/web/src --write --max-diagnostics=1000", - "format:web": "biome format apps/web/src/services/renderer --write --max-diagnostics=1000", - "test": "bun test", - "generate:fonts": "npx tsx apps/web/scripts/generate-font-sprites.ts" - }, - "dependencies": { - "@types/react": "^19.2.10", - "@types/react-dom": "^19.2.3", - "better-auth": "^1.4.15", - "next": "^16.1.3" - }, - "devDependencies": { - "turbo": "^2.7.5", - "typescript": "5.8.3" - }, - "trustedDependencies": [ - "@tailwindcss/oxide" - ] + "name": "opencut", + "packageManager": "bun@1.2.18", + "workspaces": [ + "apps/*", + "packages/*" + ], + "scripts": { + "dev:web": "turbo run dev --filter=@opencut/web", + "build:web": "turbo run build --filter=@opencut/web", + "dev:tools": "turbo run dev --filter=@opencut/tools", + "build:tools": "turbo run build --filter=@opencut/tools", + "start:tools": "turbo run start --filter=@opencut/tools", + "lint:web": "biome lint apps/web/src --max-diagnostics=1000", + "lint:web:fix": "biome lint apps/web/src --write --max-diagnostics=1000", + "format:web": "biome format apps/web/src/services/renderer --write --max-diagnostics=1000", + "test": "bun test", + "generate:fonts": "npx tsx apps/web/scripts/generate-font-sprites.ts" + }, + "dependencies": { + "@types/react": "^19.2.10", + "@types/react-dom": "^19.2.3", + "better-auth": "^1.4.15", + "next": "^16.1.3" + }, + "devDependencies": { + "turbo": "^2.7.5", + "typescript": "5.8.3" + }, + "trustedDependencies": [ + "@tailwindcss/oxide" + ] } diff --git a/packages/ui/src/icons/ui.tsx b/packages/ui/src/icons/ui.tsx index 382ec7bb..e9d4cb39 100644 --- a/packages/ui/src/icons/ui.tsx +++ b/packages/ui/src/icons/ui.tsx @@ -65,51 +65,6 @@ export function OcCheckerboardIcon({ ); } -export function OcFontWeightIcon({ - className = "", - size = 32, -}: { - className?: string; - size?: number; -}) { - return ( - - Font Weight - - - - - ); -} - export function OcSlidersVerticalIcon({ className = "", size = 32, @@ -203,3 +158,84 @@ export function OcSocialIcon({ ); } + +export function OcTextWidthIcon({ + className = "", + size = 32, +}: { + className?: string; + size?: number; +}) { + return ( + + Text width + + + ); +} + +export function OcTextHeightIcon({ + className = "", + size = 32, +}: { + className?: string; + size?: number; +}) { + return ( + + Text height + + + ); +} + +export function OcFontIcon({ + className = "", + size = 32, +}: { + className?: string; + size?: number; +}) { + return ( + + Font + + + ); +}